From 4e2c107ac5138872daff0ac3b28a1694d33b94e0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 17 Oct 2026 04:02:48 +0000 Subject: [PATCH 01/53] Stored paths: use resolve_absolute before opening linked files --- crates/io/src/doc_path.rs | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/crates/io/src/doc_path.rs b/crates/io/src/doc_path.rs index 37e7daf214..ce6cc0fdcb 100644 --- a/crates/io/src/doc_path.rs +++ b/crates/io/src/doc_path.rs @@ -35,13 +35,11 @@ impl std::error::Error for NotFollowed {} /// a relative `document` is taken from the working directory, where it was opened from. pub fn resolve(stored: &str, document: Option<&Path>) -> Result { let document = document.map(|d| if d.is_relative() { std::path::absolute(d).unwrap_or_else(|_| d.to_path_buf()) } else { d.to_path_buf() }); - let path = craft_docpath::resolve(stored, document.as_deref()).map_err(NotFollowed::Refused)?; - // A path that is still relative would be taken from the working directory, which the - // document does not choose. - if path.is_relative() { - return Err(NotFollowed::NoDocumentFolder); - } - Ok(path) + craft_docpath::resolve_absolute(stored, document.as_deref()).map_err(|r| match r { + // Without a document, a relative path resolves but stays relative. + Refused::Invalid if document.is_none() && craft_docpath::resolve(stored, None).is_ok() => NotFollowed::NoDocumentFolder, + r => NotFollowed::Refused(r), + }) } #[cfg(test)] @@ -55,5 +53,17 @@ mod tests { assert_eq!(resolve("logo.png", None), Err(NotFollowed::NoDocumentFolder)); assert_eq!(resolve(r"\\192.0.2.1\share\x.png", Some(&doc)), Err(NotFollowed::Refused(Refused::Network))); assert_eq!(resolve("//192.0.2.1/share/x.png", None), Err(NotFollowed::Refused(Refused::Network))); + assert_eq!(resolve("jar:logo.png", None), Err(NotFollowed::Refused(Refused::Url))); + // Colons in names: an alternate data stream or a URL on Windows, a plain character elsewhere. + if cfg!(windows) { + assert_eq!(resolve("img/fig:1.png", Some(&doc)), Err(NotFollowed::Refused(Refused::Invalid))); + assert_eq!(resolve("chapter1:intro.png", None), Err(NotFollowed::Refused(Refused::Url))); + } else { + assert_eq!(resolve("img/fig:1.png", Some(&doc)), Ok(std::env::temp_dir().join("art").join("img").join("fig:1.png"))); + assert_eq!(resolve("chapter1:intro.png", None), Err(NotFollowed::NoDocumentFolder)); + } + // On Unix a Windows path names no file: it is refused, not reported as relative. + #[cfg(unix)] + assert_eq!(resolve(r"C:\art\logo.png", Some(&doc)), Err(NotFollowed::Refused(Refused::Invalid))); } } From 6401cfdbe18ae5d9301cb610fedf356d6f773193 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 17 Oct 2026 04:02:48 +0000 Subject: [PATCH 02/53] CI: check that git dependencies come from storytold --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba8243353d..07f54982a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,11 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 + - name: Git dependencies come from storytold + run: | + if grep -E '^source = "git\+' Cargo.lock | grep -v '"git+https://github.com/storytold/'; then + echo "Cargo.lock has a git dependency outside github.com/storytold"; exit 1 + fi - id: pick env: EVENT: ${{ github.event_name }} From 8f9ebc3136c549f8c09121cb15a8d35fc78e4ef2 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 17 Oct 2026 04:07:06 +0000 Subject: [PATCH 03/53] CI: release only with git dependencies from storytold --- .github/workflows/release.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bfdca9e3e9..4666f5a851 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,21 @@ concurrency: cancel-in-progress: false jobs: + git-sources: + name: Git dependencies come from storytold + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - name: Check Cargo.lock + run: | + if grep -E '^source = "git\+' Cargo.lock | grep -v '"git+https://github.com/storytold/'; then + echo "Cargo.lock has a git dependency outside github.com/storytold"; exit 1 + fi + release: + needs: git-sources # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/release.yml@main with: From 42d8aad56f0c3909d0217bf93fed88aae7eea492 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 21:44:52 -0400 Subject: [PATCH 04/53] security(psd): index slice IDs for trailing descriptor lookup The version-6 slices parser matched each trailing descriptor ID against the slice list with iter_mut().find(), an O(n*m) scan over two file-controlled collections. A crafted resource with many slices and many unmatched descriptor IDs could therefore cause quadratic CPU work during import. Build an ID-to-first-index map once and use it for descriptor lookups. or_insert preserves the existing first-match behavior for duplicate IDs. Finding: cf3f9ceb-d6df-4087-a2d6-dbe0f12657c3 (CWE-407) --- crates/psd/src/slices.rs | 39 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/crates/psd/src/slices.rs b/crates/psd/src/slices.rs index 54bf4c8fcd..5d9cd50b39 100644 --- a/crates/psd/src/slices.rs +++ b/crates/psd/src/slices.rs @@ -10,6 +10,8 @@ //! version reads. Unknown fields are not kept here; callers that need a byte-exact round trip //! keep the raw resource while the slices are unchanged. +use std::collections::HashMap; + use crate::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor}; use crate::error::{PsdError, Result}; use crate::io::{Reader, WriteExt}; @@ -274,13 +276,22 @@ impl SlicesResource { && let Ok(vd) = VersionedDescriptor::read(r) && let Some(Value::List(items)) = vd.descriptor.get("slices") { + // Index each slice ID to its first occurrence once, so matching trailing + // descriptors against slices is linear rather than quadratic. Both the slice + // count and the descriptor-ID list are file-controlled, so a plain + // `find()` per descriptor is O(n*m) and lets a crafted resource burn CPU. + // `or_insert` preserves the original first-match behavior for duplicate IDs. + let mut id_to_index: HashMap = HashMap::with_capacity(slices.len()); + for (i, s) in slices.iter().enumerate() { + id_to_index.entry(s.id).or_insert(i); + } for v in items { let Value::Descriptor(d) = v else { continue }; // An entry without a usable id belongs to no slice (not to slice 0). let Some(id) = d_u32(d, "sliceID") else { continue }; let extra = slice_from_descriptor(d); - if let Some(s) = slices.iter_mut().find(|s| s.id == id) { - s.outsets = extra.outsets; + if let Some(&i) = id_to_index.get(&id) { + slices[i].outsets = extra.outsets; } } } @@ -493,4 +504,28 @@ mod tests { assert!(SlicesResource::from_bytes(&huge).is_err()); assert!(SlicesResource::from_bytes(&9u32.to_be_bytes()).is_err()); } + + #[test] + fn trailing_descriptor_applies_outsets_to_first_matching_slice() { + // Two slices share id 1; the trailing descriptor must update the first one only, + // matching the pre-index behavior of `iter_mut().find()`. + let mut plain = SlicesResource { + version: 6, + bounds: [0, 0, 80, 100], + group_name: "site".into(), + slices: vec![ + SliceRecord { id: 1, origin: 0, kind: 1, rect: [0, 0, 10, 10], ..Default::default() }, + SliceRecord { id: 1, origin: 0, kind: 1, rect: [10, 10, 20, 20], ..Default::default() }, + ], + }; + for s in &mut plain.slices { + s.outsets = [0; 4]; + } + let mut bytes = plain.to_bytes(); + let extra = Descriptor::new("slice").with("sliceID", Value::Integer(1)).with("topOutset", Value::Integer(7)); + VersionedDescriptor::new(Descriptor::new("null").with("slices", Value::List(vec![Value::Descriptor(extra)]))).write(&mut bytes); + let back = SlicesResource::from_bytes(&bytes).unwrap(); + assert_eq!(back.slices[0].outsets, [7, 0, 0, 0], "first slice receives the outset"); + assert_eq!(back.slices[1].outsets, [0, 0, 0, 0], "later duplicate is untouched"); + } } From 827d949af9266a6d1b8173a779d7fa90cccc5d20 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 21:13:12 -0400 Subject: [PATCH 05/53] security: preflight PCX and XPM output allocations --- crates/codecs/src/codecs/pcx.rs | 13 ++++++++++++- crates/codecs/src/codecs/xpm.rs | 13 +++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/crates/codecs/src/codecs/pcx.rs b/crates/codecs/src/codecs/pcx.rs index f89a781e9d..aefbd0a968 100644 --- a/crates/codecs/src/codecs/pcx.rs +++ b/crates/codecs/src/codecs/pcx.rs @@ -72,8 +72,19 @@ pub(crate) fn decode(b: &[u8], limits: &Limits) -> Result { return Err(bad("8-bit indexed PCX has no 256-colour palette")); } let image_end = if vga { b.len() - 769 } else { b.len() }; + // Each RLE packet emits at most 63 bytes and occupies at least two bytes. + // Reject inputs that cannot possibly contain all scanlines before allocating + // the decoded image buffer. + let min_packets = stride.div_ceil(63).checked_mul(2).ok_or_else(|| bad("compressed row size overflow"))?; + let min_encoded = min_packets.checked_mul(h as usize).and_then(|n| n.checked_mul(planes)).ok_or_else(|| bad("compressed image size overflow"))?; + let encoded_len = image_end.saturating_sub(HEADER); + if encoded_len < min_encoded { + return Err(bad("PCX pixel data is too short for the declared image")); + } let mut row = vec![0u8; row_len]; - let mut pixels = vec![0u8; total]; + let mut pixels = Vec::new(); + pixels.try_reserve_exact(total).map_err(|_| bad("PCX pixel buffer is too large"))?; + pixels.resize(total, 0); let mut p = HEADER; let width = w as usize; for y in 0..h as usize { diff --git a/crates/codecs/src/codecs/xpm.rs b/crates/codecs/src/codecs/xpm.rs index 82543d8867..c20f3c58e9 100644 --- a/crates/codecs/src/codecs/xpm.rs +++ b/crates/codecs/src/codecs/xpm.rs @@ -206,11 +206,16 @@ fn decode_lines(lines: &[Vec], limits: &Limits) -> Result } } let row_len = width.checked_mul(cpp).ok_or_else(|| bad("row byte count overflow"))?; - let mut pixels = vec![0u8; width.checked_mul(height).and_then(|n| n.checked_mul(4)).ok_or_else(|| bad("pixel size overflow"))?]; + // Validate all source rows before reserving the decoded RGBA image. A malformed + // short row must not be able to make a large header-sized allocation first. + if lines[1 + colours..required].iter().any(|line| line.len() != row_len) { + return Err(bad("XPM row has the wrong width")); + } + let total = width.checked_mul(height).and_then(|n| n.checked_mul(4)).ok_or_else(|| bad("pixel size overflow"))?; + let mut pixels = Vec::new(); + pixels.try_reserve_exact(total).map_err(|_| bad("XPM pixel buffer is too large"))?; + pixels.resize(total, 0); for (y, line) in lines[1 + colours..required].iter().enumerate() { - if line.len() != row_len { - return Err(bad("XPM row has the wrong width")); - } for x in 0..width { let key = &line[x * cpp..(x + 1) * cpp]; let rgba = palette.get(key).ok_or_else(|| bad("unknown palette key"))?; From 76f0e08df7a75513f00f4de081a524f27a441f42 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 21:13:12 -0400 Subject: [PATCH 06/53] security: harden native temporary and recovery files --- crates/format/src/atomic.rs | 9 ++++++++- crates/format/src/autosave.rs | 7 ++++++- crates/format/src/read.rs | 6 +++++- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/crates/format/src/atomic.rs b/crates/format/src/atomic.rs index 635bf924a2..e6510bb6a8 100644 --- a/crates/format/src/atomic.rs +++ b/crates/format/src/atomic.rs @@ -37,6 +37,9 @@ pub mod meta; use std::io::{self, Write}; use std::path::{Path, PathBuf}; + +#[cfg(unix)] +use std::os::unix::fs::OpenOptionsExt; use std::sync::atomic::{AtomicU64, Ordering}; /// The file-system steps [`atomic_write_with`] performs. [`RealFs`] is the real thing; tests @@ -86,7 +89,11 @@ impl AtomicFs for RealFs { self.write_new_progress(tmp, bytes, &|_| {}) } fn write_new_progress(&self, tmp: &Path, bytes: &[u8], progress: &dyn Fn(f32)) -> io::Result<()> { - let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(tmp)?; + let mut f = std::fs::OpenOptions::new(); + f.write(true).create_new(true); + #[cfg(unix)] + f.mode(0o600); + let mut f = f.open(tmp)?; let mut written = 0usize; for chunk in bytes.chunks(WRITE_CHUNK) { f.write_all(chunk)?; diff --git a/crates/format/src/autosave.rs b/crates/format/src/autosave.rs index fa3c9a4085..1c3bec0261 100644 --- a/crates/format/src/autosave.rs +++ b/crates/format/src/autosave.rs @@ -189,7 +189,9 @@ pub fn list_recovery(recovery_dir: &Path) -> Vec { }; for e in rd.flatten() { let p = e.path(); - if p.extension().is_some_and(|x| x == "json") + if e.file_type().is_ok_and(|ft| ft.is_file()) + && p.extension().is_some_and(|x| x == "json") + && e.metadata().is_ok_and(|m| m.len() <= (1 << 20)) && let Ok(bytes) = std::fs::read(&p) && let Ok(info) = serde_json::from_slice::(&bytes) && sanitize(&info.key) == info.key @@ -402,6 +404,9 @@ fn untouched_for(path: &Path, min_age: std::time::Duration, missing_ok: bool) -> } fn remove_entry(dir: &Path, key: &str) -> Result<()> { + if sanitize(key) != key { + return Err(FormatError::corrupt("recovery entry key contains path components")); + } let bundle = dir.join(format!("{key}.pcraft")); if bundle.exists() { std::fs::remove_dir_all(bundle)?; diff --git a/crates/format/src/read.rs b/crates/format/src/read.rs index 4ebfcbbfa6..8ad0dcccad 100644 --- a/crates/format/src/read.rs +++ b/crates/format/src/read.rs @@ -24,7 +24,11 @@ pub fn read_file(path: &Path) -> io::Result> { /// [`read_file`] with a chosen read size (tests use small ones to exercise the loop). pub fn read_file_with(path: &Path, chunk: usize) -> io::Result> { let mut f = std::fs::File::open(path)?; - let len = f.metadata().map(|m| m.len()).unwrap_or(0); + let metadata = f.metadata()?; + if !metadata.is_file() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "path is not a regular file")); + } + let len = metadata.len(); read_all_with(&mut f, len, chunk) } From fcdb572cd61a4e1336451468298d3170f2b78852 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 22 Oct 2026 06:19:33 -0400 Subject: [PATCH 07/53] security(io): admit embedded SVG raster images by header size Embedded PNG and WebP images reached resvg using only their displayed destination area, while resvg/tiny-skia allocate and decode the full intrinsic source image first. A highly compressed large image could therefore trigger gigabyte-scale allocation and decompression outside the importer's destination-memory accounting. Wrap usvg's default image-href data resolver with a check that reads PNG dimensions directly from the IHDR chunk and WebP dimensions via photocraft_codecs::probe_size, rejecting images whose decoded RGBA size exceeds the raster budget before resvg ever sees them. Findings: - f4fc2a9e-c26f-442d-9d05-4e793d6b0f13 (CWE-789, embedded WebP allocation) - d765fad5-678e-4f15-a8f8-3e6ee086056a (CWE-789, embedded PNG allocation) --- crates/io/src/svg.rs | 80 ++++++++++++++++++++++++++++++++++++++- crates/io/tests/it/svg.rs | 37 ++++++++++++++++++ 2 files changed, 116 insertions(+), 1 deletion(-) diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index ac1bd27ffa..d656f58103 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -28,6 +28,48 @@ use crate::{ImportResult, IoError}; pub const MAX_SIDE: u32 = 16_384; /// A rasterised fallback layer or a smart-object render never exceeds this many pixels. const MAX_RASTER_PIXELS: u64 = 24_000_000; +/// Largest decoded byte size (RGBA8) accepted for an embedded raster image before resvg decodes +/// it. resvg/tiny-skia allocate the full intrinsic image before any destination-size scaling, +/// so a highly compressed PNG or WebP could otherwise trigger gigabyte-scale allocation and +/// decompression that the destination pixmap's size check never sees. +const MAX_EMBEDDED_IMAGE_BYTES: u64 = MAX_RASTER_PIXELS * 4; + +/// The IHDR width/height of a PNG, read directly from its header (no decoding): the signature +/// (8 bytes), the IHDR chunk's length and type (8 bytes), then big-endian width and height. +fn png_dimensions(data: &[u8]) -> Option<(u32, u32)> { + let sig = data.get(0..8)?; + if sig != [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A] { + return None; + } + if data.get(12..16)? != b"IHDR" { + return None; + } + let w = u32::from_be_bytes(data.get(16..20)?.try_into().ok()?); + let h = u32::from_be_bytes(data.get(20..24)?.try_into().ok()?); + Some((w, h)) +} + +/// Intrinsic dimensions of a raster image resvg can embed, from its header alone, for formats +/// where decoding allocates the full image before any destination scaling (PNG, WebP). +/// Returns `Some(Some((w, h)))` when the format is PNG/WebP and its header parses cleanly, +/// `Some(None)` when the format is PNG/WebP (by MIME or magic bytes under `"text/plain"`) +/// and its header is malformed, or `None` for other formats. +fn embedded_image_dimensions(mime: &str, data: &[u8]) -> Option> { + match mime { + "image/png" => Some(png_dimensions(data)), + "image/webp" => Some(photocraft_codecs::probe_size(data).ok()), + "text/plain" => { + if data.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) { + Some(png_dimensions(data)) + } else if data.get(0..4) == Some(b"RIFF") && data.get(8..12) == Some(b"WEBP") { + Some(photocraft_codecs::probe_size(data).ok()) + } else { + None + } + } + _ => None, + } +} /// Aggregate memory for retained fallback surfaces in the document's pixel format. const MAX_PIXEL_BYTES: u64 = 1 << 30; /// Estimated peak during raster conversion (retained surfaces + RGBA8 pixmap + f32 staging + tiles). @@ -139,9 +181,24 @@ fn system_generic_family(_generic: &str) -> Option { /// `~/.ssh/...`) into the document. Embedded SVG images get no `` resolution at all /// (`usvg` already blanks it for sub-SVGs). fn options(fontdb: Arc) -> usvg::Options<'static> { + let default_resolver = usvg::ImageHrefResolver::default_data_resolver(); + let resolve_data: usvg::ImageHrefDataResolverFn<'static> = Box::new(move |mime, data, opts| { + // Reject embedded images whose decoded RGBA storage would exceed the budget before + // resvg (which allocates the full intrinsic image before any destination scaling) ever + // sees them. Formats without a cheap header probe fall through unchanged; their decode + // path is bounded elsewhere or accepted as-is, matching the previous behavior. + if let Some(probed) = embedded_image_dimensions(mime, &data) { + let (w, h) = probed?; + let decoded = u64::from(w).saturating_mul(u64::from(h)).saturating_mul(4); + if decoded > MAX_EMBEDDED_IMAGE_BYTES { + return None; + } + } + default_resolver(mime, data, opts) + }); usvg::Options { resources_dir: None, - image_href_resolver: usvg::ImageHrefResolver { resolve_data: usvg::ImageHrefResolver::default_data_resolver(), resolve_string: Box::new(|_, _| None) }, + image_href_resolver: usvg::ImageHrefResolver { resolve_data, resolve_string: Box::new(|_, _| None) }, fontdb, ..usvg::Options::default() } @@ -1058,4 +1115,25 @@ mod tests { assert_eq!((cv.size.width, cv.size.height), (MAX_SIDE, MAX_SIDE / 2)); assert_eq!(w.len(), 1); } + + #[test] + fn resolve_data_rejects_oversized_and_malformed_embedded_png_and_webp() { + let opt = options(Arc::new(usvg::fontdb::Database::new())); + let resolve = &opt.image_href_resolver.resolve_data; + let img = photocraft_codecs::Image::from_u8(2, 2, photocraft_codecs::ChannelLayout::Rgb, vec![255; 12]).unwrap(); + let valid_png = photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap(); + assert!(resolve("image/png", Arc::new(valid_png.clone()), &opt).is_some()); + assert!(resolve("text/plain", Arc::new(valid_png.clone()), &opt).is_some()); + + let mut huge_png = valid_png; + huge_png[16..20].copy_from_slice(&40_000u32.to_be_bytes()); + huge_png[20..24].copy_from_slice(&40_000u32.to_be_bytes()); + assert!(resolve("image/png", Arc::new(huge_png.clone()), &opt).is_none(), "oversized PNG rejected before decode"); + assert!(resolve("text/plain", Arc::new(huge_png), &opt).is_none(), "oversized mime-less PNG rejected before decode"); + + // Malformed WebP header under image/webp or text/plain must be rejected at the probe step. + let bad_webp = b"RIFF\x10\x00\x00\x00WEBPVP8 ".to_vec(); + assert!(resolve("image/webp", Arc::new(bad_webp.clone()), &opt).is_none()); + assert!(resolve("text/plain", Arc::new(bad_webp), &opt).is_none()); + } } diff --git a/crates/io/tests/it/svg.rs b/crates/io/tests/it/svg.rs index d1baf418fa..517190bcac 100644 --- a/crates/io/tests/it/svg.rs +++ b/crates/io/tests/it/svg.rs @@ -190,6 +190,31 @@ fn png_data_url() -> String { out } +/// A small valid PNG whose IHDR claims `w`x`h`, far larger than its actual (tiny) image data. +/// The header alone must be enough to reject it before any decode is attempted. +fn png_data_url_with_claimed_size(w: u32, h: u32) -> String { + let img = photocraft_codecs::Image::from_u8(2, 2, photocraft_codecs::ChannelLayout::Rgb, vec![255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255]).unwrap(); + let mut bytes = photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap(); + // IHDR starts right after the 8-byte signature and 8-byte chunk length/type: width at 16, + // height at 20 (big-endian u32 each). + bytes[16..20].copy_from_slice(&w.to_be_bytes()); + bytes[20..24].copy_from_slice(&h.to_be_bytes()); + const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::from("data:image/png;base64,"); + for chunk in bytes.chunks(3) { + let n = chunk.len(); + let v = (u32::from(chunk[0]) << 16) | (u32::from(*chunk.get(1).unwrap_or(&0)) << 8) | u32::from(*chunk.get(2).unwrap_or(&0)); + for i in 0..4 { + if i <= n { + out.push(T[((v >> (18 - 6 * i)) & 63) as usize] as char); + } else { + out.push('='); + } + } + } + out +} + #[test] fn embedded_raster_images_become_pixel_layers() { let r = open(&format!( @@ -204,6 +229,18 @@ fn embedded_raster_images_become_pixel_layers() { assert!(close(px(d, 16, 16), [1.0, 1.0, 1.0, 1.0]), "{:?}", px(d, 16, 16)); } +#[test] +fn embedded_png_with_a_huge_claimed_size_is_not_decoded() { + // The IHDR claims 40000x40000 (6.4 GB RGBA decoded) but the display size is tiny; the + // header-only check must reject it before resvg ever decodes the actual (2x2) image data. + let r = open(&format!( + "", + png_data_url_with_claimed_size(40_000, 40_000) + )); + // Rejected at the resolver: no image layer is produced (resvg treats it as a missing image). + assert!(r.document.layers.is_empty() || !matches!(r.document.layers[0].content, LayerContent::Raster(_)), "{:?}", r.document.layers); +} + #[test] fn text_becomes_outline_shapes_or_warns_without_a_font() { let r = open(&format!( From 1bd922f6d1857318531accbc48e17ea245034f9a Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 22 Oct 2026 19:24:24 -0400 Subject: [PATCH 08/53] fix(codecs): use exact minimum encoded row length for PCX preflight --- crates/codecs/src/codecs/pcx.rs | 18 +++++++++++++++--- crates/codecs/tests/it/pcx.rs | 19 +++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/crates/codecs/src/codecs/pcx.rs b/crates/codecs/src/codecs/pcx.rs index aefbd0a968..48dbf1169e 100644 --- a/crates/codecs/src/codecs/pcx.rs +++ b/crates/codecs/src/codecs/pcx.rs @@ -72,11 +72,23 @@ pub(crate) fn decode(b: &[u8], limits: &Limits) -> Result { return Err(bad("8-bit indexed PCX has no 256-colour palette")); } let image_end = if vga { b.len() - 769 } else { b.len() }; - // Each RLE packet emits at most 63 bytes and occupies at least two bytes. + // A 63-byte RLE packet takes 2 bytes; a trailing remainder of 1 byte below 0xC0 + // can be encoded as a 1-byte literal, while any larger remainder takes at least 2 bytes. + // Because RLE packets may span planes within a scanline (`row_len`), compute the + // minimum encoded bytes across the full scanline (`row_len`) and multiply by `h`. // Reject inputs that cannot possibly contain all scanlines before allocating // the decoded image buffer. - let min_packets = stride.div_ceil(63).checked_mul(2).ok_or_else(|| bad("compressed row size overflow"))?; - let min_encoded = min_packets.checked_mul(h as usize).and_then(|n| n.checked_mul(planes)).ok_or_else(|| bad("compressed image size overflow"))?; + let min_packets = (row_len / 63) + .checked_mul(2) + .and_then(|n| { + n.checked_add(match row_len % 63 { + 0 => 0, + 1 => 1, + _ => 2, + }) + }) + .ok_or_else(|| bad("compressed row size overflow"))?; + let min_encoded = min_packets.checked_mul(h as usize).ok_or_else(|| bad("compressed image size overflow"))?; let encoded_len = image_end.saturating_sub(HEADER); if encoded_len < min_encoded { return Err(bad("PCX pixel data is too short for the declared image")); diff --git a/crates/codecs/tests/it/pcx.rs b/crates/codecs/tests/it/pcx.rs index a5796473e6..792a41021c 100644 --- a/crates/codecs/tests/it/pcx.rs +++ b/crates/codecs/tests/it/pcx.rs @@ -87,6 +87,25 @@ fn rejects_truncated_and_overshooting_rle_without_panicking() { let mut b = header(8, 3, 2, 1, 2); b[66..68].copy_from_slice(&0u16.to_le_bytes()); assert_eq!(detect(&b), None, "invalid stride is not a PCX signature"); + + // A solid image with stride of the form 63k + 1 (64, 127) encodes as k full 63-byte runs + // plus a 1-byte literal (2k + 1 bytes per plane row), which is valid and must decode. + for width in [64u16, 127] { + let mut solid = header(8, 3, width, 1, width); + for _plane in 0..3 { + let mut rem = usize::from(width); + while rem >= 63 { + solid.extend_from_slice(&[0xff, 0x00]); // 63-byte run of 0x00 + rem -= 63; + } + if rem == 1 { + solid.push(0x00); // 1-byte literal 0x00 + } + } + let im = decode(&solid).unwrap(); + assert_eq!(im.dimensions(), (u32::from(width), 1)); + assert!(im.data().iter().all(|&v| v == 0)); + } } #[test] From 2bcf409ad342ea36024c2365e3754334defb7b8c Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 22 Oct 2026 19:28:44 -0400 Subject: [PATCH 09/53] fix(format): preserve user umask on atomic_write, 0600 only internal files, nonblocking sidecar/file reads --- crates/format/src/atomic.rs | 9 +--- crates/format/src/atomic/tests.rs | 13 ++++++ crates/format/src/autosave.rs | 72 +++++++++++++++++++++++++++++-- crates/format/src/read.rs | 26 ++++++++--- crates/format/src/store.rs | 9 +++- 5 files changed, 111 insertions(+), 18 deletions(-) diff --git a/crates/format/src/atomic.rs b/crates/format/src/atomic.rs index e6510bb6a8..635bf924a2 100644 --- a/crates/format/src/atomic.rs +++ b/crates/format/src/atomic.rs @@ -37,9 +37,6 @@ pub mod meta; use std::io::{self, Write}; use std::path::{Path, PathBuf}; - -#[cfg(unix)] -use std::os::unix::fs::OpenOptionsExt; use std::sync::atomic::{AtomicU64, Ordering}; /// The file-system steps [`atomic_write_with`] performs. [`RealFs`] is the real thing; tests @@ -89,11 +86,7 @@ impl AtomicFs for RealFs { self.write_new_progress(tmp, bytes, &|_| {}) } fn write_new_progress(&self, tmp: &Path, bytes: &[u8], progress: &dyn Fn(f32)) -> io::Result<()> { - let mut f = std::fs::OpenOptions::new(); - f.write(true).create_new(true); - #[cfg(unix)] - f.mode(0o600); - let mut f = f.open(tmp)?; + let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(tmp)?; let mut written = 0usize; for chunk in bytes.chunks(WRITE_CHUNK) { f.write_all(chunk)?; diff --git a/crates/format/src/atomic/tests.rs b/crates/format/src/atomic/tests.rs index 853a11293d..d48cf7d9a6 100644 --- a/crates/format/src/atomic/tests.rs +++ b/crates/format/src/atomic/tests.rs @@ -328,6 +328,19 @@ fn keeps_the_original_file_mode() { assert_eq!(std::fs::metadata(&p).unwrap().permissions().mode() & 0o777, 0o600); } +#[cfg(unix)] +#[test] +fn new_file_follows_the_process_umask() { + use std::os::unix::fs::PermissionsExt; + let d = TempDir::new("umask"); + let probe = d.0.join("probe.psd"); + std::fs::File::create(&probe).unwrap(); + let expected = std::fs::metadata(&probe).unwrap().permissions().mode() & 0o777; + let p = d.0.join("fresh.psd"); + atomic_write(&p, b"new").unwrap(); + assert_eq!(std::fs::metadata(&p).unwrap().permissions().mode() & 0o777, expected); +} + #[cfg(unix)] #[test] fn writes_through_a_symlink() { diff --git a/crates/format/src/autosave.rs b/crates/format/src/autosave.rs index 1c3bec0261..cb09e27cb8 100644 --- a/crates/format/src/autosave.rs +++ b/crates/format/src/autosave.rs @@ -20,7 +20,6 @@ use std::time::{SystemTime, UNIX_EPOCH}; use photocraft_doc::Document; use serde::{Deserialize, Serialize}; -use crate::store::write_atomic; use crate::{FormatError, PcraftWriter, Result, SaveOptions, SaveStats}; /// Sidecar describing an autosave. @@ -168,7 +167,7 @@ fn worker(rx: Receiver, bundle: PathBuf, sidecar: PathBuf, last: Arc, bundle: PathBuf, sidecar: PathBuf, last: Arc Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + struct PrivateFs; + impl crate::atomic::AtomicFs for PrivateFs { + fn write_new(&self, tmp: &Path, bytes: &[u8]) -> std::io::Result<()> { + use std::io::Write; + let mut f = std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(tmp)?; + f.write_all(bytes)?; + crate::atomic::sync_file(&f) + } + fn rename(&self, from: &Path, to: &Path) -> std::io::Result<()> { + crate::atomic::RealFs.rename(from, to) + } + fn remove(&self, path: &Path) -> std::io::Result<()> { + crate::atomic::RealFs.remove(path) + } + fn sync_dir(&self, dir: &Path) -> std::io::Result<()> { + crate::atomic::RealFs.sync_dir(dir) + } + fn copy_metadata(&self, from: &Path, to: &Path) -> std::io::Result<()> { + crate::atomic::RealFs.copy_metadata(from, to) + } + } + crate::atomic::atomic_write_with(&PrivateFs, crate::atomic::RenameRetry::platform(), path, bytes)?; + Ok(()) + } + #[cfg(not(unix))] + { + crate::store::write_atomic(path, bytes) + } +} + +fn read_sidecar_bounded(path: &Path, max: usize) -> Option> { + use std::io::Read; + #[cfg(unix)] + let mut file = { + use rustix::fs::{Mode, OFlags, open}; + let fd = open(path, OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC, Mode::empty()).ok()?; + let file = std::fs::File::from(fd); + if !file.metadata().ok()?.is_file() { + return None; + } + let _ = rustix::fs::fcntl_setfl(&file, OFlags::empty()); + file + }; + #[cfg(not(unix))] + let mut file = { + if !std::fs::symlink_metadata(path).ok()?.file_type().is_file() { + return None; + } + let file = std::fs::File::open(path).ok()?; + if !file.metadata().ok()?.is_file() { + return None; + } + file + }; + let mut bytes = Vec::new(); + (&mut file).take((max as u64).saturating_add(1)).read_to_end(&mut bytes).ok()?; + if bytes.len() > max { + return None; + } + Some(bytes) +} + /// Recoverable documents in `recovery_dir`, newest first. A sidecar must be named after its own /// (sanitized) key, so a stray or hand-edited one can never point outside the directory. pub fn list_recovery(recovery_dir: &Path) -> Vec { @@ -191,8 +256,7 @@ pub fn list_recovery(recovery_dir: &Path) -> Vec { let p = e.path(); if e.file_type().is_ok_and(|ft| ft.is_file()) && p.extension().is_some_and(|x| x == "json") - && e.metadata().is_ok_and(|m| m.len() <= (1 << 20)) - && let Ok(bytes) = std::fs::read(&p) + && let Some(bytes) = read_sidecar_bounded(&p, 1 << 20) && let Ok(info) = serde_json::from_slice::(&bytes) && sanitize(&info.key) == info.key && p.file_stem().is_some_and(|stem| *stem == *info.key) diff --git a/crates/format/src/read.rs b/crates/format/src/read.rs index 8ad0dcccad..8c72a20406 100644 --- a/crates/format/src/read.rs +++ b/crates/format/src/read.rs @@ -23,11 +23,27 @@ pub fn read_file(path: &Path) -> io::Result> { /// [`read_file`] with a chosen read size (tests use small ones to exercise the loop). pub fn read_file_with(path: &Path, chunk: usize) -> io::Result> { - let mut f = std::fs::File::open(path)?; - let metadata = f.metadata()?; - if !metadata.is_file() { - return Err(io::Error::new(io::ErrorKind::InvalidInput, "path is not a regular file")); - } + #[cfg(unix)] + let (mut f, metadata) = { + use rustix::fs::{Mode, OFlags, open}; + let fd = open(path, OFlags::RDONLY | OFlags::NONBLOCK | OFlags::CLOEXEC, Mode::empty()).map_err(io::Error::from)?; + let f = std::fs::File::from(fd); + let metadata = f.metadata()?; + if !metadata.is_file() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "path is not a regular file")); + } + rustix::fs::fcntl_setfl(&f, OFlags::empty()).map_err(io::Error::from)?; + (f, metadata) + }; + #[cfg(not(unix))] + let (mut f, metadata) = { + let f = std::fs::File::open(path)?; + let metadata = f.metadata()?; + if !metadata.is_file() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "path is not a regular file")); + } + (f, metadata) + }; let len = metadata.len(); read_all_with(&mut f, len, chunk) } diff --git a/crates/format/src/store.rs b/crates/format/src/store.rs index 03a85cbc83..18e6a9b432 100644 --- a/crates/format/src/store.rs +++ b/crates/format/src/store.rs @@ -598,7 +598,14 @@ fn write_bundle_file(root: &Path, path: &Path, data: &[u8], fs: &dyn crate::atom let mut temporary = None; for _ in 0..8 { let candidate = parent.join(crate::atomic::temp_name(&name.to_string_lossy())); - match std::fs::OpenOptions::new().write(true).create_new(true).open(&candidate) { + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + match opts.open(&candidate) { Ok(file) => { temporary = Some((candidate, file)); break; From ef4e13dc2d8cc65d570cb2c13d1ae34648ddf317 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 22 Oct 2026 06:22:10 -0400 Subject: [PATCH 10/53] security(io): stop double decompression of gzip-compressed SVG The importer bounded and inflated the top-level .svgz once, then handed the result to usvg::Tree::from_data, which re-checks the gzip magic and decompresses again with no byte limit of its own if the content happens to start with a nested gzip stream. The same default resolver decompresses embedded image/svg+xml data URLs (SVGZ-in-data-URL) the same unbounded way. Parse the already-bounded top-level text with Tree::from_str, which never decompresses. Wrap the image-href resolver so a gzip-compressed embedded SVG is inflated through our bounded inflate() first; the already-plain bytes handed to the default resolver no longer start with the gzip magic, so it parses them directly instead of decompressing a second time. Finding: a6444163-e510-4925-ae63-727658b6582e (CWE-409) --- crates/io/src/svg.rs | 39 ++++++++++++++++++++++++++++++++- crates/io/tests/it/svg.rs | 46 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index d656f58103..6bc256d4e0 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -194,6 +194,18 @@ fn options(fontdb: Arc) -> usvg::Options<'static> { return None; } } + // An embedded `image/svg+xml` value can itself be gzip-compressed (SVGZ-in-data-URL). + // The default resolver's sub-SVG loader checks for the gzip magic and decompresses + // again with no byte limit of its own. Apply our bounded inflate first: if the data is + // gzip-compressed, hand the default resolver the already-decompressed (and + // size-checked) bytes, which no longer start with the gzip magic, so it parses them + // directly instead of decompressing a second time. + if (mime == "image/svg+xml" || mime == "text/plain") && data.starts_with(&[0x1f, 0x8b]) { + return match inflate(&data) { + Ok(xml) if !xml.starts_with(&[0x1f, 0x8b]) => default_resolver("image/svg+xml", std::sync::Arc::new(xml.into_owned()), opts), + _ => None, + }; + } default_resolver(mime, data, opts) }); usvg::Options { @@ -231,7 +243,11 @@ fn parse_with(bytes: &[u8], fonts: fn() -> Arc) -> Resul let fontdb = if has_text { fonts() } else { Arc::new(usvg::fontdb::Database::new()) }; let fontless_text = has_text && fontdb.faces().next().is_none(); let opt = options(fontdb); - let tree = usvg::Tree::from_data(&xml, &opt).map_err(|e| IoError::Svg(e.to_string()))?; + // Parse the already-bounded-and-decoded text with from_str, not from_data: from_data + // re-checks the gzip magic and would decompress a second time without our size limit if + // the inflated content happened to start with a nested gzip stream. + let text = std::str::from_utf8(&xml).map_err(|_| IoError::Svg("the SVG is not valid UTF-8".into()))?; + let tree = usvg::Tree::from_str(text, &opt).map_err(|e| IoError::Svg(e.to_string()))?; Ok(Parsed { tree, fontless_text }) } @@ -1136,4 +1152,25 @@ mod tests { assert!(resolve("image/webp", Arc::new(bad_webp.clone()), &opt).is_none()); assert!(resolve("text/plain", Arc::new(bad_webp), &opt).is_none()); } + + #[test] + fn resolve_data_rejects_nested_gzip_and_handles_text_plain_svgz() { + use std::io::Write; + fn gz(bytes: &[u8]) -> Vec { + let mut e = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast()); + e.write_all(bytes).unwrap(); + e.finish().unwrap() + } + let opt = options(Arc::new(usvg::fontdb::Database::new())); + let resolve = &opt.image_href_resolver.resolve_data; + let svg = b""; + let once = gz(svg); + assert!(resolve("image/svg+xml", Arc::new(once.clone()), &opt).is_some()); + assert!(resolve("text/plain", Arc::new(once.clone()), &opt).is_some()); + + // Doubly gzip-compressed payload must be rejected rather than decompressed a second time by usvg. + let twice = gz(&once); + assert!(resolve("image/svg+xml", Arc::new(twice.clone()), &opt).is_none()); + assert!(resolve("text/plain", Arc::new(twice), &opt).is_none()); + } } diff --git a/crates/io/tests/it/svg.rs b/crates/io/tests/it/svg.rs index 517190bcac..c71bb0ae4f 100644 --- a/crates/io/tests/it/svg.rs +++ b/crates/io/tests/it/svg.rs @@ -282,6 +282,52 @@ fn gzip(bytes: &[u8]) -> Vec { e.finish().unwrap() } +/// A `data:` URL of `bytes` under `mime`, base64-encoded. +fn base64_data_url(mime: &str, bytes: &[u8]) -> String { + const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = format!("data:{mime};base64,"); + for chunk in bytes.chunks(3) { + let n = chunk.len(); + let v = (u32::from(chunk[0]) << 16) | (u32::from(*chunk.get(1).unwrap_or(&0)) << 8) | u32::from(*chunk.get(2).unwrap_or(&0)); + for i in 0..4 { + if i <= n { + out.push(T[((v >> (18 - 6 * i)) & 63) as usize] as char); + } else { + out.push('='); + } + } + } + out +} + +#[test] +fn gzip_compressed_embedded_svg_decompresses_once_with_a_size_cap() { + // A small gzip-compressed SVG embedded via data URL (SVGZ-in-data-URL) must still import. + let inner = format!(""); + let url = base64_data_url("image/svg+xml", &gzip(inner.as_bytes())); + let r = open(&format!("")); + assert!( + r.document.layers.iter().any(|l| matches!(l.content, LayerContent::Raster(_))), + "valid embedded gzip SVG must decode into a raster layer: {:?}", + r.document.layers + ); + + // A gzip-compressed embedded SVG whose decompressed size exceeds the cap must be rejected + // by our bounded inflate, not silently decompressed again without a limit by usvg. + let mut bomb = format!("").into_bytes(); + bomb.resize(usize::try_from(svg::MAX_INFLATED_BYTES).unwrap() + 1024, b' '); + bomb.extend_from_slice(b""); + let gz = gzip(&bomb); + assert!(gz.len() < 2 << 20, "{} bytes compressed", gz.len()); + let url = base64_data_url("image/svg+xml", &gz); + let start = std::time::Instant::now(); + let r = open(&format!("")); + assert!(start.elapsed() < std::time::Duration::from_secs(5), "took {:?}", start.elapsed()); + // The embedded bomb is rejected; the outer drawing may still open (e.g. with a missing + // image), but must not have decoded the bomb into a raster layer. + assert!(r.document.layers.iter().all(|l| !matches!(l.content, LayerContent::Raster(_))), "{:?}", r.document.layers); +} + /// 8 000 nested groups (70 KB) overflowed the XML parser's recursion on a 2 MB thread stack, an /// abort nothing can catch. It is an error now, found before the parser runs; the thread here /// has a small stack so a regression aborts the test instead of passing by luck. From ff4738810134d7f7e68b49f2e4f231b686282ff3 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 22 Oct 2026 20:06:08 -0400 Subject: [PATCH 11/53] Release 0.14.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 62 +++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4154f4f47c..5c96f56119 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4638,7 +4638,7 @@ dependencies = [ [[package]] name = "photocraft" -version = "0.13.0" +version = "0.14.0" dependencies = [ "arboard", "async-io", @@ -4684,7 +4684,7 @@ dependencies = [ [[package]] name = "photocraft-affinity" -version = "0.13.0" +version = "0.14.0" dependencies = [ "crc32fast", "flate2", @@ -4694,7 +4694,7 @@ dependencies = [ [[package]] name = "photocraft-algo" -version = "0.13.0" +version = "0.14.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4707,7 +4707,7 @@ dependencies = [ [[package]] name = "photocraft-automation" -version = "0.13.0" +version = "0.14.0" dependencies = [ "base64 0.22.1", "cap-std", @@ -4730,7 +4730,7 @@ dependencies = [ [[package]] name = "photocraft-cli" -version = "0.13.0" +version = "0.14.0" dependencies = [ "photocraft-automation", "photocraft-codecs", @@ -4745,7 +4745,7 @@ dependencies = [ [[package]] name = "photocraft-cms" -version = "0.13.0" +version = "0.14.0" dependencies = [ "moxcms", "rayon", @@ -4753,7 +4753,7 @@ dependencies = [ [[package]] name = "photocraft-codecs" -version = "0.13.0" +version = "0.14.0" dependencies = [ "exr", "flate2", @@ -4774,7 +4774,7 @@ dependencies = [ [[package]] name = "photocraft-color" -version = "0.13.0" +version = "0.14.0" dependencies = [ "photocraft-cms", "serde", @@ -4782,7 +4782,7 @@ dependencies = [ [[package]] name = "photocraft-compose" -version = "0.13.0" +version = "0.14.0" dependencies = [ "criterion", "photocraft-color", @@ -4797,7 +4797,7 @@ dependencies = [ [[package]] name = "photocraft-doc" -version = "0.13.0" +version = "0.14.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4808,7 +4808,7 @@ dependencies = [ [[package]] name = "photocraft-engine" -version = "0.13.0" +version = "0.14.0" dependencies = [ "blake3", "chrono", @@ -4847,7 +4847,7 @@ dependencies = [ [[package]] name = "photocraft-format" -version = "0.13.0" +version = "0.14.0" dependencies = [ "blake3", "crc32fast", @@ -4870,14 +4870,14 @@ dependencies = [ [[package]] name = "photocraft-geom" -version = "0.13.0" +version = "0.14.0" dependencies = [ "serde", ] [[package]] name = "photocraft-gpu" -version = "0.13.0" +version = "0.14.0" dependencies = [ "half", "log", @@ -4895,14 +4895,14 @@ dependencies = [ [[package]] name = "photocraft-heif" -version = "0.13.0" +version = "0.14.0" dependencies = [ "heic-rs", ] [[package]] name = "photocraft-io" -version = "0.13.0" +version = "0.14.0" dependencies = [ "blake3", "craft-docpath", @@ -4932,18 +4932,18 @@ dependencies = [ [[package]] name = "photocraft-ipp" -version = "0.13.0" +version = "0.14.0" [[package]] name = "photocraft-ops" -version = "0.13.0" +version = "0.14.0" dependencies = [ "photocraft-doc", ] [[package]] name = "photocraft-paint" -version = "0.13.0" +version = "0.14.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4954,7 +4954,7 @@ dependencies = [ [[package]] name = "photocraft-plugins" -version = "0.13.0" +version = "0.14.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4969,7 +4969,7 @@ dependencies = [ [[package]] name = "photocraft-psd" -version = "0.13.0" +version = "0.14.0" dependencies = [ "flate2", "photocraft-psd", @@ -4980,7 +4980,7 @@ dependencies = [ [[package]] name = "photocraft-raster" -version = "0.13.0" +version = "0.14.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4991,7 +4991,7 @@ dependencies = [ [[package]] name = "photocraft-raw" -version = "0.13.0" +version = "0.14.0" dependencies = [ "flate2", "image", @@ -5006,7 +5006,7 @@ dependencies = [ [[package]] name = "photocraft-tablet" -version = "0.13.0" +version = "0.14.0" dependencies = [ "block2 0.6.2", "objc2 0.6.4", @@ -5018,7 +5018,7 @@ dependencies = [ [[package]] name = "photocraft-testkit" -version = "0.13.0" +version = "0.14.0" dependencies = [ "memory-stats", "serde_json", @@ -5026,7 +5026,7 @@ dependencies = [ [[package]] name = "photocraft-text" -version = "0.13.0" +version = "0.14.0" dependencies = [ "flate2", "parley", @@ -5041,7 +5041,7 @@ dependencies = [ [[package]] name = "photocraft-ui-egui" -version = "0.13.0" +version = "0.14.0" dependencies = [ "base64 0.22.1", "craft-print", @@ -5084,7 +5084,7 @@ dependencies = [ [[package]] name = "photocraft-vector" -version = "0.13.0" +version = "0.14.0" dependencies = [ "photocraft-color", "photocraft-doc", @@ -5095,7 +5095,7 @@ dependencies = [ [[package]] name = "photocraft-web" -version = "0.13.0" +version = "0.14.0" dependencies = [ "eframe", "egui", @@ -5117,7 +5117,7 @@ dependencies = [ [[package]] name = "photocraft-web-boot" -version = "0.13.0" +version = "0.14.0" dependencies = [ "flate2", "js-sys", @@ -8713,7 +8713,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] name = "xtask" -version = "0.13.0" +version = "0.14.0" dependencies = [ "chrono", "serde", diff --git a/Cargo.toml b/Cargo.toml index 1fba71bc79..1215d84570 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"] [workspace.package] -version = "0.13.0" +version = "0.14.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From 4970534e054f4789f3fe796d4bcb45b800360e0b Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Fri, 23 Oct 2026 10:30:46 +0530 Subject: [PATCH 12/53] feat(engine): Select and Mask tool edits as select.refineEdge params select.refineEdge takes `edits`: the Select and Mask workspace's Quick Selection, Brush, Lasso, Object Selection, Select Subject, Clear Selection and Invert, applied in order to the starting selection (or layer mask) before the edge is refined. Cancel discards them, OK is one history step, and agents drive them over MCP and the control channel. - engine/select_mask_edits.rs: parse (bounded, every bad field an Err) and a pure apply/apply_one the workspace preview can share - algo matting::brush_region: soft round brush coverage (hardness) - ATN export reports steps with edits instead of dropping them - "Edits" label in the 21 catalogs Part of #7069 and #8148 (the workspace UI follows in a second PR). Co-Authored-By: Claude Opus 5.5 --- crates/algo/src/matting.rs | 2 +- crates/algo/src/matting/brush.rs | 103 +++++++-- crates/engine/src/atn_cmds.rs | 9 +- crates/engine/src/atn_cmds/tests.rs | 17 ++ crates/engine/src/lib.rs | 1 + crates/engine/src/select_mask_edits.rs | 303 +++++++++++++++++++++++++ crates/engine/src/smartselect_cmds.rs | 132 ++++++++++- crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sr.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + docs/scorecard.md | 2 +- scorecard/tools.toml | 2 +- 30 files changed, 568 insertions(+), 24 deletions(-) create mode 100644 crates/engine/src/select_mask_edits.rs diff --git a/crates/algo/src/matting.rs b/crates/algo/src/matting.rs index e40b687040..2008256702 100644 --- a/crates/algo/src/matting.rs +++ b/crates/algo/src/matting.rs @@ -238,7 +238,7 @@ pub fn morph(src: &[f32], w: usize, h: usize, r: usize, grow: bool) -> Vec mod brush; mod morphology; -pub use brush::{EdgeBrush, EdgeStroke, MAX_SWEPT_AREA, SIZE_RANGE as BRUSH_SIZE_RANGE, swept_area}; +pub use brush::{EdgeBrush, EdgeStroke, MAX_SWEPT_AREA, SIZE_RANGE as BRUSH_SIZE_RANGE, brush_region, swept_area}; /// Running max (or min) over a window of radius `r` (edge-clipped), van Herk / Gil–Werman: /// three comparisons per sample whatever the radius. diff --git a/crates/algo/src/matting/brush.rs b/crates/algo/src/matting/brush.rs index f70342e6d7..ae78e28d93 100644 --- a/crates/algo/src/matting/brush.rs +++ b/crates/algo/src/matting/brush.rs @@ -14,6 +14,7 @@ use photocraft_geom::Rect; use super::box_mean; use crate::segment::RgbImage; +use crate::selection::Region; /// One Refine Edge Brush stroke, in document pixels. #[derive(Clone, Debug, PartialEq)] @@ -41,14 +42,27 @@ pub struct EdgeBrush { window: usize, } -/// Coverage (0–1) of a round brush of radius `r` swept from `a` to `b`, at pixel centre `p`. -fn capsule(p: (f32, f32), a: (f32, f32), b: (f32, f32), r: f32) -> f32 { +/// Distance from pixel centre `p` to the segment from `a` to `b`. +fn segment_distance(p: (f32, f32), a: (f32, f32), b: (f32, f32)) -> f32 { let (dx, dy) = (b.0 - a.0, b.1 - a.1); let len2 = dx * dx + dy * dy; let t = if len2 > 0.0 { (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / len2).clamp(0.0, 1.0) } else { 0.0 }; let (cx, cy) = (a.0 + t * dx, a.1 + t * dy); - let d = ((p.0 - cx).powi(2) + (p.1 - cy).powi(2)).sqrt(); - (r + 0.5 - d).clamp(0.0, 1.0) + ((p.0 - cx).powi(2) + (p.1 - cy).powi(2)).sqrt() +} + +/// Coverage (0–1) of a round brush of radius `r` swept from `a` to `b`, at pixel centre `p`. +fn capsule(p: (f32, f32), a: (f32, f32), b: (f32, f32), r: f32) -> f32 { + (r + 0.5 - segment_distance(p, a, b)).clamp(0.0, 1.0) +} + +/// Coverage (0–1) at distance `d` from a brush's path, for radius `r` and a solid core of radius +/// `core` (hardness × `r`): full inside the core, then a smooth fall to zero half a pixel past `r` +/// (a hard brush keeps just the one-pixel anti-aliased rim). +fn soft_coverage(d: f32, r: f32, core: f32) -> f32 { + let ramp = r + 0.5 - core; + let t = ((r + 0.5 - d) / ramp.max(1.0)).clamp(0.0, 1.0); + if ramp > 1.0 { t * t * (3.0 - 2.0 * t) } else { t } } /// The pixels a disc of radius `r` at `p` can touch, as a (possibly empty) rectangle. @@ -64,14 +78,50 @@ pub const MAX_SWEPT_AREA: f64 = (1u64 << 28) as f64; /// The area `strokes` sweep (see [`MAX_SWEPT_AREA`]); non-finite strokes count as infinite. pub fn swept_area(strokes: &[EdgeStroke]) -> f64 { - strokes - .iter() - .map(|s| { - let size = f64::from(s.size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end())); - let len: f64 = s.points.windows(2).filter_map(|w| Some(f64::from((w.get(1)?.0 - w.first()?.0).hypot(w.get(1)?.1 - w.first()?.1)))).sum(); - if len.is_finite() { len * size + size * size } else { f64::INFINITY } - }) - .sum() + strokes.iter().map(|s| stroke_area(&s.points, s.size)).sum() +} + +/// The area one stroke of `points` sweeps with a brush `size` wide: length × size + size². +fn stroke_area(points: &[(f32, f32)], size: f32) -> f64 { + let size = f64::from(size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end())); + let len: f64 = points.windows(2).filter_map(|w| Some(f64::from((w.get(1)?.0 - w.first()?.0).hypot(w.get(1)?.1 - w.first()?.1)))).sum(); + if len.is_finite() { len * size + size * size } else { f64::INFINITY } +} + +/// Select and Mask's Brush: the coverage one stroke of a round brush `size` px wide with +/// `hardness` (0–100) paints along `points`, clipped to `canvas`. Overlapping dabs keep the most +/// coverage any of them gives, so a stroke never paints past full. `None` when it paints nothing, +/// has non-finite points or sweeps more than [`MAX_SWEPT_AREA`]. +pub fn brush_region(points: &[(f32, f32)], size: f32, hardness: f32, canvas: Rect) -> Option { + if points.is_empty() || !size.is_finite() || !hardness.is_finite() || points.iter().any(|p| !p.0.is_finite() || !p.1.is_finite()) { + return None; + } + if stroke_area(points, size) > MAX_SWEPT_AREA { + return None; + } + let r = size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end()) / 2.0; + let core = r * hardness.clamp(0.0, 100.0) / 100.0; + let bbox = points.iter().fold(Rect::EMPTY, |acc, p| acc.union(&dab_bounds(*p, r))).intersect(&canvas); + if bbox.is_empty() { + return None; + } + let bw = bbox.width() as usize; + let mut cov = vec![0.0f32; bw.checked_mul(bbox.height() as usize)?]; + for (a, b) in segments(points, r) { + let seg = dab_bounds(a, r).union(&dab_bounds(b, r)).intersect(&bbox); + for y in seg.y0..seg.y1 { + for x in seg.x0..seg.x1 { + let c = soft_coverage(segment_distance((x as f32 + 0.5, y as f32 + 0.5), a, b), r, core); + if let Some(slot) = cov.get_mut((y - bbox.y0) as usize * bw + (x - bbox.x0) as usize) + && c > *slot + { + *slot = c; + } + } + } + } + let mask: Vec = cov.iter().map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8).collect(); + mask.iter().any(|v| *v > 0).then_some(Region { bbox, mask }) } /// The stroke's path as short segments: points closer than a quarter of the radius to the last @@ -274,7 +324,6 @@ mod tests { use super::*; use crate::matting::{RefineParams, refine_mask_brushed, region_reader}; use crate::segment::ImageSampler; - use crate::selection::Region; /// Strands of the subject's colour (every 4th column from x = 60) beyond the selection's /// edge at x = 40: brushing over them adds them, Alt-brushing the edge keeps it as it was. @@ -330,4 +379,32 @@ mod tests { let empty = EdgeStroke { points: vec![], size: 10.0, add: true }; assert!(EdgeBrush::new(&[off, nan, empty], canvas).is_none()); } + #[test] + fn brush_region_paints_hard_and_soft_strokes() { + let canvas = Rect::new(0, 0, 100, 50); + let hard = brush_region(&[(10.0, 25.0), (90.0, 25.0)], 20.0, 100.0, canvas).expect("painted"); + assert!(hard.at(50, 25) > 0.99 && hard.at(50, 33) > 0.99, "solid inside the radius"); + assert!(hard.at(50, 36) == 0.0 && hard.at(50, 10) == 0.0, "nothing past it"); + // A soft brush fades from the centre line to the rim. + let soft = brush_region(&[(10.0, 25.0), (90.0, 25.0)], 20.0, 0.0, canvas).expect("painted"); + let (centre, mid, rim) = (soft.at(50, 25), soft.at(50, 30), soft.at(50, 34)); + assert!(centre > 0.95 && mid > 0.2 && mid < 0.8 && rim < 0.1, "{centre} {mid} {rim}"); + // A single point is one dab; clipped to the canvas. + let dab = brush_region(&[(0.0, 0.0)], 10.0, 100.0, canvas).expect("painted"); + assert_eq!((dab.bbox.x0, dab.bbox.y0), (0, 0)); + assert!(dab.at(1, 1) > 0.99); + } + + #[test] + fn brush_region_paints_nothing_for_hostile_strokes() { + let canvas = Rect::new(0, 0, 100, 50); + assert!(brush_region(&[], 10.0, 100.0, canvas).is_none()); + assert!(brush_region(&[(f32::NAN, 1.0)], 10.0, 100.0, canvas).is_none()); + assert!(brush_region(&[(1.0, 1.0)], f32::INFINITY, 100.0, canvas).is_none()); + assert!(brush_region(&[(1.0, 1.0)], 10.0, f32::NAN, canvas).is_none()); + assert!(brush_region(&[(-500.0, -500.0)], 10.0, 100.0, canvas).is_none(), "off the canvas"); + assert!(brush_region(&[(0.0, 0.0), (1e7, 1e7)], 5000.0, 100.0, canvas).is_none(), "sweeps too much"); + // Sizes and hardness outside their ranges are clamped, not rejected. + assert!(brush_region(&[(50.0, 25.0)], 0.0, 500.0, canvas).is_some()); + } } diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index f166c239b3..ac1d2df8a0 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -3037,13 +3037,14 @@ macro_rules! adjustment_event { const RIPPLE_SIZE: &[(&str, &str)] = &[("Sml", "small"), ("Mdm", "medium"), ("Lrg", "large")]; -/// Whether a Select and Mask step carries Refine Edge Brush strokes, which no descriptor holds. +/// Whether a Select and Mask step carries Refine Edge Brush strokes or the workspace's tool +/// edits (Quick Selection, Brush, Lasso…), which no descriptor holds. fn has_refine_strokes(p: &Value) -> bool { - p.get("refineStrokes").is_some_and(|v| !v.is_null() && v.as_array().is_none_or(|a| !a.is_empty())) + p.get("refineStrokes").is_some_and(|v| !v.is_null() && v.as_array().is_none_or(|a| !a.is_empty())) || crate::select_mask_edits::has_edits(p) } -/// Select and Mask to Photoshop. The descriptor has no field for the Refine Edge Brush, so a -/// step with `refineStrokes` can't be exported faithfully. One with `sampleAllLayers` is not +/// Select and Mask to Photoshop. The descriptor has no field for the Refine Edge Brush or the +/// workspace's tools, so a step with `refineStrokes` or `edits` can't be exported faithfully. One with `sampleAllLayers` is not /// exported here either: it exports as `smartBrushWorkspace`. fn refine_edge_to(_: &str, p: &Value) -> Option { if !pixel_target(p) || p.get("sampleAllLayers").is_some() || has_refine_strokes(p) { diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index d83d69e624..60236b57c3 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -2466,6 +2466,23 @@ fn refine_edge_with_brush_strokes_is_not_exported_without_them() { } } +/// Nor does it hold the workspace's tool edits (Quick Selection, Brush, Lasso…), with or without +/// Sample All Layers, so a step with them is reported instead of exported without them. +#[test] +fn refine_edge_with_workspace_edits_is_not_exported_without_them() { + let edit = json!({"tool": "quick", "points": [[5, 4]], "size": 4}); + for params in [json!({"radius": 1, "edits": [edit]}), json!({"radius": 1, "sampleAllLayers": true, "edits": [{"tool": "invert"}]})] { + let edited = Action { name: "Probe".into(), steps: vec![("select.refineEdge".into(), params.clone())], ..Default::default() }; + assert!(export("Audit", &[&edited], &[], false).is_err(), "{params}"); + let (_, skipped) = export("Audit", &[&edited], &[], true).unwrap(); + assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "select.refineEdge".into() }]); + } + for params in [json!({"radius": 1, "edits": []}), json!({"radius": 1, "edits": null})] { + let plain = Action { name: "Plain".into(), steps: vec![("select.refineEdge".into(), params)], ..Default::default() }; + assert!(export("Audit", &[&plain], &[], false).is_ok()); + } +} + #[test] fn image_size_resolution_only_does_not_gain_keep_print_size() { // The .atn cannot tell resolution-only from keepPrintSize:true, so the former must not diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 64814126cd..206029d860 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -108,6 +108,7 @@ pub mod retouch_cmds; pub mod sample_cmds; pub mod sampler_cmds; pub mod select_extra_cmds; +pub mod select_mask_edits; pub mod selection_cmds; pub mod shell_commands; pub mod shortcut_sets; diff --git a/crates/engine/src/select_mask_edits.rs b/crates/engine/src/select_mask_edits.rs new file mode 100644 index 0000000000..10bffb5bff --- /dev/null +++ b/crates/engine/src/select_mask_edits.rs @@ -0,0 +1,303 @@ +//! Select and Mask's own selection tools (#7069, #8148): the edits the workspace makes to the +//! selection before it refines the edge, carried as `select.refineEdge`'s `edits` param. +//! +//! Photoshop's Select and Mask has a Quick Selection tool, a Brush, Object Selection, Lasso and +//! Polygonal Lasso, a Select Subject button and Clear Selection / Invert. Here each use is one +//! [`SelectionEdit`], applied in order to the selection (or layer mask) Select and Mask starts +//! from. Keeping them as params rather than live edits of the document means Cancel discards +//! them, OK is one history step, and the UI, CLI, MCP and the control channel all drive them the +//! same way. [`apply`] is pure, so the workspace's preview runs exactly what OK commits. + +use photocraft_algo::matting; +use photocraft_algo::segment::{grabcut, quick, subject}; +use photocraft_algo::selection::{self as sel, Region, SelectionMode, combine_region}; +use photocraft_doc::{Document, LayerId}; +use photocraft_geom::Rect; +use photocraft_raster::Surface; +use serde_json::Value; + +use crate::smartselect_cmds::with_doc_sampler; +use crate::{EngineError, Result}; + +const CMD: &str = "select.refineEdge"; + +/// Most edits one Select and Mask takes, and points across them. +pub const MAX_EDITS: usize = 4096; +pub const MAX_EDIT_POINTS: usize = 100_000; + +/// One use of a Select and Mask tool. `add: false` is the tool's subtract mode (Alt). +#[derive(Clone, Debug, PartialEq)] +pub enum SelectionEdit { + /// Quick Selection: grows a region from the stroke out to the image's edges. + Quick { points: Vec<(f32, f32)>, size: f32, add: bool, sample_all_layers: bool }, + /// Brush: paints the selection directly with a round brush. + Brush { points: Vec<(f32, f32)>, size: f32, hardness: f32, add: bool }, + /// Lasso and Polygonal Lasso: the polygon through `points`. + Lasso { points: Vec<(f32, f32)>, add: bool }, + /// Object Selection: the object in `rect` [x, y, w, h], or the one under `point`. + Object { area: ObjectArea, add: bool, sample_all_layers: bool }, + /// The Select Subject button. + Subject { mode: SelectionMode, sample_all_layers: bool }, + /// Global Refinements › Clear Selection. + Clear, + /// Global Refinements › Invert. + Invert, +} + +/// Where Object Selection looks. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ObjectArea { + Rect(Rect), + Point(f32, f32), +} + +fn bad(msg: &str) -> EngineError { + EngineError::BadParams { cmd: CMD.into(), msg: msg.into() } +} + +/// A finite coordinate within the range strokes may use. +fn coord(v: &Value) -> Option { + v.as_f64().filter(|v| v.is_finite() && v.abs() <= 1e7).map(|v| v as f32) +} + +fn point(v: &Value) -> Option<(f32, f32)> { + let xy = v.as_array().filter(|a| a.len() == 2)?; + Some((coord(xy.first()?)?, coord(xy.get(1)?)?)) +} + +fn points(o: &serde_json::Map, min: usize, total: &mut usize) -> Result> { + let list = o.get("points").and_then(Value::as_array).ok_or_else(|| bad("this edit needs a points array of [x,y] pairs"))?; + if list.len() < min { + return Err(bad(&format!("this edit needs at least {min} point{}", if min == 1 { "" } else { "s" }))); + } + *total = total.saturating_add(list.len()); + if *total > MAX_EDIT_POINTS { + return Err(bad(&format!("edits hold more than {MAX_EDIT_POINTS} points"))); + } + list.iter().map(point).collect::>>().ok_or_else(|| bad("edit points must be [x,y] pairs of finite numbers")) +} + +/// A number in `range`, `default` when absent; anything else is an error naming `key`. +fn number(o: &serde_json::Map, key: &str, default: f32, range: std::ops::RangeInclusive) -> Result { + match o.get(key) { + None => Ok(default), + Some(v) => v + .as_f64() + .map(|v| v as f32) + .filter(|v| v.is_finite() && range.contains(v)) + .ok_or_else(|| bad(&format!("edit {key} must be a number in {}..{}", range.start(), range.end()))), + } +} + +fn flag(o: &serde_json::Map, key: &str, default: bool) -> Result { + match o.get(key) { + None => Ok(default), + Some(v) => v.as_bool().ok_or_else(|| bad(&format!("edit {key} must be true or false"))), + } +} + +/// The tools' `mode`: `add` (the default) or `subtract`. +fn adds(o: &serde_json::Map) -> Result { + match o.get("mode").map(Value::as_str) { + None | Some(Some("add")) => Ok(true), + Some(Some("subtract")) => Ok(false), + Some(_) => Err(bad("edit mode must be add|subtract")), + } +} + +/// The `edits` of `p`, in order (none when absent or null). +pub fn parse(p: &Value) -> Result> { + let Some(v) = p.get("edits").filter(|v| !v.is_null()) else { return Ok(Vec::new()) }; + let list = v.as_array().ok_or_else(|| bad("edits must be an array of {\"tool\":…} objects"))?; + if list.len() > MAX_EDITS { + return Err(bad(&format!("edits holds more than {MAX_EDITS} edits"))); + } + let size_range = matting::BRUSH_SIZE_RANGE; + let mut total = 0usize; + let mut edits = Vec::with_capacity(list.len()); + for e in list { + let o = e.as_object().ok_or_else(|| bad("each edit must be an object with a tool"))?; + let tool = o.get("tool").and_then(Value::as_str).ok_or_else(|| bad("each edit needs a tool: quick|brush|lasso|object|subject|clear|invert"))?; + let edit = match tool { + "quick" => SelectionEdit::Quick { + points: points(o, 1, &mut total)?, + size: number(o, "size", 30.0, size_range.clone())?, + add: adds(o)?, + sample_all_layers: flag(o, "sampleAllLayers", false)?, + }, + "brush" => SelectionEdit::Brush { + points: points(o, 1, &mut total)?, + size: number(o, "size", 30.0, size_range.clone())?, + hardness: number(o, "hardness", 100.0, 0.0..=100.0)?, + add: adds(o)?, + }, + "lasso" => SelectionEdit::Lasso { points: points(o, 3, &mut total)?, add: adds(o)? }, + "object" => { + let area = match (o.get("rect"), o.get("point")) { + (Some(r), _) => { + let v = r.as_array().filter(|a| a.len() == 4).ok_or_else(|| bad("object rect must be [x,y,w,h]"))?; + let n = v.iter().map(coord).collect::>>().ok_or_else(|| bad("object rect must be four finite numbers"))?; + let (x, y, w, h) = (n.first().copied(), n.get(1).copied(), n.get(2).copied(), n.get(3).copied()); + let (Some(x), Some(y), Some(w), Some(h)) = (x, y, w, h) else { return Err(bad("object rect must be [x,y,w,h]")) }; + // Negative sizes (dragging up or left) are normalised. + let (x0, x1, y0, y1) = (x.min(x + w), x.max(x + w), y.min(y + h), y.max(y + h)); + ObjectArea::Rect(Rect::new(x0.floor() as i32, y0.floor() as i32, x1.ceil() as i32, y1.ceil() as i32)) + } + (None, Some(pt)) => { + let (x, y) = point(pt).ok_or_else(|| bad("object point must be [x,y]"))?; + ObjectArea::Point(x, y) + } + (None, None) => return Err(bad("object needs \"rect\": [x,y,w,h] or \"point\": [x,y]")), + }; + SelectionEdit::Object { area, add: adds(o)?, sample_all_layers: flag(o, "sampleAllLayers", false)? } + } + "subject" => { + let mode = match o.get("mode").map(Value::as_str) { + None | Some(Some("replace")) => SelectionMode::Replace, + Some(Some("add")) => SelectionMode::Add, + Some(Some("subtract")) => SelectionMode::Subtract, + Some(_) => return Err(bad("subject mode must be replace|add|subtract")), + }; + SelectionEdit::Subject { mode, sample_all_layers: flag(o, "sampleAllLayers", true)? } + } + "clear" => SelectionEdit::Clear, + "invert" => SelectionEdit::Invert, + other => return Err(bad(&format!("unknown edit tool `{other}`: quick|brush|lasso|object|subject|clear|invert"))), + }; + edits.push(edit); + } + Ok(edits) +} + +/// Whether `p` carries Select and Mask tool edits. +pub fn has_edits(p: &Value) -> bool { + p.get("edits").is_some_and(|v| !v.is_null() && v.as_array().is_none_or(|a| !a.is_empty())) +} + +fn mode(add: bool) -> SelectionMode { + if add { SelectionMode::Add } else { SelectionMode::Subtract } +} + +/// The polygon through `points` (anti-aliased), over the rows and columns it spans in `canvas`. +fn polygon_region(points: &[(f32, f32)], canvas: Rect) -> Option { + let (mut x0, mut y0, mut x1, mut y1) = (f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY); + for p in points { + (x0, y0, x1, y1) = (x0.min(p.0), y0.min(p.1), x1.max(p.0), y1.max(p.1)); + } + if x0 > x1 || y0 > y1 { + return None; + } + let clamp = |v: f32| v.clamp(-1e7, 1e7) as i32; + let bbox = Rect::new(clamp(x0.floor()) - 1, clamp(y0.floor()) - 1, clamp(x1.ceil()) + 1, clamp(y1.ceil()) + 1).intersect(&canvas); + if bbox.is_empty() { + return None; + } + let mask: Vec = sel::polygon(points, bbox, true).iter().map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8).collect(); + mask.iter().any(|v| *v > 0).then_some(Region { bbox, mask }) +} + +/// `base` inverted over `canvas`; `None` when that leaves nothing selected. +fn invert(base: Option<&Surface>, canvas: Rect) -> Option { + let m: Vec = sel::mask_from_surface(base, canvas).into_iter().map(|v| 1.0 - v).collect(); + let mut s = sel::mask_to_surface(&m, canvas); + s.prune(); + (!s.content_bounds().is_empty()).then_some(s) +} + +/// One edit applied to `base` (the selection so far; `None` = nothing selected) in `doc`, the +/// sampling tools reading `layer` (or the composite with Sample All Layers). A tool that finds +/// nothing leaves the selection as it was, as the same tools do outside Select and Mask. +pub fn apply_one(doc: &Document, layer: Option, base: Option, edit: &SelectionEdit) -> Option { + let canvas = doc.bounds(); + let combine = |region: Option, m: SelectionMode| match region { + Some(r) => combine_region(base.as_ref(), Some(&r), m), + None => base.clone(), + }; + match edit { + SelectionEdit::Quick { points, size, add, sample_all_layers } => { + let region = with_doc_sampler(doc, layer, *sample_all_layers, |smp, doc| quick::quick_select(smp, doc.bounds(), points, *size)); + combine(region, mode(*add)) + } + SelectionEdit::Brush { points, size, hardness, add } => combine(matting::brush_region(points, *size, *hardness, canvas), mode(*add)), + SelectionEdit::Lasso { points, add } => combine(polygon_region(points, canvas), mode(*add)), + SelectionEdit::Object { area, add, sample_all_layers } => { + let region = with_doc_sampler(doc, layer, *sample_all_layers, |smp, doc| match *area { + ObjectArea::Rect(r) => grabcut::object_select(smp, doc.bounds(), r, 160_000), + ObjectArea::Point(x, y) => grabcut::object_at(smp, doc.bounds(), x, y, grabcut::OBJECT_AT_WORK_PX), + }); + combine(region, mode(*add)) + } + SelectionEdit::Subject { mode, sample_all_layers } => { + let region = with_doc_sampler(doc, layer, *sample_all_layers, |smp, doc| subject::select_subject(smp, doc.bounds())); + combine(region, *mode) + } + SelectionEdit::Clear => None, + SelectionEdit::Invert => invert(base.as_ref(), canvas), + } +} + +/// `edits` applied in order to `base`: the selection Select and Mask refines. +pub fn apply(doc: &Document, layer: Option, base: Option, edits: &[SelectionEdit]) -> Option { + edits.iter().fold(base, |sel, e| apply_one(doc, layer, sel, e)) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn parses_every_tool() { + let p = json!({"edits": [ + {"tool": "quick", "points": [[1, 2]], "size": 12, "mode": "subtract", "sampleAllLayers": true}, + {"tool": "brush", "points": [[1, 2], [3, 4]], "hardness": 50}, + {"tool": "lasso", "points": [[0, 0], [10, 0], [0, 10]]}, + {"tool": "object", "rect": [10, 10, -5, -5]}, + {"tool": "object", "point": [3, 4], "mode": "subtract"}, + {"tool": "subject", "mode": "add"}, + {"tool": "clear"}, + {"tool": "invert"}, + ]}); + let edits = parse(&p).expect("valid edits"); + assert_eq!(edits.len(), 8); + assert_eq!(edits[0], SelectionEdit::Quick { points: vec![(1.0, 2.0)], size: 12.0, add: false, sample_all_layers: true }); + assert_eq!(edits[1], SelectionEdit::Brush { points: vec![(1.0, 2.0), (3.0, 4.0)], size: 30.0, hardness: 50.0, add: true }); + assert_eq!(edits[3], SelectionEdit::Object { area: ObjectArea::Rect(Rect::new(5, 5, 10, 10)), add: true, sample_all_layers: false }); + assert_eq!(edits[5], SelectionEdit::Subject { mode: SelectionMode::Add, sample_all_layers: true }); + assert!(parse(&json!({})).expect("none").is_empty()); + assert!(parse(&json!({"edits": null})).expect("none").is_empty()); + assert!(has_edits(&p) && !has_edits(&json!({"edits": []})) && !has_edits(&json!({}))); + } + + #[test] + fn rejects_hostile_edits() { + let many_points = vec![json!([1, 1]); MAX_EDIT_POINTS + 1]; + let many_edits = vec![json!({"tool": "clear"}); MAX_EDITS + 1]; + for bad in [ + json!("x"), + json!([1]), + json!([{}]), + json!([{"tool": 7}]), + json!([{"tool": "wand"}]), + json!([{"tool": "quick"}]), + json!([{"tool": "quick", "points": []}]), + json!([{"tool": "quick", "points": [[1]]}]), + json!([{"tool": "quick", "points": [["a", 1]]}]), + json!([{"tool": "quick", "points": [[1e300, 1]]}]), + json!([{"tool": "quick", "points": [[1, 1]], "size": 0}]), + json!([{"tool": "quick", "points": [[1, 1]], "size": "big"}]), + json!([{"tool": "quick", "points": [[1, 1]], "mode": "intersect"}]), + json!([{"tool": "quick", "points": [[1, 1]], "sampleAllLayers": 1}]), + json!([{"tool": "brush", "points": [[1, 1]], "hardness": 101}]), + json!([{"tool": "lasso", "points": [[1, 1], [2, 2]]}]), + json!([{"tool": "object"}]), + json!([{"tool": "object", "rect": [1, 2, 3]}]), + json!([{"tool": "object", "point": [1, "y"]}]), + json!([{"tool": "subject", "mode": "intersect"}]), + json!([{"tool": "brush", "points": many_points}]), + json!(many_edits), + ] { + assert!(parse(&json!({"edits": bad})).is_err(), "{bad}"); + } + } +} diff --git a/crates/engine/src/smartselect_cmds.rs b/crates/engine/src/smartselect_cmds.rs index 039f248a5b..cad2feac28 100644 --- a/crates/engine/src/smartselect_cmds.rs +++ b/crates/engine/src/smartselect_cmds.rs @@ -11,6 +11,7 @@ use photocraft_geom::Rect; use serde_json::{Value, json}; use crate::commands::CommandSpec; +use crate::select_mask_edits; use crate::{EngineError, Result, Session}; fn has_doc(s: &Session) -> std::result::Result<(), String> { @@ -398,8 +399,9 @@ fn refine_strokes(p: &Value) -> Result> { Ok(strokes) } -/// Select and Mask: refines the selection edge, then outputs to the selection, a layer mask on -/// the active layer, a new layer or a new document (each with or without a mask). +/// Select and Mask: applies the workspace's tool `edits` to the selection (or layer mask), refines +/// its edge, then outputs to the selection, a layer mask on the active layer, a new layer or a new +/// document (each with or without a mask). /// Decontaminating colours needs new pixels, so it upgrades `selection` / `layerMask` output to /// `newLayerWithMask` (as Photoshop does). New-layer outputs hide the source layer; new-document /// outputs leave the source document untouched. @@ -419,10 +421,14 @@ fn refine_edge(s: &mut Session, p: &Value) -> Result { if decontaminate && matches!(output.as_str(), "selection" | "layerMask") { output = "newLayerWithMask".into(); } + let edits = select_mask_edits::parse(p)?; let region = with_sampler(s, b(p, "sampleAllLayers", false), |smp, doc| { let mask_surf = active.and_then(|id| doc.layer(id)).and_then(|l| l.mask.as_ref()).map(|m| &m.surface); let target_mask = p.get("target").and_then(Value::as_str) == Some("mask"); - let sel = if target_mask { mask_surf.or(doc.selection.as_ref()) } else { doc.selection.as_ref().or(mask_surf) }?; + let start = if target_mask { mask_surf.or(doc.selection.as_ref()) } else { doc.selection.as_ref().or(mask_surf) }; + // The workspace's tool edits change the selection before its edge is refined. + let edited = if edits.is_empty() { None } else { select_mask_edits::apply(doc, active, start.cloned(), &edits) }; + let sel = if edits.is_empty() { start } else { edited.as_ref() }?; let brush = matting::EdgeBrush::new(&strokes, doc.bounds()); matting::refine_mask_brushed(smp, &matting::surface_reader(sel), sel.content_bounds(), doc.bounds(), ¶ms, brush.as_ref()) })?; @@ -532,7 +538,7 @@ pub fn specs() -> Vec { "select.refineEdge", "Refine Edge", [], - r##"{"radius":px=0,"smartRadius":bool=false,"smooth":0..100=0,"feather":px=0,"contrast":0..100=0,"shiftEdge":-100..100=0,"decontaminate":bool=false,"amount":0..100=100,"output":"selection|layerMask|newLayer|newLayerWithMask|newDocument|newDocumentWithMask"="selection","sampleAllLayers":bool=false,"refineStrokes":[{"points":[[x,y],…],"size":px=30,"mode":"add|subtract"="add"},…]=[]} (refineStrokes: the Refine Edge Brush, in painting order: add refines the painted area from the image, subtract keeps the original edge there)"##, + r##"{"radius":px=0,"smartRadius":bool=false,"smooth":0..100=0,"feather":px=0,"contrast":0..100=0,"shiftEdge":-100..100=0,"decontaminate":bool=false,"amount":0..100=100,"output":"selection|layerMask|newLayer|newLayerWithMask|newDocument|newDocumentWithMask"="selection","sampleAllLayers":bool=false,"refineStrokes":[{"points":[[x,y],…],"size":px=30,"mode":"add|subtract"="add"},…]=[],"edits":[{"tool":"quick","points":[[x,y],…],"size":px=30,"mode":"add|subtract"="add","sampleAllLayers":bool=false} | {"tool":"brush","points":[[x,y],…],"size":px=30,"hardness":0..100=100,"mode":"add|subtract"="add"} | {"tool":"lasso","points":[[x,y],…≥3],"mode":"add|subtract"="add"} | {"tool":"object","rect":[x,y,w,h] | "point":[x,y],"mode":"add|subtract"="add","sampleAllLayers":bool=false} | {"tool":"subject","mode":"replace|add|subtract"="replace","sampleAllLayers":bool=true} | {"tool":"clear"} | {"tool":"invert"},…]=[]} (refineStrokes: the Refine Edge Brush, in painting order: add refines the painted area from the image, subtract keeps the original edge there; edits: the workspace's Quick Selection, Brush, Lasso, Object Selection, Select Subject, Clear Selection and Invert, applied in order to the selection before its edge is refined)"##, has_selection_or_mask, refine_edge ), @@ -1065,4 +1071,122 @@ mod tests { s.execute("select.focusArea", json!({})).unwrap(); assert!(cov(&s, 30, 50) >= 0.5 && cov(&s, 130, 50) < 0.5); } + /// The selection as 8-bit coverage over `w × h`, to compare two selections exactly. + fn sel_bytes(s: &Session, w: i32, h: i32) -> Vec { + (0..h).flat_map(|y| (0..w).map(move |x| (cov(s, x, y) * 255.0).round() as u8)).collect() + } + + /// Select and Mask's own tools (#7069, #8148): each edit changes the selection before the + /// edge is refined, at every depth, and the whole run is one history step. + #[test] + fn refine_edge_edits_apply_each_tool_at_every_depth() { + for depth in [8, 16, 32] { + let mut s = two_regions(depth); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap(); + let start = sel_bytes(&s, 140, 100); + let run = |s: &mut Session, edits: Value| { + s.execute("select.refineEdge", json!({"edits": edits})).unwrap(); + let after = sel_bytes(s, 140, 100); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(sel_bytes(s, 140, 100), start, "depth {depth}: one undo restores the selection"); + after + }; + let at = |b: &[u8], x: usize, y: usize| b[y * 140 + x]; + // Quick Selection grows across the cool region; subtracting it again leaves the start. + let quick = run(&mut s, json!([{"tool": "quick", "points": [[100, 50], [110, 50]], "size": 8}])); + assert!(at(&quick, 120, 80) >= 128 && at(&quick, 5, 5) >= 128 && at(&quick, 40, 60) < 128, "depth {depth}: quick"); + let back = run( + &mut s, + json!([{"tool": "quick", "points": [[100, 50]], "size": 8}, {"tool": "quick", "points": [[100, 50]], "size": 8, "mode": "subtract"}]), + ); + assert!(at(&back, 120, 80) < 128 && at(&back, 5, 5) >= 128, "depth {depth}: quick subtract"); + // The Brush paints exactly where it goes. + let brush = run(&mut s, json!([{"tool": "brush", "points": [[30, 80], [60, 80]], "size": 10}])); + assert!(at(&brush, 45, 80) == 255 && at(&brush, 45, 90) == 0, "depth {depth}: brush"); + let erased = run(&mut s, json!([{"tool": "brush", "points": [[10, 10]], "size": 6, "mode": "subtract"}])); + assert!(at(&erased, 10, 10) == 0 && at(&erased, 2, 2) == 255, "depth {depth}: brush subtract"); + // Lasso adds and subtracts polygons. + let lasso = run(&mut s, json!([{"tool": "lasso", "points": [[50, 50], [90, 50], [90, 90], [50, 90]]}])); + assert!(at(&lasso, 70, 70) == 255 && at(&lasso, 95, 70) == 0 && at(&lasso, 5, 5) == 255, "depth {depth}: lasso"); + let cut = run(&mut s, json!([{"tool": "lasso", "points": [[0, 0], [10, 0], [10, 10], [0, 10]], "mode": "subtract"}])); + assert!(at(&cut, 5, 5) == 0 && at(&cut, 15, 15) == 255, "depth {depth}: lasso subtract"); + // Clear Selection, then Invert of nothing: everything. + let all = run(&mut s, json!([{"tool": "clear"}, {"tool": "invert"}])); + assert!(all.iter().all(|v| *v == 255), "depth {depth}: clear + invert"); + let inverted = run(&mut s, json!([{"tool": "invert"}])); + assert!(at(&inverted, 5, 5) == 0 && at(&inverted, 100, 50) == 255, "depth {depth}: invert"); + } + } + + /// Object Selection and Select Subject inside the workspace find what the standalone tools find. + #[test] + fn refine_edge_edits_object_and_subject() { + for depth in [8, 16, 32] { + let (mut s, inside) = disc(depth); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + s.execute("select.refineEdge", json!({"edits": [{"tool": "object", "rect": [40, 20, 120, 120]}, {"tool": "lasso", "points": [[0, 0], [10, 0], [10, 10], [0, 10]], "mode": "subtract"}]})).unwrap(); + let v = sel_iou(&s, 200, 160, &inside); + assert!(v > 0.95, "depth {depth}: object rect IoU {v}"); + s.execute("select.refineEdge", json!({"edits": [{"tool": "clear"}, {"tool": "object", "point": [3, 3]}, {"tool": "object", "point": [100, 80]}]})) + .unwrap(); + let v = sel_iou(&s, 200, 160, &inside); + assert!(v > 0.9, "depth {depth}: object point IoU {v} (the background point finds nothing)"); + } + let (mut s, inside) = disc(8); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + s.execute("select.refineEdge", json!({"edits": [{"tool": "subject"}]})).unwrap(); + let v = sel_iou(&s, 200, 160, &inside); + assert!(v > 0.9, "subject replaces the selection: IoU {v}"); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + s.execute("select.refineEdge", json!({"edits": [{"tool": "subject", "mode": "add"}]})).unwrap(); + assert!(cov(&s, 5, 5) >= 0.5 && cov(&s, 100, 80) >= 0.5, "subject added to the selection"); + } + + /// An edit gives what the standalone tool gives, then the edge is refined as usual, and the + /// result reaches every output. + #[test] + fn refine_edge_edits_match_the_tools_and_reach_the_outputs() { + let mut tool = two_regions(8); + tool.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap(); + tool.execute("select.quick", json!({"points": [[100, 50], [110, 50]], "size": 8})).unwrap(); + let mut ws = two_regions(8); + ws.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap(); + ws.execute("select.refineEdge", json!({"edits": [{"tool": "quick", "points": [[100, 50], [110, 50]], "size": 8}]})).unwrap(); + assert_eq!(sel_bytes(&ws, 140, 100), sel_bytes(&tool, 140, 100)); + // Refinement runs on the edited selection: Shift Edge contracts the brushed bar too. + let mut s = two_regions(8); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap(); + s.execute("select.refineEdge", json!({"shiftEdge": -100, "edits": [{"tool": "brush", "points": [[30, 80], [100, 80]], "size": 20}]})).unwrap(); + assert!(cov(&s, 60, 80) >= 0.5 && cov(&s, 60, 88) < 0.5 && cov(&s, 60, 72) < 0.5, "the 20 px bar shrank"); + // Layer mask and new-layer outputs take the edited selection. + let mut s = blurred_edge_selected(); + let r = s.execute("select.refineEdge", json!({"output": "layerMask", "edits": [{"tool": "invert"}]})).unwrap(); + assert_eq!(r["output"], "layerMask"); + let m = s.active().unwrap().doc.layers[0].mask.as_ref().unwrap(); + assert!(m.surface.sample_channel(10, 20, 0) < 0.01 && m.surface.sample_channel(70, 20, 0) > 0.99); + let mut s = blurred_edge_selected(); + let r = s.execute("select.refineEdge", json!({"output": "newLayerWithMask", "edits": [{"tool": "invert"}]})).unwrap(); + assert_eq!(r["output"], "newLayerWithMask"); + let d = s.active().unwrap(); + let m = d.doc.layers.iter().find_map(|l| l.mask.as_ref()).unwrap(); + assert!(m.surface.sample_channel(10, 20, 0) < 0.01 && m.surface.sample_channel(70, 20, 0) > 0.99); + // Clearing everything deselects. + let mut s = blurred_edge_selected(); + let r = s.execute("select.refineEdge", json!({"edits": [{"tool": "clear"}]})).unwrap(); + assert_eq!(r["selected"], false); + assert!(s.active().unwrap().doc.selection.is_none()); + } + + /// Bad edits are refused before anything changes. + #[test] + fn refine_edge_rejects_bad_edits_and_keeps_the_selection() { + let mut s = blurred_edge_selected(); + let before = sel_bytes(&s, 80, 40); + for bad in + [json!(7), json!([{"tool": "nope"}]), json!([{"tool": "lasso", "points": [[0, 0]]}]), json!([{"tool": "brush", "points": [[0, 0]], "size": -1}])] + { + assert!(s.execute("select.refineEdge", json!({"edits": bad})).is_err(), "{bad}"); + assert_eq!(sel_bytes(&s, 80, 40), before, "{bad}"); + } + } } diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 3cbbfe672b..1e373d2792 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -822,6 +822,7 @@ Split Warp Vertically Rozdělit deformaci svisle Statistics… Statistika… Refine Strokes Tahy zpřesnění + Edits Úpravy @plural {n} item|{n} items {n} položka|{n} položky|{n} položek Auto Automaticky diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index cecf610a2b..5152f361ef 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -811,6 +811,7 @@ Split Warp Vertically Verkrümmung vertikal teilen Statistics… Statistiken… Refine Strokes Verfeinerungsstriche + Edits Bearbeitungen @plural {n} item|{n} items {n} Element|{n} Elemente Auto Automatisch (print to the printer) (an den Drucker senden) diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 40069a334f..b72e1f8d2f 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1597,6 +1597,7 @@ Reset Alignment Επαναφορά στοίχισης Centre the gradient (offset 0, 0) Κεντράρισμα διαβάθμισης (μετατόπιση 0, 0) Refine Strokes Πινελιές βελτίωσης + Edits Επεξεργασίες # Gradient Editor window, opened from the options bar's gradient swatch. Gradient Editor Επεξεργασία διαβάθμισης Gradient picker Επιλογή διαβάθμισης diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index e0cf3a62e9..419dd0d452 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -819,6 +819,7 @@ Split Warp Vertically Dividir deformación verticalmente Statistics… Estadísticas… Refine Strokes Trazos de perfeccionamiento + Edits Ediciones @plural {n} item|{n} items {n} elemento|{n} elementos Auto Automático diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index b6df1c135b..c3a1b800ac 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -806,6 +806,7 @@ Split Warp Vertically Diviser la déformation verticalement Statistics… Statistiques… Refine Strokes Traits d'affinement + Edits Modifications @plural {n} item|{n} items {n} élément|{n} éléments Auto Auto (print to the printer) (imprimer sur l'imprimante) diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index bdd224c440..34d749f777 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -502,6 +502,7 @@ cameraRaw Yellow पीला Output Output Refine Edge… Refine Edge… Refine Strokes Refine Strokes + Edits संपादन Selection Selection Shift Edge Shift Edge Smart Radius Smart Radius diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index cf66b32534..275de55bdb 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1635,6 +1635,7 @@ fontWeight Thin Hajszálvékony Refine Finomítás Refine Edge Brush (R) Élfinomító ecset (R) Refine Strokes Finomító ecsetvonások + Edits Szerkesztések Reflect Visszaverődés Reflected Tükrözött Rename Channel… Csatorna átnevezése… diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 6198dd099b..51ffe4d8b9 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -799,6 +799,7 @@ Split Warp Vertically Bagi Warp Vertikal Statistics… Statistik… Refine Strokes Goresan penyempurnaan + Edits Suntingan # Counted status and panel messages @plural Exported {n} swatch to {file}|Exported {n} swatches to {file} {n} swatch diekspor ke {file} @plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {n} swatch diimpor dari {file} diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index a7b7c85381..3c65c5a25f 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -813,6 +813,7 @@ Split Warp Vertically Dividi altera verticalmente Statistics… Statistiche… Refine Strokes Tracce di perfezionamento + Edits Modifiche @plural {n} item|{n} items {n} elemento|{n} elementi Auto Automatico diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 214a56fd93..c9018c00df 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -816,6 +816,7 @@ Split Warp Vertically ワープを垂直方向に分割 Statistics… 統計情報… Refine Strokes 調整ストローク + Edits 編集 @plural {n} item|{n} items {n} 件 Auto 自動 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 00bf8dff22..c70cf5fb1c 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -793,6 +793,7 @@ Split Warp Vertically 뒤틀기 세로로 분할 Statistics… 통계… Refine Strokes 다듬기 획 + Edits 편집 # Counted status and panel messages @plural Exported {n} swatch to {file}|Exported {n} swatches to {file} 색상 견본 {n}개를 {file}(으)로 내보냈습니다 @plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {file}에서 색상 견본 {n}개를 가져왔습니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 472dab3980..20de061389 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -798,6 +798,7 @@ Split Warp Vertically Verbuiging verticaal splitsen Statistics… Statistieken… Refine Strokes Verfijningsstreken + Edits Bewerkingen @plural {n} item|{n} items {n} element|{n} elementen Auto Automatisch (print to the printer) (naar de printer afdrukken) diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 5744be49df..b66b543d74 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -815,6 +815,7 @@ Split Warp Vertically Podziel wypaczenie w pionie Statistics… Statystyki… Refine Strokes Pociągnięcia udoskonalania + Edits Edycje # Counted status and panel messages (plural forms: 1 | 2–4 except 12–14 | the rest) @plural Exported {n} swatch to {file}|Exported {n} swatches to {file} Wyeksportowano {n} próbkę koloru do {file}|Wyeksportowano {n} próbki kolorów do {file}|Wyeksportowano {n} próbek kolorów do {file} @plural Imported {n} swatch from {file}|Imported {n} swatches from {file} Zaimportowano {n} próbkę koloru z {file}|Zaimportowano {n} próbki kolorów z {file}|Zaimportowano {n} próbek kolorów z {file} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 67964ad894..e1195ea059 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -799,6 +799,7 @@ Split Warp Vertically Dividir deformação verticalmente Statistics… Estatísticas… Refine Strokes Refinar traços + Edits Edições @plural {n} item|{n} items {n} item|{n} itens Auto Automático (print to the printer) (imprimir na impressora) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 791dcf8fb8..66bc4d128e 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -336,6 +336,7 @@ Current & Below Текущий и ниже Current Layer Текущий слой Refine Strokes Штрихи уточнения + Edits Правки # Filter Last Filter Последний фильтр diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 9695ced464..f071ee5399 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -790,6 +790,7 @@ Split Warp Vertically Podeli krivljenje vertikalno Statistics… Statistika… Refine Strokes Doteraj poteze + Edits Izmene @plural {n} item|{n} items {n} stavka|{n} stavke|{n} stavki Auto Automatski (print to the printer) (štampaj na štampaču) diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 342a0ebff4..90fe0c3eef 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -782,6 +782,7 @@ Split Warp Vertically Çözgüyü Dikey Olarak Böl Statistics… İstatistikler… Refine Strokes Fırça Vuruşlarını Hassaslaştır + Edits Düzenlemeler @plural {n} item|{n} items {n} öğe Auto Otomatik (print to the printer) (yazıcıya yazdır) diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index a96f028048..56ad5e718a 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -314,6 +314,7 @@ Current & Below Поточний і нижчі Current Layer Поточний шар Refine Strokes Штрихи уточнення + Edits Правки # Filter Last Filter Останній фільтр diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 9c7155fe0d..65f866e486 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3591,6 +3591,7 @@ cameraData Lens Ống kính Decontaminate Khử nhiễm màu Refine Edge Tinh chỉnh cạnh Refine Strokes Tinh chỉnh nét + Edits Chỉnh sửa New Document With Mask Tài liệu mới có mặt nạ New Layer With Mask Lớp mới có mặt nạ Convert Point Tool Công cụ Chuyển đổi điểm diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index c1630ad97c..16cd47edf8 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -823,6 +823,7 @@ Split Warp Vertically 垂直分割变形 Statistics… 统计… Refine Strokes 调整描边 + Edits 编辑 # Counted status and panel messages @plural Exported {n} swatch to {file}|Exported {n} swatches to {file} 已将 {n} 个色板导出到 {file} @plural Imported {n} swatch from {file}|Imported {n} swatches from {file} 已从 {file} 导入 {n} 个色板 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 0771327a40..7350d24ba9 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -828,6 +828,7 @@ Split Warp Vertically 垂直分割彎曲 Statistics… 統計資料… Refine Strokes 調整筆畫 + Edits 編輯 @plural {n} item|{n} items {n} 個項目 Auto 自動 (print to the printer) (列印至印表機) diff --git a/docs/scorecard.md b/docs/scorecard.md index 6e84b9847e..be32d94aee 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -149,7 +149,7 @@ Corpus floors (`crates/io/tests/it/corpus.rs`): the enforced minimums; the corpu | TOOL-213-13 | Brush regression tests: no seams, hard edge, zero-scatter defaults, ABR never black | done | [#213](https://github.com/storytold/photocraft/issues/213) | Seam, hard-edge and zero-scatter tests in brush_tests.rs; ABR tip colour test in abr_map/tests.rs | | TOOL-213-14 | Tablet: no OS arrow cursor over the canvas on macOS, Windows and Linux | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Brush tools set CursorIcon::None (canvas.rs:1259-1275); not verified per OS | | TOOL-213-18 | Pattern Stamp tool: sibling of Clone Stamp in the S flyout | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::PatternStamp in the S group; paint.patternStamp stamps the Patterns-panel tile with Aligned and Impressionist (an approximation: it jitters each dab's tile phase, not Photoshop's smeared impressionist look); engine tests cover depths, seams, hostile input | -| TOOL-214-1 | Select and Mask workspace: view modes, Refine Edge brush, 6 outputs | partial | [#214](https://github.com/storytold/photocraft/issues/214) | select.refineEdge dialog with a live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); Refine Edge Brush (R, size, Expand Detection Area / Restore Original Edge, Alt swaps) paints select.refineEdge refineStrokes, estimated by colour-line matting (matting/brush.rs, #2972); outputs Selection, Layer Mask, New Layer, New Layer with Mask, New Document, New Document with Mask (#2998); no workspace layout | +| TOOL-214-1 | Select and Mask workspace: view modes, Refine Edge brush, 6 outputs | partial | [#214](https://github.com/storytold/photocraft/issues/214) | select.refineEdge dialog with a live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); Refine Edge Brush (R, size, Expand Detection Area / Restore Original Edge, Alt swaps) paints select.refineEdge refineStrokes, estimated by colour-line matting (matting/brush.rs, #2972); outputs Selection, Layer Mask, New Layer, New Layer with Mask, New Document, New Document with Mask (#2998); the workspace tools Quick Selection, Brush, Lasso, Object Selection, Select Subject, Clear Selection and Invert run as select.refineEdge edits, drivable by agents (#7069); no workspace layout or toolbar yet | | TOOL-214-2 | Marquee: Shift square, Alt from centre, live W x H readout | done | [#214](https://github.com/storytold/photocraft/issues/214) | canvas.rs marquee_corners: Shift square or circle, Alt from the centre, live W x H readout beside the cursor (marquee_readout); marquee_tests.rs | | TOOL-214-3 | Color Range test: Sampled Colors at Fuzziness 0 selects the exact match only | done | [#214](https://github.com/storytold/photocraft/issues/214) | color_range_fuzziness_zero_selects_exact_match_only in selection_cmds.rs asserts exact match is selected and 1-level-off pixels are rejected at fuzziness 0; tonal presets covered in color_range_presets | | TOOL-214-4 | Move tool: box-select layers, Auto-Select hover outline, double-click fit, drag to a tab | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Auto-Select hover outline (Preferences › Tools › Show Highlight on Rollover, #2834) and box-select layers from an empty spot (move_box.rs:59, layer.selectInRect, #2844) done; dragging layers with the Move tool (or from the Layers panel) onto another document's tab copies them there (layer_transfer.rs:162, layer.copyToDocument, #589/#851); double-click fit and dragging a selection's pixels to another document open | diff --git a/scorecard/tools.toml b/scorecard/tools.toml index 6a1ac772f7..2ec39ab4d9 100644 --- a/scorecard/tools.toml +++ b/scorecard/tools.toml @@ -115,7 +115,7 @@ id = "TOOL-214-1" target = "Select and Mask workspace: view modes, Refine Edge brush, 6 outputs" status = "partial" issue = 214 -note = "select.refineEdge dialog with a live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); Refine Edge Brush (R, size, Expand Detection Area / Restore Original Edge, Alt swaps) paints select.refineEdge refineStrokes, estimated by colour-line matting (matting/brush.rs, #2972); outputs Selection, Layer Mask, New Layer, New Layer with Mask, New Document, New Document with Mask (#2998); no workspace layout" +note = "select.refineEdge dialog with a live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); Refine Edge Brush (R, size, Expand Detection Area / Restore Original Edge, Alt swaps) paints select.refineEdge refineStrokes, estimated by colour-line matting (matting/brush.rs, #2972); outputs Selection, Layer Mask, New Layer, New Layer with Mask, New Document, New Document with Mask (#2998); the workspace tools Quick Selection, Brush, Lasso, Object Selection, Select Subject, Clear Selection and Invert run as select.refineEdge edits, drivable by agents (#7069); no workspace layout or toolbar yet" [[item]] id = "TOOL-214-2" From 37463ae532ee8a87c1b7694a79e78458d97ed6a9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 05:06:52 +0000 Subject: [PATCH 13/53] Stored paths: refuse Windows drive and share paths outside Windows without a document Without a document, a relative path is reported as needing the document's folder. Outside Windows a drive or share path such as C:\art\logo.png never becomes absolute, so it is refused as invalid whether or not the document has a file. --- crates/io/src/doc_path.rs | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/crates/io/src/doc_path.rs b/crates/io/src/doc_path.rs index ce6cc0fdcb..8c3c36c93d 100644 --- a/crates/io/src/doc_path.rs +++ b/crates/io/src/doc_path.rs @@ -37,11 +37,20 @@ pub fn resolve(stored: &str, document: Option<&Path>) -> Result NotFollowed::NoDocumentFolder, + Refused::Invalid if document.is_none() && craft_docpath::resolve(stored, None).is_ok_and(|p| cfg!(windows) || !is_windows_form(&p)) => { + NotFollowed::NoDocumentFolder + } r => NotFollowed::Refused(r), }) } +/// Whether `path`, as `craft_docpath` writes it, has a Windows drive or share root (`C:x`, `C:\x`, +/// `\\server\share\x`). Outside Windows no document folder makes such a path absolute. +fn is_windows_form(path: &Path) -> bool { + let s = path.as_os_str().as_encoded_bytes(); + s.starts_with(b"\\") || matches!(s, [l, b':', ..] if l.is_ascii_alphabetic()) +} + #[cfg(test)] mod tests { use super::*; @@ -62,8 +71,13 @@ mod tests { assert_eq!(resolve("img/fig:1.png", Some(&doc)), Ok(std::env::temp_dir().join("art").join("img").join("fig:1.png"))); assert_eq!(resolve("chapter1:intro.png", None), Err(NotFollowed::NoDocumentFolder)); } - // On Unix a Windows path names no file: it is refused, not reported as relative. - #[cfg(unix)] - assert_eq!(resolve(r"C:\art\logo.png", Some(&doc)), Err(NotFollowed::Refused(Refused::Invalid))); + // Outside Windows a Windows path names no file: it is refused, not reported as relative. + if !cfg!(windows) { + for stored in [r"C:\art\logo.png", "C:logo.png"] { + assert_eq!(resolve(stored, Some(&doc)), Err(NotFollowed::Refused(Refused::Invalid)), "{stored}"); + assert_eq!(resolve(stored, None), Err(NotFollowed::Refused(Refused::Invalid)), "{stored}"); + } + assert_eq!(resolve("../logo.png", None), Err(NotFollowed::NoDocumentFolder)); + } } } From 82a7cfa3c540b1ee8309fe072aebad00cff0e539 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 05:08:53 +0000 Subject: [PATCH 14/53] CI: check the lockfile as Cargo reads it, and pin the release checkout The git-sources step fails when Cargo.lock is missing or out of date with the manifests, when .cargo/config.toml replaces or patches a source, or when the parsed lockfile has a git source outside github.com/storytold. The release job's checkout is pinned to a commit. --- .github/workflows/ci.yml | 19 +++++++++++++++++-- .github/workflows/release.yml | 23 +++++++++++++++++++---- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07f54982a4..c1ea76222c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,9 +52,24 @@ jobs: fetch-depth: 0 - name: Git dependencies come from storytold run: | - if grep -E '^source = "git\+' Cargo.lock | grep -v '"git+https://github.com/storytold/'; then - echo "Cargo.lock has a git dependency outside github.com/storytold"; exit 1 + set -euo pipefail + test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; } + # Source replacement and patches in cargo config never show up in Cargo.lock. + if grep -qE '^[[:space:]]*(replace-with|\[patch)' .cargo/config.toml 2>/dev/null; then + echo ".cargo/config.toml replaces or patches a source"; exit 1 fi + # Fails when Cargo.lock is out of date with the manifests. + cargo metadata --locked --format-version 1 > /dev/null + python3 - <<'PY' + import sys, tomllib + lock = tomllib.load(open("Cargo.lock", "rb")) + bad = sorted({p["source"] for p in lock.get("package", []) + if p.get("source", "").startswith("git+") + and not p["source"].startswith("git+https://github.com/storytold/")}) + if bad: + print("Git dependencies outside github.com/storytold:", *bad, sep="\n") + sys.exit(1) + PY - id: pick env: EVENT: ${{ github.event_name }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4666f5a851..99028d8a38 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,12 +24,27 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v4 - - name: Check Cargo.lock + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Git dependencies come from storytold run: | - if grep -E '^source = "git\+' Cargo.lock | grep -v '"git+https://github.com/storytold/'; then - echo "Cargo.lock has a git dependency outside github.com/storytold"; exit 1 + set -euo pipefail + test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; } + # Source replacement and patches in cargo config never show up in Cargo.lock. + if grep -qE '^[[:space:]]*(replace-with|\[patch)' .cargo/config.toml 2>/dev/null; then + echo ".cargo/config.toml replaces or patches a source"; exit 1 fi + # Fails when Cargo.lock is out of date with the manifests. + cargo metadata --locked --format-version 1 > /dev/null + python3 - <<'PY' + import sys, tomllib + lock = tomllib.load(open("Cargo.lock", "rb")) + bad = sorted({p["source"] for p in lock.get("package", []) + if p.get("source", "").startswith("git+") + and not p["source"].startswith("git+https://github.com/storytold/")}) + if bad: + print("Git dependencies outside github.com/storytold:", *bad, sep="\n") + sys.exit(1) + PY release: needs: git-sources From ca13e355c9f1cec0d7d9907d34391e5b85b79c8d Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Fri, 23 Oct 2026 11:14:50 +0530 Subject: [PATCH 15/53] fix(algo,engine): bound brush stroke work and total brush edit area Review of #8783: - matting::brush `segments` no longer collects its pieces: they are made as they are painted, and each segment is first cut to the painted area grown by the brush. A 1 px stroke zigzagging across 2 million px (within MAX_SWEPT_AREA) split into ~134 million pieces collected up front (~2 GB) in brush_region and EdgeBrush::new; now ~5000 pieces. - select.refineEdge `edits`: the Brush edits' swept area in all is bounded by MAX_SWEPT_AREA, as refineStrokes' is. Co-Authored-By: Claude Opus 5.5 --- crates/algo/src/matting.rs | 2 +- crates/algo/src/matting/brush.rs | 82 ++++++++++++++++++-------- crates/engine/src/select_mask_edits.rs | 21 +++++-- 3 files changed, 75 insertions(+), 30 deletions(-) diff --git a/crates/algo/src/matting.rs b/crates/algo/src/matting.rs index 2008256702..892a5f145f 100644 --- a/crates/algo/src/matting.rs +++ b/crates/algo/src/matting.rs @@ -238,7 +238,7 @@ pub fn morph(src: &[f32], w: usize, h: usize, r: usize, grow: bool) -> Vec mod brush; mod morphology; -pub use brush::{EdgeBrush, EdgeStroke, MAX_SWEPT_AREA, SIZE_RANGE as BRUSH_SIZE_RANGE, brush_region, swept_area}; +pub use brush::{EdgeBrush, EdgeStroke, MAX_SWEPT_AREA, SIZE_RANGE as BRUSH_SIZE_RANGE, brush_region, stroke_area, swept_area}; /// Running max (or min) over a window of radius `r` (edge-clipped), van Herk / Gil–Werman: /// three comparisons per sample whatever the radius. diff --git a/crates/algo/src/matting/brush.rs b/crates/algo/src/matting/brush.rs index ae78e28d93..ee54ea1875 100644 --- a/crates/algo/src/matting/brush.rs +++ b/crates/algo/src/matting/brush.rs @@ -81,8 +81,9 @@ pub fn swept_area(strokes: &[EdgeStroke]) -> f64 { strokes.iter().map(|s| stroke_area(&s.points, s.size)).sum() } -/// The area one stroke of `points` sweeps with a brush `size` wide: length × size + size². -fn stroke_area(points: &[(f32, f32)], size: f32) -> f64 { +/// The area one stroke of `points` sweeps with a brush `size` wide: length × size + size²; +/// non-finite strokes count as infinite. +pub fn stroke_area(points: &[(f32, f32)], size: f32) -> f64 { let size = f64::from(size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end())); let len: f64 = points.windows(2).filter_map(|w| Some(f64::from((w.get(1)?.0 - w.first()?.0).hypot(w.get(1)?.1 - w.first()?.1)))).sum(); if len.is_finite() { len * size + size * size } else { f64::INFINITY } @@ -107,7 +108,7 @@ pub fn brush_region(points: &[(f32, f32)], size: f32, hardness: f32, canvas: Rec } let bw = bbox.width() as usize; let mut cov = vec![0.0f32; bw.checked_mul(bbox.height() as usize)?]; - for (a, b) in segments(points, r) { + for (a, b) in segments(points, r, bbox) { let seg = dab_bounds(a, r).union(&dab_bounds(b, r)).intersect(&bbox); for y in seg.y0..seg.y1 { for x in seg.x0..seg.x1 { @@ -124,17 +125,40 @@ pub fn brush_region(points: &[(f32, f32)], size: f32, hardness: f32, canvas: Rec mask.iter().any(|v| *v > 0).then_some(Region { bbox, mask }) } -/// The stroke's path as short segments: points closer than a quarter of the radius to the last -/// kept one are dropped, and long segments are split at most `r` long, so each segment's box -/// stays close to the area it paints. -fn segments(points: &[(f32, f32)], r: f32) -> Vec<((f32, f32), (f32, f32))> { - let Some(&first) = points.first() else { return Vec::new() }; +/// The part of the segment `a`–`b` inside `r` (Liang–Barsky), or `None` when it misses it. +fn clip_segment(a: (f32, f32), b: (f32, f32), r: Rect) -> Option<((f32, f32), (f32, f32))> { + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let (mut t0, mut t1) = (0.0f32, 1.0f32); + for (p, q) in [(-dx, a.0 - r.x0 as f32), (dx, r.x1 as f32 - a.0), (-dy, a.1 - r.y0 as f32), (dy, r.y1 as f32 - a.1)] { + if p == 0.0 { + if q < 0.0 { + return None; + } + } else { + let t = q / p; + if p < 0.0 { + t0 = t0.max(t); + } else { + t1 = t1.min(t); + } + } + } + (t0 <= t1 && t0.is_finite() && t1.is_finite()).then_some(((a.0 + dx * t0, a.1 + dy * t0), (a.0 + dx * t1, a.1 + dy * t1))) +} + +/// The stroke's path as short segments within reach of `clip`: points closer than a quarter of +/// the radius to the last kept one are dropped, each segment is cut to `clip` grown by the brush +/// (what lies beyond can't paint inside it), and long ones are split at most `r` long, so each +/// piece's box stays close to the area it paints. The pieces are made as they are painted, never +/// collected, and only where they can paint: a 1 px stroke zigzagging across 2 million px is over +/// 100 million pieces in all, within [`MAX_SWEPT_AREA`] (the #8783 review). +fn segments(points: &[(f32, f32)], r: f32, clip: Rect) -> impl Iterator { let min_step = (r / 4.0).max(0.5); let max_step = r.max(2.0); - let mut kept = vec![first]; - for p in points.iter().skip(1) { - let last = kept.last().copied().unwrap_or(first); - if (p.0 - last.0).hypot(p.1 - last.1) >= min_step { + let reach = clip.inflate((r + 2.0).ceil().clamp(0.0, 1e7) as i32); + let mut kept: Vec<(f32, f32)> = Vec::new(); + for p in points { + if kept.last().is_none_or(|l| (p.0 - l.0).hypot(p.1 - l.1) >= min_step) { kept.push(*p); } } @@ -143,20 +167,17 @@ fn segments(points: &[(f32, f32)], r: f32) -> Vec<((f32, f32), (f32, f32))> { { kept.push(end); } - if kept.len() == 1 { - return vec![(first, first)]; - } - let mut out = Vec::new(); - for w in kept.windows(2) { - let (Some(&a), Some(&b)) = (w.first(), w.get(1)) else { continue }; + // A single point is one dab. + let dab = if kept.len() == 1 { kept.first().map(|p| (*p, *p)) } else { None }; + let pairs: Vec<((f32, f32), (f32, f32))> = kept.windows(2).filter_map(|w| clip_segment(*w.first()?, *w.get(1)?, reach)).collect(); + dab.into_iter().chain(pairs.into_iter().flat_map(move |(a, b)| { let pieces = ((b.0 - a.0).hypot(b.1 - a.1) / max_step).ceil().clamp(1.0, 1e6) as usize; - let at = |k: usize| { + let at = move |k: usize| { let t = k as f32 / pieces as f32; (a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t) }; - out.extend((0..pieces).map(|k| (at(k), at(k + 1)))); - } - out + (0..pieces).map(move |k| (at(k), at(k + 1))) + })) } impl EdgeBrush { @@ -193,7 +214,7 @@ impl EdgeBrush { cov.clear(); cov.resize(sw * sb.height() as usize, 0.0); // The stroke's coverage: the most any of its segments gives (a single dab for one point). - for (a, b) in segments(&s.points, r) { + for (a, b) in segments(&s.points, r, sb) { let seg = dab_bounds(a, r).union(&dab_bounds(b, r)).intersect(&sb); for y in seg.y0..seg.y1 { for x in seg.x0..seg.x1 { @@ -407,4 +428,19 @@ mod tests { // Sizes and hardness outside their ranges are clamped, not rejected. assert!(brush_region(&[(50.0, 25.0)], 0.0, 500.0, canvas).is_some()); } + /// A 1 px stroke zigzagging across 2 million px stays within [`MAX_SWEPT_AREA`] yet splits + /// into over 100 million pieces: painting it must not collect them all first (it allocated + /// about 2 GB up front; the #8783 review). + #[test] + fn a_long_thin_zigzag_paints_without_collecting_its_pieces() { + let canvas = Rect::new(0, 0, 64, 64); + let points: Vec<(f32, f32)> = (0..135).map(|i| (if i % 2 == 0 { 0.0 } else { 2_000_000.0 }, 32.0 + (i % 3) as f32)).collect(); + assert!(stroke_area(&points, 1.0) <= MAX_SWEPT_AREA); + // Only the pieces within reach of the canvas are made. + assert!(segments(&points, 0.5, canvas).count() < 134 * 40, "{}", segments(&points, 0.5, canvas).count()); + let r = brush_region(&points, 1.0, 100.0, canvas).expect("it paints its row"); + assert!(r.at(10, 32) > 0.5); + let strokes = [EdgeStroke { points, size: 1.0, add: true }]; + assert!(EdgeBrush::new(&strokes, canvas).is_some_and(|b| b.at(10, 32) > 0.5)); + } } diff --git a/crates/engine/src/select_mask_edits.rs b/crates/engine/src/select_mask_edits.rs index 10bffb5bff..7d75b50510 100644 --- a/crates/engine/src/select_mask_edits.rs +++ b/crates/engine/src/select_mask_edits.rs @@ -114,6 +114,8 @@ pub fn parse(p: &Value) -> Result> { } let size_range = matting::BRUSH_SIZE_RANGE; let mut total = 0usize; + // The area the Brush edits sweep in all, bounded as `refineStrokes`' is. + let mut swept = 0.0f64; let mut edits = Vec::with_capacity(list.len()); for e in list { let o = e.as_object().ok_or_else(|| bad("each edit must be an object with a tool"))?; @@ -125,12 +127,15 @@ pub fn parse(p: &Value) -> Result> { add: adds(o)?, sample_all_layers: flag(o, "sampleAllLayers", false)?, }, - "brush" => SelectionEdit::Brush { - points: points(o, 1, &mut total)?, - size: number(o, "size", 30.0, size_range.clone())?, - hardness: number(o, "hardness", 100.0, 0.0..=100.0)?, - add: adds(o)?, - }, + "brush" => { + let points = points(o, 1, &mut total)?; + let size = number(o, "size", 30.0, size_range.clone())?; + swept += matting::stroke_area(&points, size); + if swept > matting::MAX_SWEPT_AREA { + return Err(bad("brush edits paint too large an area")); + } + SelectionEdit::Brush { points, size, hardness: number(o, "hardness", 100.0, 0.0..=100.0)?, add: adds(o)? } + } "lasso" => SelectionEdit::Lasso { points: points(o, 3, &mut total)?, add: adds(o)? }, "object" => { let area = match (o.get("rect"), o.get("point")) { @@ -267,6 +272,8 @@ mod tests { assert!(parse(&json!({})).expect("none").is_empty()); assert!(parse(&json!({"edits": null})).expect("none").is_empty()); assert!(has_edits(&p) && !has_edits(&json!({"edits": []})) && !has_edits(&json!({}))); + // One long brush edit within the area limit is fine; two of them (rejects_hostile_edits) are not. + assert!(parse(&json!({"edits": [{"tool": "brush", "points": [[0, 0], [10_000_000, 0]], "size": 20}]})).is_ok()); } #[test] @@ -296,6 +303,8 @@ mod tests { json!([{"tool": "subject", "mode": "intersect"}]), json!([{"tool": "brush", "points": many_points}]), json!(many_edits), + // Each within the limit, together past it. + json!(vec![json!({"tool": "brush", "points": [[0, 0], [10_000_000, 0]], "size": 20}); 2]), ] { assert!(parse(&json!({"edits": bad})).is_err(), "{bad}"); } From 6ed69874fde8b11ad3be33c59beae6fc40cf6cf2 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 06:00:19 +0000 Subject: [PATCH 16/53] Profiles: no debug info for dependencies in dev and test builds Workspace crates keep line tables, so panics in PhotoCraft code still show file and line. --- Cargo.toml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 1215d84570..bacd48500c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,13 +72,16 @@ unsafe_code = "forbid" dbg_macro = "warn" todo = "warn" +# Dependencies carry no debug info: their frames in a backtrace lose file:line, and the target dir +# and link times shrink. The test profile inherits both settings. [profile.dev.package."*"] opt-level = 2 +debug = false [profile.dev] opt-level = 1 # Line tables keep file:line in panic backtraces but drop variable info, which roughly halves link -# time and PDB size for the ~260 test binaries (#2204). CI sets the same through +# time and PDB size for the test binaries (#2204). CI sets the same through # CARGO_PROFILE_DEV_DEBUG (and no debuginfo at all for the Windows tests). To inspect locals in a # debugger, build with CARGO_PROFILE_DEV_DEBUG=true. debug = "line-tables-only" From 175c9c1e75c7bd206d7498396ef09a4acd2dd26d Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 06:00:38 +0000 Subject: [PATCH 17/53] xtask: test with nextest, doctests separately, one wasm check call cargo xtask test and the ci test step run cargo nextest over libs, bins and tests when nextest is installed (cargo test otherwise, with a notice), then cargo test --doc. The wasm check is one cargo check with --keep-going over the wasm-safe crates, plus one call per optional web feature. Clippy runs with --keep-going. --- docs/development.md | 7 +++-- xtask/src/main.rs | 74 +++++++++++++++++++++++++++++++++------------ 2 files changed, 58 insertions(+), 23 deletions(-) diff --git a/docs/development.md b/docs/development.md index fd5512b710..f285c93e97 100644 --- a/docs/development.md +++ b/docs/development.md @@ -39,14 +39,15 @@ cargo run --release -p photocraft -- path/to/image.psd # desktop app cargo run --release -p photocraft -- --control 7878 --control-token-file .private/control.token \ --automation-read-root work --automation-write-root work cargo test --workspace # everything -cargo xtask ci # fmt + clippy + tests + layers + wasm +cargo xtask test # tests (nextest when installed) + doctests +cargo xtask ci # fmt + clippy + tests + doctests + layers + wasm cargo xtask stats # tests and lines per crate cargo xtask parity # Photoshop menu coverage -> docs/parity-checklist.md ``` Image code is slow at `opt-level 0`, so the workspace profile builds dependencies at `opt-level 2`. Use `--release` for anything interactive. Test builds compile ui-egui itself at `opt-level 0` (`[profile.test.package.photocraft-ui-egui]` in `Cargo.toml`): it was one of the biggest test-build units, and its tests run no heavy pixel code of their own. -The dev profile builds with `debug = "line-tables-only"`: panic backtraces keep file and line, and test binaries link about twice as fast. To inspect local variables in a debugger, build with `CARGO_PROFILE_DEV_DEBUG=true`. +The dev profile builds PhotoCraft's crates with `debug = "line-tables-only"` and dependencies with no debug info: panic backtraces keep file and line in PhotoCraft code, and test binaries link about twice as fast. To inspect local variables in a debugger, build with `CARGO_PROFILE_DEV_DEBUG=true`. ### Faster test loops @@ -54,7 +55,7 @@ Most of a `cargo test` run is linking test binaries and running them one after a ```sh cargo test -p --lib --tests # skip examples and doctests (ui-egui has 11 examples) -cargo test -p --test # one integration-test file +cargo test -p --test it :: # one integration-test module (tests/it/.rs) cargo nextest run --workspace # parallel across binaries (cargo install cargo-nextest --locked) cargo test --doc --workspace # nextest does not run doctests ``` diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 7136f90ecf..0f40543829 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -16,15 +16,17 @@ mod stats; mod version; use std::path::{Path, PathBuf}; -use std::process::{Command, ExitCode}; +use std::process::{Command, ExitCode, Stdio}; const USAGE: &str = "\ usage: cargo xtask commands: layers enforce the crate dependency layering (docs/architecture.md §3) - wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates - ci fmt --check, clippy -D warnings, test, layers, wasm (stops at first failure) + wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates (one call, plus + one per optional web feature) + test workspace tests (cargo nextest when installed, else cargo test), then doctests + ci fmt --check, clippy -D warnings, test, doctest, layers, wasm (stops at first failure) corpus [--all | --pngsuite | --psd | --psd-tools | --pixls | --atn | --photoshop | --gimp] [--local] [--update-manifest] show where test corpora live and their pins (xtask/src/corpus_pins.rs), or fetch them into corpus/ (pinned commits, sha256-verified; --all = every corpus but pixls, @@ -54,6 +56,7 @@ fn main() -> ExitCode { let result = match args.first().map(String::as_str) { Some("layers") => cmd_layers(), Some("wasm") => cmd_wasm(), + Some("test") => cmd_test().and_then(|()| cmd_doctest()), Some("ci") => cmd_ci(), Some("corpus") => corpus::cmd(&rest), Some("test-corpus") => corpus::test_cmd(&rest), @@ -157,15 +160,23 @@ fn wasm_set() -> Result, String> { /// (package, feature). const WASM_FEATURES: &[(&str, &str)] = &[("photocraft-codecs", "heif")]; +/// One `cargo check` for the whole set: each crate is checked once, and `--keep-going` still +/// reports every failing crate. Features unify across the set, so each crate is checked with the +/// union of the features the set enables. The optional features get a call each, so the crates +/// are also checked without them. fn cmd_wasm() -> Result<(), String> { let set = wasm_set()?; - let mut results = Vec::new(); - for pkg in &set { - let mut c = cargo(); - c.args(["check", "--target", "wasm32-unknown-unknown", "-p", pkg]); - let ok = run(c, &format!("cargo check --target wasm32-unknown-unknown -p {pkg}")).is_ok(); - results.push((pkg.clone(), ok)); + if set.is_empty() { + return Err("no wasm-safe crates found in the workspace metadata".into()); } + let mut results = Vec::new(); + let mut c = cargo(); + c.args(["check", "--target", "wasm32-unknown-unknown", "--keep-going"]); + for pkg in &set { + c.args(["-p", pkg]); + } + let ok = run(c, &format!("cargo check --target wasm32-unknown-unknown --keep-going -p {}", set.join(" -p "))).is_ok(); + results.push((format!("{} crates", set.len()), ok)); for (pkg, feature) in WASM_FEATURES { let mut c = cargo(); c.args(["check", "--target", "wasm32-unknown-unknown", "-p", pkg, "--features", feature]); @@ -177,7 +188,36 @@ fn cmd_wasm() -> Result<(), String> { println!(" {:<6} {p}", if *ok { "ok" } else { "FAIL" }); } let failed = results.iter().filter(|r| !r.1).count(); - if failed == 0 { Ok(()) } else { Err(format!("{failed} crate(s) failed the wasm check")) } + if failed == 0 { Ok(()) } else { Err(format!("{failed} wasm check(s) failed")) } +} + +/// Test targets for the test build. Examples are left out so they aren't linked; clippy +/// `--all-targets` still compiles them. +const TEST_TARGETS: [&str; 4] = ["--workspace", "--lib", "--bins", "--tests"]; + +fn nextest_available() -> bool { + cargo().args(["nextest", "--version"]).stdout(Stdio::null()).stderr(Stdio::null()).status().is_ok_and(|s| s.success()) +} + +fn cmd_test() -> Result<(), String> { + let mut c = cargo(); + let tool = if nextest_available() { + c.args(["nextest", "run"]); + "nextest run" + } else { + eprintln!("note: cargo-nextest is not installed, running cargo test (cargo install cargo-nextest --locked)"); + c.arg("test"); + "test" + }; + c.args(TEST_TARGETS); + run(c, &format!("cargo {tool} {}", TEST_TARGETS.join(" "))) +} + +/// nextest does not run doctests. +fn cmd_doctest() -> Result<(), String> { + let mut c = cargo(); + c.args(["test", "--workspace", "--doc"]); + run(c, "cargo test --workspace --doc") } fn cmd_parity() -> Result<(), String> { @@ -201,18 +241,12 @@ fn cmd_ci() -> Result<(), String> { "clippy", Box::new(|| { let mut c = cargo(); - c.args(["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]); - run(c, "cargo clippy --workspace --all-targets -- -D warnings") - }), - ), - ( - "test", - Box::new(|| { - let mut c = cargo(); - c.args(["test", "--workspace"]); - run(c, "cargo test --workspace") + c.args(["clippy", "--workspace", "--all-targets", "--keep-going", "--", "-D", "warnings"]); + run(c, "cargo clippy --workspace --all-targets --keep-going -- -D warnings") }), ), + ("test", Box::new(cmd_test)), + ("doctest", Box::new(cmd_doctest)), ("layers", Box::new(cmd_layers)), ("wasm", Box::new(cmd_wasm)), ]; From 2d894eab2b65ed68aee1f811c84e892d2affb6f3 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Fri, 23 Oct 2026 11:42:26 +0530 Subject: [PATCH 18/53] fix(engine): tile-wise Invert edit; a run of Inverts costs one Review of #8783: - The Invert edit no longer builds a canvas-sized buffer: the canvas takes the inverse of the selection's untouched value (one shared tile for every fully covered tile, as fill_rect stores it), then only the tiles the selection holds are inverted. Byte-for-byte equal to the dense inversion; a 100k x 100k canvas inverts in well under a second. - A run of Inverts is applied as one or none (pairs cancel exactly), so 4096 of them cost at most one pass between other edits. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/select_mask_edits.rs | 93 ++++++++++++++++++++++++-- 1 file changed, 89 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/select_mask_edits.rs b/crates/engine/src/select_mask_edits.rs index 7d75b50510..6506ac72ea 100644 --- a/crates/engine/src/select_mask_edits.rs +++ b/crates/engine/src/select_mask_edits.rs @@ -201,10 +201,27 @@ fn polygon_region(points: &[(f32, f32)], canvas: Rect) -> Option { mask.iter().any(|v| *v > 0).then_some(Region { bbox, mask }) } -/// `base` inverted over `canvas`; `None` when that leaves nothing selected. +/// `base` inverted over `canvas`; `None` when that leaves nothing selected. Tile by tile: the +/// canvas takes the inverse of `base`'s untouched value (one tile shared by every fully covered +/// tile, as `fill_rect` stores it), then only the tiles `base` holds are inverted, so neither a +/// sparse selection nor a huge canvas costs a canvas-sized buffer (the #8783 review). fn invert(base: Option<&Surface>, canvas: Rect) -> Option { - let m: Vec = sel::mask_from_surface(base, canvas).into_iter().map(|v| 1.0 - v).collect(); - let mut s = sel::mask_to_surface(&m, canvas); + let outside = base.map_or(0.0, |b| b.default_pixel().first().copied().unwrap_or(0.0).clamp(0.0, 1.0)); + let mut s = Surface::new(photocraft_color::PixelFormat::GRAY8); + if outside < 1.0 { + s.fill_rect(canvas, &[1.0 - outside]); + } + if let Some(b) = base { + let ch = b.channels().max(1); + for (tc, _) in b.tiles() { + let r = tc.rect().intersect(&canvas); + if r.is_empty() { + continue; + } + let inverted: Vec = b.read_region(r).chunks_exact(ch).map(|px| 1.0 - px.first().copied().unwrap_or(0.0).clamp(0.0, 1.0)).collect(); + s.write_region(r, &inverted); + } + } s.prune(); (!s.content_bounds().is_empty()).then_some(s) } @@ -242,8 +259,25 @@ pub fn apply_one(doc: &Document, layer: Option, base: Option, } /// `edits` applied in order to `base`: the selection Select and Mask refines. +/// A run of Inverts is one Invert or none (pairs cancel exactly), so thousands of them cost at +/// most one pass each between other edits (the #8783 review). pub fn apply(doc: &Document, layer: Option, base: Option, edits: &[SelectionEdit]) -> Option { - edits.iter().fold(base, |sel, e| apply_one(doc, layer, sel, e)) + let mut sel = base; + let mut invert = false; + for e in edits { + if *e == SelectionEdit::Invert { + invert = !invert; + continue; + } + if std::mem::take(&mut invert) { + sel = apply_one(doc, layer, sel, &SelectionEdit::Invert); + } + sel = apply_one(doc, layer, sel, e); + } + if invert { + sel = apply_one(doc, layer, sel, &SelectionEdit::Invert); + } + sel } #[cfg(test)] @@ -309,4 +343,55 @@ mod tests { assert!(parse(&json!({"edits": bad})).is_err(), "{bad}"); } } + + /// The dense inversion this replaced: the whole canvas read, inverted and written back. + fn dense_invert(base: Option<&Surface>, canvas: Rect) -> Vec { + let m: Vec = sel::mask_from_surface(base, canvas).into_iter().map(|v| 1.0 - v).collect(); + bytes(Some(&sel::mask_to_surface(&m, canvas)), canvas) + } + + fn bytes(s: Option<&Surface>, canvas: Rect) -> Vec { + sel::mask_from_surface(s, canvas).into_iter().map(|v| (v * 255.0).round() as u8).collect() + } + + /// Tile by tile, Invert gives exactly what the dense inversion gave: partial edge tiles, soft + /// values, a selection reaching past the canvas, a mask whose untouched pixels are white, and + /// nothing selected (everything, inverted). + #[test] + fn tiled_invert_matches_the_dense_one() { + let canvas = Rect::new(0, 0, 600, 333); + let mut soft = Surface::new(photocraft_color::PixelFormat::GRAY8); + soft.fill_rect(Rect::new(250, 20, 900, 300), &[1.0]); + soft.fill_rect(Rect::new(10, 10, 40, 30), &[0.4]); + let mut white = Surface::with_default(photocraft_color::PixelFormat::GRAY8, &[1.0]); + white.fill_rect(Rect::new(300, 100, 520, 140), &[0.0]); + for base in [None, Some(soft), Some(white)] { + let got = bytes(invert(base.as_ref(), canvas).as_ref(), canvas); + assert_eq!(got, dense_invert(base.as_ref(), canvas)); + } + // Inverting everything selects nothing. + let mut all = Surface::new(photocraft_color::PixelFormat::GRAY8); + all.fill_rect(canvas, &[1.0]); + assert!(invert(Some(&all), canvas).is_none()); + } + + /// A huge canvas inverts without a canvas-sized buffer, and a run of Inverts costs one. + #[test] + fn invert_on_a_huge_canvas_and_runs_of_inverts() { + let doc = Document::new("huge", photocraft_doc::Size::new(100_000, 100_000), photocraft_doc::ColorMode::Rgb, photocraft_color::SampleType::U8); + let mut small = Surface::new(photocraft_color::PixelFormat::GRAY8); + small.fill_rect(Rect::new(10, 10, 20, 20), &[1.0]); + let t = std::time::Instant::now(); + let one = apply(&doc, None, Some(small.clone()), &[SelectionEdit::Invert]).expect("almost everything selected"); + assert!(one.sample_channel(15, 15, 0) < 0.01 && one.sample_channel(99_999, 99_999, 0) > 0.99 && one.sample_channel(50_000, 5, 0) > 0.99); + let odd = apply(&doc, None, Some(small.clone()), &vec![SelectionEdit::Invert; 4095]).expect("inverted"); + assert!(odd.sample_channel(15, 15, 0) < 0.01 && odd.sample_channel(99_999, 99_999, 0) > 0.99); + let even = apply(&doc, None, Some(small.clone()), &vec![SelectionEdit::Invert; 4096]).expect("the selection itself"); + assert!(even.sample_channel(15, 15, 0) > 0.99 && even.sample_channel(50_000, 50_000, 0) < 0.01); + // Runs split by another edit invert on each side of it. + let split = [SelectionEdit::Invert, SelectionEdit::Invert, SelectionEdit::Invert, SelectionEdit::Clear, SelectionEdit::Invert]; + let all = apply(&doc, None, Some(small), &split).expect("cleared, then everything"); + assert!(all.sample_channel(15, 15, 0) > 0.99 && all.sample_channel(70_000, 3, 0) > 0.99); + assert!(t.elapsed() < std::time::Duration::from_secs(20), "{:?}", t.elapsed()); + } } From b87cfb3c6a31f8771c885995bb81fe996bc25628 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 07:20:54 +0000 Subject: [PATCH 19/53] Tests: spread the two longest sweeps over the days swept_blocks_match_the_pixel_search skips the cases where the colour mode reaches extrude only through a channel count and ink setting an earlier mode already covered. It and damaged_layer_data_opens_flattened run a quarter of their cases per run, a different quarter each UTC day (photocraft-testkit's Sweep). PHOTOCRAFT_FULL_SWEEP=1 runs every case, and PHOTOCRAFT_SWEEP_PART= replays the part a failing run printed. --- Cargo.lock | 2 ++ crates/algo/Cargo.toml | 1 + crates/algo/src/stylize2/tests.rs | 13 ++++++- crates/io/Cargo.toml | 1 + crates/io/tests/it/tiff_layers.rs | 17 +++++++--- crates/testkit/src/lib.rs | 2 ++ crates/testkit/src/sweep.rs | 56 +++++++++++++++++++++++++++++++ docs/development.md | 2 +- 8 files changed, 87 insertions(+), 7 deletions(-) create mode 100644 crates/testkit/src/sweep.rs diff --git a/Cargo.lock b/Cargo.lock index 5c96f56119..91f34c5102 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4699,6 +4699,7 @@ dependencies = [ "photocraft-color", "photocraft-geom", "photocraft-raster", + "photocraft-testkit", "rayon", "rustfft", "serde", @@ -4921,6 +4922,7 @@ dependencies = [ "photocraft-psd", "photocraft-raster", "photocraft-raw", + "photocraft-testkit", "photocraft-text", "photocraft-vector", "proptest", diff --git a/crates/algo/Cargo.toml b/crates/algo/Cargo.toml index c4f34b4b1d..af50c1d20e 100644 --- a/crates/algo/Cargo.toml +++ b/crates/algo/Cargo.toml @@ -21,4 +21,5 @@ rustfft = "6.4.1" rayon = { workspace = true } [dev-dependencies] +photocraft-testkit = { workspace = true } serde_json = { workspace = true } diff --git a/crates/algo/src/stylize2/tests.rs b/crates/algo/src/stylize2/tests.rs index 3d7f6b5235..c7139153af 100644 --- a/crates/algo/src/stylize2/tests.rs +++ b/crates/algo/src/stylize2/tests.rs @@ -29,14 +29,25 @@ const MODELS: [ColorMode; 8] = #[test] fn swept_blocks_match_the_pixel_search() { let bounds = Rect::new(-13, -9, 31, 26); + // Unless the heights are level-based, the colour mode reaches extrude only through the channel + // count and whether the samples are CMYK ink, so modes that agree on both repeat those cases. + // A run takes a quarter of the rest, a different quarter each day. + let mut seen = std::collections::HashSet::new(); + let sweep = photocraft_testkit::Sweep::new(4); + let mut case = 0; for mode in MODELS { for alpha in [false, true] { let n = PixelFormat::new(mode, SampleType::F32, alpha).channels(); let ctx = Ctx { bounds, mode, alpha }; + let repeat = !seen.insert((n, alpha, mode == ColorMode::Cmyk)); for kind in 0..5 { let src = image(bounds.inflate(20), n, kind); for (size, depth) in [(2.0, 0.0), (2.0, 255.0), (3.5, 190.0), (8.0, 64.0), (22.0, 255.0), (255.0, 100.0)] { - for flags in 0..8 { + for flags in (0..8).filter(|f| !repeat || f & 1 != 0) { + case += 1; + if !sweep.includes(case) { + continue; + } let spec = ExtrudeSpec { kind: ExtrudeType::Blocks, size, diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index eb8c0b00d7..261c16481c 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -54,4 +54,5 @@ photocraft-affinity = { workspace = true, features = ["synth"] } flate2 = { workspace = true } photocraft-psd = { workspace = true, features = ["testgen"] } photocraft-raw = { workspace = true, features = ["testgen"] } +photocraft-testkit = { workspace = true } proptest = { workspace = true } diff --git a/crates/io/tests/it/tiff_layers.rs b/crates/io/tests/it/tiff_layers.rs index 382f45bc44..1bbe8e2f4b 100644 --- a/crates/io/tests/it/tiff_layers.rs +++ b/crates/io/tests/it/tiff_layers.rs @@ -333,21 +333,28 @@ fn damaged_layer_data_opens_flattened() { let r2 = import("x.tif", &bad).unwrap(); assert_eq!(r2.document.layers.len(), 1); assert!(r2.warnings.iter().any(|w| w.contains("could not be read")), "{:?}", r2.warnings); - // Every truncation and a sweep of bit flips through the whole file: never a panic. - for cut in (0..r.bytes.len()).step_by(11) { + // Truncations and bit flips through the whole file: never a panic. A run takes a quarter of + // them, a different quarter each day. + let sweep = photocraft_testkit::Sweep::new(4); + let mut case = 0; + let mut runs = || { + case += 1; + sweep.includes(case) + }; + for cut in (0..r.bytes.len()).step_by(11).filter(|_| runs()) { let _ = import("x.tif", &r.bytes[..cut]); } - for i in (0..r.bytes.len()).step_by(29) { + for i in (0..r.bytes.len()).step_by(29).filter(|_| runs()) { let mut b = r.bytes.clone(); b[i] ^= 0x5a; let _ = import("x.tif", &b); } for name in ["layered-le-8bit.tif", "layered-be-16bit.tif"] { let Some(bytes) = fixture(name) else { continue }; - for cut in (0..bytes.len()).step_by(7) { + for cut in (0..bytes.len()).step_by(7).filter(|_| runs()) { let _ = import(name, &bytes[..cut]); } - for i in (0..bytes.len()).step_by(5) { + for i in (0..bytes.len()).step_by(5).filter(|_| runs()) { let mut b = bytes.clone(); b[i] ^= 0xff; let _ = import(name, &b); diff --git a/crates/testkit/src/lib.rs b/crates/testkit/src/lib.rs index 03af4b630f..06f70a4e2d 100644 --- a/crates/testkit/src/lib.rs +++ b/crates/testkit/src/lib.rs @@ -4,5 +4,7 @@ pub mod gpu; #[cfg(not(target_arch = "wasm32"))] pub mod perf; +pub mod sweep; pub use gpu::gpu_lock; +pub use sweep::Sweep; diff --git a/crates/testkit/src/sweep.rs b/crates/testkit/src/sweep.rs new file mode 100644 index 0000000000..1c4664bcb4 --- /dev/null +++ b/crates/testkit/src/sweep.rs @@ -0,0 +1,56 @@ +//! Long test sweeps spread over several days. + +use std::time::{SystemTime, UNIX_EPOCH}; + +/// The cases of a long sweep that this run covers. By default a run takes one of `parts` slices, +/// chosen by the UTC date, so every case runs at least once every `parts` days. +/// `PHOTOCRAFT_FULL_SWEEP=1` runs every case, and `PHOTOCRAFT_SWEEP_PART=` replays slice `n`; +/// [`Sweep::new`] prints the slice it picked. +#[derive(Debug, Clone, Copy)] +pub struct Sweep { + part: u64, + parts: u64, +} + +impl Sweep { + pub fn new(parts: u64) -> Self { + if std::env::var_os("PHOTOCRAFT_FULL_SWEEP").is_some_and(|v| v == "1") { + eprintln!("full sweep (PHOTOCRAFT_FULL_SWEEP=1)"); + return Self { part: 0, parts: 1 }; + } + let parts = parts.max(1); + let part = match std::env::var("PHOTOCRAFT_SWEEP_PART").ok().and_then(|v| v.parse::().ok()) { + Some(p) => p % parts, + None => SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs() / 86_400).unwrap_or_default() % parts, + }; + eprintln!("sweep part {part} of {parts}: replay with PHOTOCRAFT_SWEEP_PART={part}, run every part with PHOTOCRAFT_FULL_SWEEP=1"); + Self { part, parts } + } + + /// Whether case `i` runs. Cases go to slices by a hash of `i`, so each slice mixes every + /// position of the sweep's inner loops instead of taking every `parts`-th one. + pub fn includes(&self, i: u64) -> bool { + self.parts == 1 || mix(i) % self.parts == self.part + } +} + +/// splitmix64's finalizer. +fn mix(mut x: u64) -> u64 { + x = (x ^ (x >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + x = (x ^ (x >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + x ^ (x >> 31) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_parts_cover_every_case_once() { + let parts: Vec = (0..4).map(|part| Sweep { part, parts: 4 }).collect(); + for i in 0..1000 { + assert_eq!(parts.iter().filter(|s| s.includes(i)).count(), 1, "case {i}"); + } + assert!(parts.iter().all(|s| (0..1000).filter(|&i| s.includes(i)).count() > 200)); + } +} diff --git a/docs/development.md b/docs/development.md index f285c93e97..3ba7fc8ed6 100644 --- a/docs/development.md +++ b/docs/development.md @@ -60,7 +60,7 @@ cargo nextest run --workspace # parallel across binaries (cargo ins cargo test --doc --workspace # nextest does not run doctests ``` -[cargo-nextest](https://nexte.st) runs each test in its own process. `.config/nextest.toml` puts the GPU tests (`photocraft-gpu`, and ui-egui's integration and canvas/GPU tests) in a group of at most 4 at a time, because each process opens its own wgpu device and the in-process `gpu_lock()` no longer serializes them. On a 32-thread Windows machine the full workspace went from 297 s (`cargo test`) to about 120 s (#2204). Ignored tests run with `cargo nextest run --run-ignored only`. CI runs it with `--profile ci` (fail-fast, a 10-minute hang timeout), plus `cargo test --doc` for the doctests (#6117); a full `cargo test` stays the final check before a PR. +[cargo-nextest](https://nexte.st) runs each test in its own process. `.config/nextest.toml` puts the GPU tests (`photocraft-gpu`, and ui-egui's integration and canvas/GPU tests) in a group of at most 4 at a time, because each process opens its own wgpu device and the in-process `gpu_lock()` no longer serializes them. On a 32-thread Windows machine the full workspace went from 297 s (`cargo test`) to about 120 s (#2204). Ignored tests run with `cargo nextest run --run-ignored only`. The longest sweeps (`photocraft-testkit`'s `Sweep`) run a quarter of their cases, a different quarter each UTC day; `PHOTOCRAFT_FULL_SWEEP=1` runs all of them, and `PHOTOCRAFT_SWEEP_PART=` replays the part a failing run printed. CI runs it with `--profile ci` (fail-fast, a 10-minute hang timeout), plus `cargo test --doc` for the doctests (#6117); a full `cargo test` stays the final check before a PR. ### Shortcut capture without a GPU, and native snapshots From 18f4b5348ccee7f85c45d12c67aa509a3d00ca2f Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 08:30:48 +0000 Subject: [PATCH 20/53] Tests: spread three format-by-geometry matrices over the days, and build one precheck state per case The tilt-shift reference tests and tiled_clear_and_fill_match_one_pass_over_the_area cross pixel formats or channel counts with independent geometry and fill settings. Each run takes a quarter of the combinations through Sweep, a different quarter each UTC day. precheck_agrees_with_execute built all five document states for every call and kept one; it now builds only the state the call needs. --- Cargo.lock | 1 + crates/algo/src/tests_tilt_shift.rs | 16 +++++++++ crates/engine/Cargo.toml | 1 + crates/engine/src/pixels_tests.rs | 22 ++++++++---- crates/engine/tests/it/precheck.rs | 54 ++++++++++++++++------------- 5 files changed, 63 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 91f34c5102..c1ca95f85b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4833,6 +4833,7 @@ dependencies = [ "photocraft-psd", "photocraft-raster", "photocraft-raw", + "photocraft-testkit", "photocraft-text", "photocraft-vector", "rayon", diff --git a/crates/algo/src/tests_tilt_shift.rs b/crates/algo/src/tests_tilt_shift.rs index 88066ca9d6..b82dd1c2c4 100644 --- a/crates/algo/src/tests_tilt_shift.rs +++ b/crates/algo/src/tests_tilt_shift.rs @@ -52,11 +52,19 @@ fn tilt_shift_matches_frozen_kernel_at_band_boundaries_and_edges() { } cases.push(params(80.0, (0.5, 0.5), 0.0, 5.0, 0.1)); cases.push(params(80.0, (-5.0, -5.0), 27.0, 0.1, 0.15)); + // The channel count and alpha are independent of the band geometry: a run takes a quarter of + // their combinations, a different quarter each day. + let sweep = photocraft_testkit::Sweep::new(4); + let mut case = 0; for ch in 1..=6 { let src = image(bounds.inflate(12), ch); for alpha in [false, true] { let ctx = Ctx { bounds, mode: ColorMode::Rgb, alpha }; for p in &cases { + case += 1; + if !sweep.includes(case) { + continue; + } check(&src, bounds, &ctx, p); check(&src, Rect::new(-7, 17, 15, 39), &ctx, p); } @@ -146,6 +154,10 @@ fn tilt_shift_depths_models_selection_and_tile_edges_match_reference() { selection.fill_rect(bounds, &[0.4]); selection.fill_rect(Rect::new(3, 19, 35, 44), &[1.0]); selection.fill_rect(Rect::new(10, 20, 20, 30), &[0.0]); + // Pixel formats and tile layouts are independent: a run takes a quarter of their combinations, + // a different quarter each day. + let sweep = photocraft_testkit::Sweep::new(4); + let mut case = 0; for mode in [ ColorMode::Rgb, ColorMode::Grayscale, @@ -167,6 +179,10 @@ fn tilt_shift_depths_models_selection_and_tile_edges_match_reference() { assert_eq!(p.halo_for(bounds), crate::Halo::Radius(radius)); for extent in [None, Some(bounds)] { for tile in [16, 47, 128] { + case += 1; + if !sweep.includes(case) { + continue; + } let got = crate::apply_tiled(&surface, &p, area, bounds, Some(&selection), tile, extent); let mut want = surface.clone(); for y in (area.y0..area.y1).step_by(tile as usize) { diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index ec5b4ac075..2ccb98f81b 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -60,6 +60,7 @@ photocraft-ipp = { workspace = true } photocraft-affinity = { workspace = true, features = ["synth"] } # Synthetic camera raw files for the open tests. photocraft-raw = { workspace = true, features = ["testgen"] } +photocraft-testkit = { workspace = true } # WebAssembly text -> binary for plug-in test modules (Apache-2.0 WITH LLVM-exception). wat = "1.228" # Synthetic multi-part EXR fixtures for the parts-commands tests. diff --git a/crates/engine/src/pixels_tests.rs b/crates/engine/src/pixels_tests.rs index 5af30d0e76..3b1c25c21e 100644 --- a/crates/engine/src/pixels_tests.rs +++ b/crates/engine/src/pixels_tests.rs @@ -183,19 +183,29 @@ fn tiled_clear_and_fill_match_one_pass_over_the_area() { let sr = Rect::new(0, 0, 600, 400); soft.write_region(sr, &(0..sr.height()).flat_map(|_| (0..sr.width()).map(|x| (1.0 - (x as f32 - 256.0).max(0.0) / 300.0).max(0.0))).collect::>()); let canvas = Rect::new(0, 0, 700, 600); + // Pixel formats and fill settings are independent: a run takes a quarter of their combinations, + // a different quarter each day. + let sweep = photocraft_testkit::Sweep::new(4); + let mut case = 0; + let mut runs = || { + case += 1; + sweep.includes(case) + }; for fmt in formats { let s = clear_layer(fmt); for sel in [None, Some(&soft)] { let area = sel.map_or(canvas, |m| m.content_bounds()); let what = format!("{fmt:?} selection {}", sel.is_some()); - let mut got = s.clone(); - try_clear_surface(&mut got, area, sel).unwrap(); - assert!(same_pixels(&got, &reference_fill_or_clear(&s, area, sel, None)), "clear {what}"); - if fmt.alpha { - assert!(!same_pixels(&got, &s), "clear {what} changes the layer"); + if runs() { + let mut got = s.clone(); + try_clear_surface(&mut got, area, sel).unwrap(); + assert!(same_pixels(&got, &reference_fill_or_clear(&s, area, sel, None)), "clear {what}"); + if fmt.alpha { + assert!(!same_pixels(&got, &s), "clear {what} changes the layer"); + } } for color in [[1.0, 1.0, 1.0, 1.0], [0.3, 0.5, 0.9, 1.0], [0.3, 0.5, 0.9, 0.4]] { - for lock in [true, false] { + for lock in [true, false].into_iter().filter(|_| runs()) { let mut got = s.clone(); try_fill_surface(&mut got, area, color, sel, lock).unwrap(); assert!(same_pixels(&got, &reference_fill_or_clear(&s, area, sel, Some((color, lock)))), "fill {color:?} lock {lock} {what}"); diff --git a/crates/engine/tests/it/precheck.rs b/crates/engine/tests/it/precheck.rs index f549fbb8c8..b737ddea5a 100644 --- a/crates/engine/tests/it/precheck.rs +++ b/crates/engine/tests/it/precheck.rs @@ -7,28 +7,32 @@ use serde_json::{Value, json}; /// Documents in different states: a Background only, a pixel layer active, a group active, a /// selection, no document. -fn states() -> Vec<(&'static str, Session)> { - let mut out = Vec::new(); - out.push(("no document", Session::new())); +const STATES: [&str; 5] = ["no document", "background only", "pixel layer", "group, no background", "selection"]; + +/// A fresh session in `state`. +fn state(state: &str) -> Session { let mut s = Session::new(); - s.execute("file.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap(); - out.push(("background only", s)); - let mut s = Session::new(); - s.execute("file.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap(); - s.execute("layer.new.layer", json!({})).unwrap(); - s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); - out.push(("pixel layer", s)); - let mut s = Session::new(); - s.execute("file.new", json!({"width": 40, "height": 30, "background": "transparent"})).unwrap(); - s.execute("layer.new.group", json!({})).unwrap(); - out.push(("group, no background", s)); - let mut s = Session::new(); - s.execute("file.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap(); - s.execute("layer.new.layer", json!({})).unwrap(); - s.execute("edit.fill", json!({"color": "#00ff00"})).unwrap(); - s.execute("select.rect", json!({"x": 2, "y": 2, "width": 10, "height": 10})).unwrap(); - out.push(("selection", s)); - out + if state == "no document" { + return s; + } + let background = if state == "group, no background" { "transparent" } else { "white" }; + s.execute("file.new", json!({"width": 40, "height": 30, "background": background})).unwrap(); + match state { + "pixel layer" => { + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + } + "group, no background" => { + s.execute("layer.new.group", json!({})).unwrap(); + } + "selection" => { + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"color": "#00ff00"})).unwrap(); + s.execute("select.rect", json!({"x": 2, "y": 2, "width": 10, "height": 10})).unwrap(); + } + _ => {} + } + s } fn params() -> Vec { @@ -63,13 +67,13 @@ fn precheck_agrees_with_execute() { let mut disagreements = Vec::new(); for id in PRECHECKED { for p in params() { - for (state, _) in states() { - // A fresh copy of the state for each call: execute changes it. - let mut s = states().into_iter().find(|(n, _)| *n == state).map(|(_, s)| s).unwrap(); + for name in STATES { + // A fresh session for each call: execute changes it. + let mut s = state(name); let pre = s.precheck(id, &p).is_ok(); let ran = s.execute(id, p.clone()).is_ok(); if pre != ran { - disagreements.push(format!("{id} {p} on {state}: precheck {pre}, execute {ran}")); + disagreements.push(format!("{id} {p} on {name}: precheck {pre}, execute {ran}")); } } } From 56bbe6b80b88682741106358dc8ad2049c9cadd9 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Fri, 23 Oct 2026 14:22:33 +0530 Subject: [PATCH 21/53] fix(algo,engine): paint Select and Mask Brush, Lasso and Refine Edge Brush tile by tile The Brush edit, the Lasso edit and EdgeBrush::new allocated a dense buffer over their bounding box, which the swept-area cap does not bound: a 1 px diagonal across a 100,000 px canvas sweeps ~141k px but asked for ~40 GB. - matting::brush_surface (replaces brush_region) and EdgeBrush paint only the tiles a stroke touches (painted_tiles), capped at MAX_PAINTED_TILES. - selection::polygon_surface rasterises only the tiles the Lasso's edges cross, byte-identical to selection::polygon; interior tiles share one. - selection::combine_sparse adds/subtracts visiting only the new shape's tiles, keeping the rest of the selection shared. - select.refineEdge refuses edits and refineStrokes that would cross too many tiles before painting anything, leaving the selection unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FszacAJDjznVimaxQktoJT --- crates/algo/src/matting.rs | 2 +- crates/algo/src/matting/brush.rs | 296 +++++++++++++++---------- crates/algo/src/selection.rs | 5 +- crates/algo/src/selection/lasso.rs | 291 ++++++++++++++++++++++++ crates/algo/src/selection/marquee.rs | 80 +++++++ crates/engine/src/select_mask_edits.rs | 98 ++++++-- crates/engine/src/smartselect_cmds.rs | 27 +++ 7 files changed, 665 insertions(+), 134 deletions(-) create mode 100644 crates/algo/src/selection/lasso.rs diff --git a/crates/algo/src/matting.rs b/crates/algo/src/matting.rs index 892a5f145f..f9f3036eb3 100644 --- a/crates/algo/src/matting.rs +++ b/crates/algo/src/matting.rs @@ -238,7 +238,7 @@ pub fn morph(src: &[f32], w: usize, h: usize, r: usize, grow: bool) -> Vec mod brush; mod morphology; -pub use brush::{EdgeBrush, EdgeStroke, MAX_SWEPT_AREA, SIZE_RANGE as BRUSH_SIZE_RANGE, brush_region, stroke_area, swept_area}; +pub use brush::{EdgeBrush, EdgeStroke, MAX_SWEPT_AREA, SIZE_RANGE as BRUSH_SIZE_RANGE, brush_surface, painted_tiles, stroke_area, swept_area}; /// Running max (or min) over a window of radius `r` (edge-clipped), van Herk / Gil–Werman: /// three comparisons per sample whatever the radius. diff --git a/crates/algo/src/matting/brush.rs b/crates/algo/src/matting/brush.rs index ee54ea1875..24e4c82ad1 100644 --- a/crates/algo/src/matting/brush.rs +++ b/crates/algo/src/matting/brush.rs @@ -10,11 +10,17 @@ //! size), the colour-line model of A. Levin, D. Lischinski and Y. Weiss, "A Closed-Form Solution //! to Natural Image Matting" (CVPR 2006), with one foreground and one background colour per window. -use photocraft_geom::Rect; +use std::collections::{BTreeMap, BTreeSet}; + +use photocraft_color::PixelFormat; +use photocraft_geom::{Rect, TILE_SIZE, TileCoord}; +use photocraft_raster::Surface; use super::box_mean; use crate::segment::RgbImage; -use crate::selection::Region; +use crate::selection::{MAX_PAINTED_TILES, clip_segment}; + +const TS: usize = TILE_SIZE as usize; /// One Refine Edge Brush stroke, in document pixels. #[derive(Clone, Debug, PartialEq)] @@ -30,12 +36,15 @@ pub struct EdgeStroke { /// Brush diameters the strokes may use. pub const SIZE_RANGE: std::ops::RangeInclusive = 1.0..=5000.0; -/// The painted area: signed coverage (`+1` refined, `−1` restored, `0` untouched) over `bbox`. +/// The painted area: signed coverage (`+1` refined, `−1` restored, `0` untouched), kept only for +/// the tiles the strokes paint, so a thin stroke across a huge canvas costs those tiles, not its +/// bounding box (#8783). #[derive(Clone, Debug, PartialEq)] pub struct EdgeBrush { + /// Where the strokes can paint. bbox: Rect, - /// Coverage × 127, row-major over `bbox`. - v: Vec, + /// Coverage × 127 per painted tile, row-major over the tile. + tiles: BTreeMap>, /// Bounds of the added (positive) coverage. added: Rect, /// Box radius of the colour estimate (pixels). @@ -89,11 +98,73 @@ pub fn stroke_area(points: &[(f32, f32)], size: f32) -> f64 { if len.is_finite() { len * size + size * size } else { f64::INFINITY } } +/// The tiles of `canvas` a round brush `size` px wide along `points` can paint, in order; `None` +/// when that is more than `limit` (a thin stroke sweeps little but touches a whole tile wherever +/// it passes, so this, not [`MAX_SWEPT_AREA`], bounds what painting it allocates; #8783). +pub fn painted_tiles(points: &[(f32, f32)], size: f32, canvas: Rect, limit: usize) -> Option> { + let r = size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end()) / 2.0; + let mut set = BTreeSet::new(); + // Consecutive pieces mostly fall in the same tiles. + let mut last: Option = None; + for (a, b) in segments(points, r, canvas) { + let seg = dab_bounds(a, r).union(&dab_bounds(b, r)).intersect(&canvas); + // The tile coordinates `seg` spans (inclusive). + let span = + Rect::new(seg.x0.div_euclid(TILE_SIZE), seg.y0.div_euclid(TILE_SIZE), (seg.x1 - 1).div_euclid(TILE_SIZE), (seg.y1 - 1).div_euclid(TILE_SIZE)); + if seg.is_empty() || last == Some(span) { + continue; + } + last = Some(span); + for tc in seg.tiles() { + set.insert(tc); + if set.len() > limit { + return None; + } + } + } + Some(set) +} + +/// One stroke's coverage (0–255) per tile of `canvas`: the most `cov(pixel centre, a, b)` gives +/// over the path's pieces, so a stroke never paints past full. Only painted tiles are kept; +/// `None` past `limit` tiles (see [`painted_tiles`]). +fn paint_tiles( + points: &[(f32, f32)], + size: f32, + canvas: Rect, + limit: usize, + cov: impl Fn((f32, f32), (f32, f32), (f32, f32)) -> f32, +) -> Option>> { + let r = size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end()) / 2.0; + let mut tiles: BTreeMap> = painted_tiles(points, size, canvas, limit)?.into_iter().map(|tc| (tc, vec![0u8; TS * TS])).collect(); + for (a, b) in segments(points, r, canvas) { + let seg = dab_bounds(a, r).union(&dab_bounds(b, r)).intersect(&canvas); + for tc in seg.tiles() { + let o = tc.rect(); + let Some(buf) = tiles.get_mut(&tc) else { continue }; + let tr = o.intersect(&seg); + for y in tr.y0..tr.y1 { + for x in tr.x0..tr.x1 { + let c = (cov((x as f32 + 0.5, y as f32 + 0.5), a, b).clamp(0.0, 1.0) * 255.0).round() as u8; + if let Some(slot) = buf.get_mut((y - o.y0) as usize * TS + (x - o.x0) as usize) + && c > *slot + { + *slot = c; + } + } + } + } + } + tiles.retain(|_, b| b.iter().any(|v| *v > 0)); + Some(tiles) +} + /// Select and Mask's Brush: the coverage one stroke of a round brush `size` px wide with -/// `hardness` (0–100) paints along `points`, clipped to `canvas`. Overlapping dabs keep the most -/// coverage any of them gives, so a stroke never paints past full. `None` when it paints nothing, -/// has non-finite points or sweeps more than [`MAX_SWEPT_AREA`]. -pub fn brush_region(points: &[(f32, f32)], size: f32, hardness: f32, canvas: Rect) -> Option { +/// `hardness` (0–100) paints along `points`, clipped to `canvas`, as a GRAY8 selection holding +/// only the tiles it paints. Overlapping dabs keep the most coverage any of them gives, so a +/// stroke never paints past full. `None` when it paints nothing, has non-finite points, sweeps +/// more than [`MAX_SWEPT_AREA`] or paints more than [`MAX_PAINTED_TILES`] tiles. +pub fn brush_surface(points: &[(f32, f32)], size: f32, hardness: f32, canvas: Rect) -> Option { if points.is_empty() || !size.is_finite() || !hardness.is_finite() || points.iter().any(|p| !p.0.is_finite() || !p.1.is_finite()) { return None; } @@ -102,48 +173,12 @@ pub fn brush_region(points: &[(f32, f32)], size: f32, hardness: f32, canvas: Rec } let r = size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end()) / 2.0; let core = r * hardness.clamp(0.0, 100.0) / 100.0; - let bbox = points.iter().fold(Rect::EMPTY, |acc, p| acc.union(&dab_bounds(*p, r))).intersect(&canvas); - if bbox.is_empty() { - return None; + let tiles = paint_tiles(points, size, canvas, MAX_PAINTED_TILES, |p, a, b| soft_coverage(segment_distance(p, a, b), r, core))?; + let mut s = Surface::new(PixelFormat::GRAY8); + for (tc, bytes) in &tiles { + s.write_interleaved(tc.rect(), bytes); } - let bw = bbox.width() as usize; - let mut cov = vec![0.0f32; bw.checked_mul(bbox.height() as usize)?]; - for (a, b) in segments(points, r, bbox) { - let seg = dab_bounds(a, r).union(&dab_bounds(b, r)).intersect(&bbox); - for y in seg.y0..seg.y1 { - for x in seg.x0..seg.x1 { - let c = soft_coverage(segment_distance((x as f32 + 0.5, y as f32 + 0.5), a, b), r, core); - if let Some(slot) = cov.get_mut((y - bbox.y0) as usize * bw + (x - bbox.x0) as usize) - && c > *slot - { - *slot = c; - } - } - } - } - let mask: Vec = cov.iter().map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8).collect(); - mask.iter().any(|v| *v > 0).then_some(Region { bbox, mask }) -} - -/// The part of the segment `a`–`b` inside `r` (Liang–Barsky), or `None` when it misses it. -fn clip_segment(a: (f32, f32), b: (f32, f32), r: Rect) -> Option<((f32, f32), (f32, f32))> { - let (dx, dy) = (b.0 - a.0, b.1 - a.1); - let (mut t0, mut t1) = (0.0f32, 1.0f32); - for (p, q) in [(-dx, a.0 - r.x0 as f32), (dx, r.x1 as f32 - a.0), (-dy, a.1 - r.y0 as f32), (dy, r.y1 as f32 - a.1)] { - if p == 0.0 { - if q < 0.0 { - return None; - } - } else { - let t = q / p; - if p < 0.0 { - t0 = t0.max(t); - } else { - t1 = t1.min(t); - } - } - } - (t0 <= t1 && t0.is_finite() && t1.is_finite()).then_some(((a.0 + dx * t0, a.1 + dy * t0), (a.0 + dx * t1, a.1 + dy * t1))) + (s.tile_count() > 0).then_some(s) } /// The stroke's path as short segments within reach of `clip`: points closer than a quarter of @@ -181,8 +216,8 @@ fn segments(points: &[(f32, f32)], r: f32, clip: Rect) -> impl Iterator Option { let usable = |s: &&EdgeStroke| s.size.is_finite() && !s.points.is_empty() && s.points.iter().all(|p| p.0.is_finite() && p.1.is_finite()); if swept_area(strokes) > MAX_SWEPT_AREA { @@ -197,61 +232,43 @@ impl EdgeBrush { if bbox.is_empty() { return None; } - let bw = bbox.width() as usize; - let mut v = vec![0i8; bw.checked_mul(bbox.height() as usize)?]; - let mut cov: Vec = Vec::new(); + let mut tiles: BTreeMap> = BTreeMap::new(); let mut max_add = 0.0f32; for s in strokes { - let sb = stroke_bounds(s); - if sb.is_empty() { - continue; - } let r = s.size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end()) / 2.0; if s.add { max_add = max_add.max(2.0 * r); } - let sw = sb.width() as usize; - cov.clear(); - cov.resize(sw * sb.height() as usize, 0.0); // The stroke's coverage: the most any of its segments gives (a single dab for one point). - for (a, b) in segments(&s.points, r, sb) { - let seg = dab_bounds(a, r).union(&dab_bounds(b, r)).intersect(&sb); - for y in seg.y0..seg.y1 { - for x in seg.x0..seg.x1 { - let c = capsule((x as f32 + 0.5, y as f32 + 0.5), a, b, r); - if let Some(slot) = cov.get_mut((y - sb.y0) as usize * sw + (x - sb.x0) as usize) - && c > *slot - { - *slot = c; - } - } - } - } + let cov = paint_tiles(&s.points, s.size, canvas, MAX_PAINTED_TILES, |p, a, b| capsule(p, a, b, r))?; let target = if s.add { 1.0 } else { -1.0 }; - for y in sb.y0..sb.y1 { - for x in sb.x0..sb.x1 { - let c = cov.get((y - sb.y0) as usize * sw + (x - sb.x0) as usize).copied().unwrap_or(0.0); - if c <= 0.0 { - continue; - } - if let Some(slot) = v.get_mut((y - bbox.y0) as usize * bw + (x - bbox.x0) as usize) { - let cur = f32::from(*slot) / 127.0; + for (tc, c) in cov { + let v = tiles.entry(tc).or_insert_with(|| vec![0i8; TS * TS]); + for (slot, c) in v.iter_mut().zip(c) { + if c > 0 { + let (cur, c) = (f32::from(*slot) / 127.0, f32::from(c) / 255.0); *slot = ((cur + (target - cur) * c) * 127.0).round().clamp(-127.0, 127.0) as i8; } } } + if tiles.len() > MAX_PAINTED_TILES { + return None; + } } - if v.iter().all(|c| *c == 0) { + tiles.retain(|_, v| v.iter().any(|c| *c != 0)); + if tiles.is_empty() { return None; } - let (mut x0, mut y0, mut x1, mut y1) = (i32::MAX, i32::MAX, i32::MIN, i32::MIN); - for (row, line) in v.chunks_exact(bw).enumerate() { - let (Some(first), Some(last)) = (line.iter().position(|c| *c > 0), line.iter().rposition(|c| *c > 0)) else { continue }; - let y = bbox.y0 + row as i32; - (x0, x1, y0, y1) = (x0.min(bbox.x0 + first as i32), x1.max(bbox.x0 + last as i32 + 1), y0.min(y), y1.max(y + 1)); + let mut added = Rect::EMPTY; + for (tc, v) in &tiles { + let o = tc.rect(); + for (row, line) in v.as_chunks::().0.iter().enumerate() { + let (Some(first), Some(last)) = (line.iter().position(|c| *c > 0), line.iter().rposition(|c| *c > 0)) else { continue }; + let y = o.y0 + row as i32; + added = added.union(&Rect::new(o.x0 + first as i32, y, o.x0 + last as i32 + 1, y + 1)); + } } - let added = if x0 < x1 { Rect::new(x0, y0, x1, y1) } else { Rect::EMPTY }; - Some(EdgeBrush { bbox, v, added, window: max_add.ceil().clamp(4.0, 64.0) as usize }) + Some(EdgeBrush { bbox, tiles, added, window: max_add.ceil().clamp(4.0, 64.0) as usize }) } /// Where the strokes paint (added or restored). @@ -271,16 +288,32 @@ impl EdgeBrush { /// Signed coverage at document pixel `(x, y)` (zero outside the painted area). pub fn at(&self, x: i32, y: i32) -> f32 { - if !self.bbox.contains(x, y) { - return 0.0; - } - let i = (y - self.bbox.y0) as usize * self.bbox.width() as usize + (x - self.bbox.x0) as usize; - self.v.get(i).map_or(0.0, |c| f32::from(*c) / 127.0) + let (tc, o) = (TileCoord::containing(x, y), (y.rem_euclid(TILE_SIZE) as usize) * TS + x.rem_euclid(TILE_SIZE) as usize); + self.tiles.get(&tc).and_then(|v| v.get(o)).map_or(0.0, |c| f32::from(*c) / 127.0) } /// Signed coverage over `r` (row-major). pub fn read(&self, r: Rect) -> Vec { - (r.y0..r.y1).flat_map(|y| (r.x0..r.x1).map(move |x| self.at(x, y))).collect() + let w = r.width() as usize; + let mut out = vec![0.0f32; w * r.height() as usize]; + for tc in r.tiles() { + let Some(v) = self.tiles.get(&tc) else { continue }; + let (o, tr) = (tc.rect(), tc.rect().intersect(&r)); + for y in tr.y0..tr.y1 { + let src = (y - o.y0) as usize * TS; + let dst = (y - r.y0) as usize * w; + let (Some(src), Some(dst)) = ( + v.get(src + (tr.x0 - o.x0) as usize..src + (tr.x1 - o.x0) as usize), + out.get_mut(dst + (tr.x0 - r.x0) as usize..dst + (tr.x1 - r.x0) as usize), + ) else { + continue; + }; + for (d, c) in dst.iter_mut().zip(src) { + *d = f32::from(*c) / 127.0; + } + } + } + out } } @@ -345,6 +378,11 @@ mod tests { use super::*; use crate::matting::{RefineParams, refine_mask_brushed, region_reader}; use crate::segment::ImageSampler; + use crate::selection::Region; + + fn at(s: &Surface, x: i32, y: i32) -> f32 { + s.pixel(x, y).first().copied().unwrap_or(0.0) + } /// Strands of the subject's colour (every 4th column from x = 60) beyond the selection's /// edge at x = 40: brushing over them adds them, Alt-brushing the edge keeps it as it was. @@ -401,32 +439,33 @@ mod tests { assert!(EdgeBrush::new(&[off, nan, empty], canvas).is_none()); } #[test] - fn brush_region_paints_hard_and_soft_strokes() { + fn brush_surface_paints_hard_and_soft_strokes() { let canvas = Rect::new(0, 0, 100, 50); - let hard = brush_region(&[(10.0, 25.0), (90.0, 25.0)], 20.0, 100.0, canvas).expect("painted"); - assert!(hard.at(50, 25) > 0.99 && hard.at(50, 33) > 0.99, "solid inside the radius"); - assert!(hard.at(50, 36) == 0.0 && hard.at(50, 10) == 0.0, "nothing past it"); + let hard = brush_surface(&[(10.0, 25.0), (90.0, 25.0)], 20.0, 100.0, canvas).expect("painted"); + assert!(at(&hard, 50, 25) > 0.99 && at(&hard, 50, 33) > 0.99, "solid inside the radius"); + assert!(at(&hard, 50, 36) == 0.0 && at(&hard, 50, 10) == 0.0, "nothing past it"); // A soft brush fades from the centre line to the rim. - let soft = brush_region(&[(10.0, 25.0), (90.0, 25.0)], 20.0, 0.0, canvas).expect("painted"); - let (centre, mid, rim) = (soft.at(50, 25), soft.at(50, 30), soft.at(50, 34)); + let soft = brush_surface(&[(10.0, 25.0), (90.0, 25.0)], 20.0, 0.0, canvas).expect("painted"); + let (centre, mid, rim) = (at(&soft, 50, 25), at(&soft, 50, 30), at(&soft, 50, 34)); assert!(centre > 0.95 && mid > 0.2 && mid < 0.8 && rim < 0.1, "{centre} {mid} {rim}"); // A single point is one dab; clipped to the canvas. - let dab = brush_region(&[(0.0, 0.0)], 10.0, 100.0, canvas).expect("painted"); - assert_eq!((dab.bbox.x0, dab.bbox.y0), (0, 0)); - assert!(dab.at(1, 1) > 0.99); + let dab = brush_surface(&[(0.0, 0.0)], 10.0, 100.0, canvas).expect("painted"); + let b = dab.content_bounds(); + assert_eq!((b.x0, b.y0), (0, 0)); + assert!(at(&dab, 1, 1) > 0.99); } #[test] - fn brush_region_paints_nothing_for_hostile_strokes() { + fn brush_surface_paints_nothing_for_hostile_strokes() { let canvas = Rect::new(0, 0, 100, 50); - assert!(brush_region(&[], 10.0, 100.0, canvas).is_none()); - assert!(brush_region(&[(f32::NAN, 1.0)], 10.0, 100.0, canvas).is_none()); - assert!(brush_region(&[(1.0, 1.0)], f32::INFINITY, 100.0, canvas).is_none()); - assert!(brush_region(&[(1.0, 1.0)], 10.0, f32::NAN, canvas).is_none()); - assert!(brush_region(&[(-500.0, -500.0)], 10.0, 100.0, canvas).is_none(), "off the canvas"); - assert!(brush_region(&[(0.0, 0.0), (1e7, 1e7)], 5000.0, 100.0, canvas).is_none(), "sweeps too much"); + assert!(brush_surface(&[], 10.0, 100.0, canvas).is_none()); + assert!(brush_surface(&[(f32::NAN, 1.0)], 10.0, 100.0, canvas).is_none()); + assert!(brush_surface(&[(1.0, 1.0)], f32::INFINITY, 100.0, canvas).is_none()); + assert!(brush_surface(&[(1.0, 1.0)], 10.0, f32::NAN, canvas).is_none()); + assert!(brush_surface(&[(-500.0, -500.0)], 10.0, 100.0, canvas).is_none(), "off the canvas"); + assert!(brush_surface(&[(0.0, 0.0), (1e7, 1e7)], 5000.0, 100.0, canvas).is_none(), "sweeps too much"); // Sizes and hardness outside their ranges are clamped, not rejected. - assert!(brush_region(&[(50.0, 25.0)], 0.0, 500.0, canvas).is_some()); + assert!(brush_surface(&[(50.0, 25.0)], 0.0, 500.0, canvas).is_some()); } /// A 1 px stroke zigzagging across 2 million px stays within [`MAX_SWEPT_AREA`] yet splits /// into over 100 million pieces: painting it must not collect them all first (it allocated @@ -438,9 +477,34 @@ mod tests { assert!(stroke_area(&points, 1.0) <= MAX_SWEPT_AREA); // Only the pieces within reach of the canvas are made. assert!(segments(&points, 0.5, canvas).count() < 134 * 40, "{}", segments(&points, 0.5, canvas).count()); - let r = brush_region(&points, 1.0, 100.0, canvas).expect("it paints its row"); - assert!(r.at(10, 32) > 0.5); + let r = brush_surface(&points, 1.0, 100.0, canvas).expect("it paints its row"); + assert!(at(&r, 10, 32) > 0.5); let strokes = [EdgeStroke { points, size: 1.0, add: true }]; assert!(EdgeBrush::new(&strokes, canvas).is_some_and(|b| b.at(10, 32) > 0.5)); } + + /// A 1 px diagonal across a 100,000 px canvas sweeps about 141,000 px, well within + /// [`MAX_SWEPT_AREA`], but its bounding box is 10 billion pixels: painting it must cost the + /// tiles it crosses, not that box (it allocated 40 GB; the #8783 review). + #[test] + fn a_thin_diagonal_across_a_huge_canvas_paints_only_its_tiles() { + let canvas = Rect::new(0, 0, 100_000, 100_000); + let diagonal = [(0.0, 0.0), (99_999.0, 99_999.0)]; + assert!(stroke_area(&diagonal, 1.0) <= MAX_SWEPT_AREA); + let tiles = painted_tiles(&diagonal, 1.0, canvas, MAX_PAINTED_TILES).expect("within the limit"); + assert!(tiles.len() < 1500, "{}", tiles.len()); + let s = brush_surface(&diagonal, 1.0, 100.0, canvas).expect("painted"); + assert!(s.tile_count() <= tiles.len()); + assert!(at(&s, 50_000, 50_000) > 0.5 && at(&s, 50_000, 40_000) == 0.0); + let strokes = [EdgeStroke { points: diagonal.to_vec(), size: 1.0, add: true }]; + let b = EdgeBrush::new(&strokes, canvas).expect("painted"); + assert!(b.at(50_000, 50_000) > 0.5 && b.at(50_000, 40_000) == 0.0); + assert!(b.read(Rect::new(49_990, 49_990, 50_010, 50_010)).iter().any(|v| *v > 0.5)); + // A zigzag crossing more tiles than the limit is refused rather than allocated. + let zigzag: Vec<(f32, f32)> = (0..20).map(|i| (if i % 2 == 0 { 0.0 } else { 99_999.0 }, i as f32 * 5000.0)).collect(); + assert!(stroke_area(&zigzag, 1.0) <= MAX_SWEPT_AREA); + assert!(painted_tiles(&zigzag, 1.0, canvas, MAX_PAINTED_TILES).is_none()); + assert!(brush_surface(&zigzag, 1.0, 100.0, canvas).is_none()); + assert!(EdgeBrush::new(&[EdgeStroke { points: zigzag, size: 1.0, add: true }], canvas).is_none()); + } } diff --git a/crates/algo/src/selection.rs b/crates/algo/src/selection.rs index 94f744d0be..1e71a502c7 100644 --- a/crates/algo/src/selection.rs +++ b/crates/algo/src/selection.rs @@ -761,10 +761,13 @@ pub fn tone_range(px: &[[f32; 4]], lo: f32, hi: f32, falloff: f32) -> Vec { } mod distance; +mod lasso; mod marquee; pub use distance::edt; -pub use marquee::{combine_surfaces, ellipse_surface}; +pub(crate) use lasso::clip_segment; +pub use lasso::{MAX_PAINTED_TILES, polygon_edge_tiles, polygon_surface}; +pub use marquee::{combine_sparse, combine_surfaces, ellipse_surface}; /// Grows the selection by `r` pixels. pub fn expand(m: &[f32], w: usize, h: usize, r: f32) -> Vec { diff --git a/crates/algo/src/selection/lasso.rs b/crates/algo/src/selection/lasso.rs new file mode 100644 index 0000000000..3ca4eacd52 --- /dev/null +++ b/crates/algo/src/selection/lasso.rs @@ -0,0 +1,291 @@ +//! Lasso shapes built straight into tiles (#8783): only the tiles an edge crosses are rasterised, +//! and every tile wholly inside the polygon shares one solid tile, so a thin lasso across a huge +//! canvas costs the tiles along its edges, not its bounding box. The bytes are those +//! [`super::polygon`] gives over the canvas. + +use std::collections::BTreeSet; +use std::sync::Arc; + +use photocraft_color::PixelFormat; +use photocraft_geom::{Rect, TILE_SIZE, TileCoord}; +use photocraft_raster::{Surface, Tile}; + +use crate::photo_util::par_map; + +const TS: usize = TILE_SIZE as usize; + +/// Most partly covered tiles one painted shape (a lasso, a brush stroke) may hold: 2²⁸ pixels' +/// worth, 256 MB as GRAY8. A thin shape touches a whole tile wherever it passes, so this, not +/// its area, bounds what painting it allocates. +pub const MAX_PAINTED_TILES: usize = (1 << 28) / (TS * TS); + +/// The part of the segment `a`–`b` inside `r` (Liang–Barsky), or `None` when it misses it. +pub(crate) fn clip_segment(a: (f32, f32), b: (f32, f32), r: Rect) -> Option<((f32, f32), (f32, f32))> { + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let (mut t0, mut t1) = (0.0f32, 1.0f32); + for (p, q) in [(-dx, a.0 - r.x0 as f32), (dx, r.x1 as f32 - a.0), (-dy, a.1 - r.y0 as f32), (dy, r.y1 as f32 - a.1)] { + if p == 0.0 { + if q < 0.0 { + return None; + } + } else { + let t = q / p; + if p < 0.0 { + t0 = t0.max(t); + } else { + t1 = t1.min(t); + } + } + } + (t0 <= t1 && t0.is_finite() && t1.is_finite()).then_some(((a.0 + dx * t0, a.1 + dy * t0), (a.0 + dx * t1, a.1 + dy * t1))) +} + +/// The pixels the polygon through `points` can touch within `clip` (its bounding box, grown by a +/// pixel, as the Lasso has always rasterised it). +fn polygon_area(points: &[(f32, f32)], clip: Rect) -> Rect { + let (mut x0, mut y0, mut x1, mut y1) = (f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY); + for p in points { + (x0, y0, x1, y1) = (x0.min(p.0), y0.min(p.1), x1.max(p.0), y1.max(p.1)); + } + if points.len() < 3 || !(x0 <= x1 && y0 <= y1) { + return Rect::EMPTY; + } + let clamp = |v: f32| v.clamp(-1e7, 1e7) as i32; + Rect::new(clamp(x0.floor()) - 1, clamp(y0.floor()) - 1, clamp(x1.ceil()) + 1, clamp(y1.ceil()) + 1).intersect(&clip) +} + +/// The tiles of `clip` the edges of the polygon through `points` cross (with a pixel's margin), +/// in order: the only ones whose pixels can differ. `None` when there are more than `limit`. +pub fn polygon_edge_tiles(points: &[(f32, f32)], clip: Rect, limit: usize) -> Option> { + let area = polygon_area(points, clip); + let mut set = BTreeSet::new(); + if area.is_empty() { + return Some(set); + } + let step = TILE_SIZE as f32 / 4.0; + for (&a, &b) in points.iter().zip(points.iter().cycle().skip(1)) { + let Some((a, b)) = clip_segment(a, b, area.inflate(2)) else { continue }; + let pieces = ((b.0 - a.0).hypot(b.1 - a.1) / step).ceil().clamp(1.0, 1e6) as usize; + let at = |k: usize| { + let t = k as f32 / pieces as f32; + (a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t) + }; + for k in 0..pieces { + let (p, q) = (at(k), at(k + 1)); + let lo = |u: f32, v: f32| u.min(v).floor() as i32 - 1; + let hi = |u: f32, v: f32| u.max(v).floor() as i32 + 2; + for tc in Rect::new(lo(p.0, q.0), lo(p.1, q.1), hi(p.0, q.0), hi(p.1, q.1)).intersect(&area).tiles() { + set.insert(tc); + if set.len() > limit { + return None; + } + } + } + } + Some(set) +} + +/// One polygon edge, as [`super::polygon`] keeps it. +struct Edge { + top: f32, + bottom: f32, + a: (f32, f32), + b: (f32, f32), +} + +/// The anti-aliased (even-odd) polygon through `points`, cut to `clip`, as a GRAY8 selection: the +/// bytes of [`super::polygon`] over `clip`, quantised. Edge tiles are rasterised, tiles wholly +/// inside share one solid tile. `None` when the edges cross more than [`MAX_PAINTED_TILES`] tiles. +pub fn polygon_surface(points: &[(f32, f32)], clip: Rect) -> Option { + let mut s = Surface::new(PixelFormat::GRAY8); + let area = polygon_area(points, clip); + if area.is_empty() { + return Some(s); + } + let edge_tiles = polygon_edge_tiles(points, clip, MAX_PAINTED_TILES)?; + const SUB: usize = 4; + // Sample rows as `polygon` over `clip` takes them, so the bytes match. + let sample_y = |y: i32, k: usize| clip.y0 as f32 + (y - clip.y0) as f32 + (k as f32 + 0.5) / SUB as f32; + let mut edges: Vec = points + .iter() + .zip(points.iter().cycle().skip(1)) + .filter_map(|(&a, &b)| { + if a.1 < b.1 { + Some(Edge { top: a.1, bottom: b.1, a, b }) + } else if b.1 < a.1 { + Some(Edge { top: b.1, bottom: a.1, a, b }) + } else { + None + } + }) + .collect(); + edges.sort_by(|p, q| p.top.total_cmp(&q.top)); + let crossings = |e: &[&Edge], y: f32, xs: &mut Vec| { + xs.clear(); + xs.extend(e.iter().map(|&&Edge { a, b, .. }| a.0 + (y - a.1) / (b.1 - a.1) * (b.0 - a.0))); + xs.sort_by(|a, b| a.total_cmp(b)); + }; + let full = s.solid_tile(&[1.0]); + let blank = s.solid_tile(&[0.0]); + let bands: Vec = Rect::new(area.x0, area.y0, area.x0 + 1, area.y1).tiles().map(|tc| tc.ty).collect(); + let built = par_map(bands.len(), |bi| -> Vec<(TileCoord, Arc)> { + let Some(&ty) = bands.get(bi) else { return Vec::new() }; + let rows = Rect::new(area.x0, ty * TILE_SIZE, area.x1, (ty + 1) * TILE_SIZE).intersect(&area); + // In `x` order (`TileCoord` orders by `tx` first). + let cols: Vec = edge_tiles.iter().filter(|tc| tc.ty == ty).copied().collect(); + let mut out = Vec::new(); + let (first, last) = (sample_y(rows.y0, 0), sample_y(rows.y1 - 1, SUB - 1)); + let reach = edges.partition_point(|e| e.top <= last); + let band_edges = edges.get(..reach).unwrap_or_default(); + let mut xs: Vec = Vec::new(); + // Tiles no edge crosses are wholly in or out: one sample row decides them all. + let y_mid = sample_y(rows.y0 + rows.height() as i32 / 2, 0); + let mid: Vec<&Edge> = band_edges.iter().filter(|e| e.top <= y_mid && e.bottom > y_mid).collect(); + crossings(&mid, y_mid, &mut xs); + for tc in rows.tiles() { + if cols.binary_search(&tc).is_ok() { + continue; + } + let tr = tc.rect().intersect(&rows); + let cx = (tr.x0 + tr.x1) as f32 / 2.0; + if xs.partition_point(|x| *x <= cx) % 2 == 0 { + continue; + } + if tr == tc.rect() { + out.push((tc, Arc::clone(&full))); + } else { + let mut tile = Tile::clone(&blank); + fill(&mut tile, tc, tr, u8::MAX); + out.push((tc, Arc::new(tile))); + } + } + if cols.is_empty() { + return out; + } + // The edge tiles: `polygon`'s scanline, accumulated only over their columns. + let x_of = |tc: &TileCoord| tc.rect().intersect(&rows); + let spans: Vec = cols.iter().map(x_of).collect(); + let mut acc = vec![0.0f32; cols.len() * TS]; + let mut tiles: Vec = cols.iter().map(|_| Tile::clone(&blank)).collect(); + let mut pending = band_edges.iter().filter(|e| e.bottom > first).peekable(); + let mut active: Vec<&Edge> = Vec::new(); + let origin = clip.x0 as f32; + for y in rows.y0..rows.y1 { + acc.fill(0.0); + for k in 0..SUB { + let sy = sample_y(y, k); + while let Some(e) = pending.next_if(|e| e.top <= sy) { + active.push(e); + } + active.retain(|e| e.bottom > sy); + if active.is_empty() { + continue; + } + crossings(&active, sy, &mut xs); + for pair in xs.as_chunks::<2>().0 { + let (x0, x1) = ((pair[0] - origin).max(0.0), (pair[1] - origin).min(clip.width() as f32)); + if x1 <= x0 { + continue; + } + let (c0, c1) = (clip.x0 + x0.floor() as i32, clip.x0 + x1.ceil() as i32); + let start = spans.partition_point(|r| r.x1 <= c0); + for (ci, r) in spans.iter().enumerate().skip(start).take_while(|(_, r)| r.x0 < c1) { + for x in c0.max(r.x0)..c1.min(r.x1) { + let cx = (x - clip.x0) as f32; + let cov = (cx + 1.0).min(x1) - cx.max(x0); + if let Some(v) = acc.get_mut(ci * TS + (x - r.x0) as usize) { + *v += cov.max(0.0) / SUB as f32; + } + } + } + } + } + for ((r, tile), row) in spans.iter().zip(&mut tiles).zip(acc.as_chunks::().0) { + let base = y.rem_euclid(TILE_SIZE) as usize * TS + r.x0.rem_euclid(TILE_SIZE) as usize; + let Some(dst) = tile.bytes_mut().get_mut(base..base + r.width() as usize) else { continue }; + for (d, v) in dst.iter_mut().zip(row) { + *d = (v.min(1.0).clamp(0.0, 1.0) * 255.0).round() as u8; + } + } + } + for (tc, tile) in cols.into_iter().zip(tiles) { + let (lo, hi) = tile.bytes().iter().fold((u8::MAX, 0u8), |(lo, hi), v| (lo.min(*v), hi.max(*v))); + if hi > 0 { + out.push((tc, if lo == u8::MAX { Arc::clone(&full) } else { Arc::new(tile) })); + } + } + out + }); + s.put_tiles(built.into_iter().flatten()); + Some(s) +} + +/// Sets `tr` (within tile `tc`) to `v`. +fn fill(tile: &mut Tile, tc: TileCoord, tr: Rect, v: u8) { + let o = tc.rect(); + for y in tr.y0..tr.y1 { + let base = (y - o.y0) as usize * TS; + if let Some(row) = tile.bytes_mut().get_mut(base + (tr.x0 - o.x0) as usize..base + (tr.x1 - o.x0) as usize) { + row.fill(v); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The dense rasteriser over `clip`, quantised as the Lasso stores it. + fn dense(points: &[(f32, f32)], clip: Rect) -> Surface { + let m: Vec = super::super::polygon(points, clip, true).iter().map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8).collect(); + let mut s = Surface::from_interleaved(PixelFormat::GRAY8, clip, &m); + s.prune(); + s + } + + #[test] + fn matches_the_dense_polygon() { + let clip = Rect::new(-40, 10, 1300, 900); + let shapes: [&[(f32, f32)]; 6] = [ + &[(10.5, 20.25), (1200.0, 40.0), (600.0, 880.7)], + // Whole tiles inside, edges exactly on tile boundaries. + &[(0.0, 256.0), (768.0, 256.0), (768.0, 768.0), (0.0, 768.0)], + // Self-intersecting (even-odd), past the clip. + &[(-300.0, -50.0), (1500.0, 950.0), (1500.0, -50.0), (-300.0, 950.0)], + &[(100.0, 100.0), (101.0, 100.0), (100.5, 700.0)], + &[(5.0, 15.0), (5.0, 15.0), (5.0, 15.0)], + &[(2000.0, 2000.0), (2100.0, 2000.0), (2000.0, 2100.0)], + ]; + for pts in shapes { + let s = polygon_surface(pts, clip).expect("within the tile limit"); + let d = dense(pts, clip); + let area = clip.union(&s.tile_bounds()); + assert!(s.read_region(area) == d.read_region(area), "{pts:?}"); + } + } + + /// A thin sliver across a 100,000 px canvas: only the tiles along it are built (the dense + /// rasteriser allocated its 10-billion-pixel bounding box; the #8783 review). + #[test] + fn a_thin_sliver_across_a_huge_canvas_costs_its_edge_tiles() { + let clip = Rect::new(0, 0, 100_000, 100_000); + let sliver = [(0.0, 0.0), (99_999.0, 99_999.0), (0.0, 1.5)]; + let tiles = polygon_edge_tiles(&sliver, clip, MAX_PAINTED_TILES).expect("within the limit"); + assert!(tiles.len() < 2000, "{}", tiles.len()); + let s = polygon_surface(&sliver, clip).expect("within the limit"); + assert!(s.tile_count() <= tiles.len()); + assert!(s.pixel(50_000, 50_000)[0] > 0.0 && s.pixel(50_000, 40_000)[0] == 0.0); + // A zigzag that crosses more tiles than the limit is refused. + let zigzag: Vec<(f32, f32)> = (0..200).map(|i| (if i % 2 == 0 { 0.0 } else { 99_999.0 }, i as f32 * 500.0)).collect(); + assert!(polygon_edge_tiles(&zigzag, clip, MAX_PAINTED_TILES).is_none()); + assert!(polygon_surface(&zigzag, clip).is_none()); + } + + #[test] + fn a_huge_polygon_shares_one_tile_inside() { + let clip = Rect::new(0, 0, 51_200, 51_200); + let s = polygon_surface(&[(-10.0, -10.0), (60_000.0, -10.0), (60_000.0, 60_000.0), (-10.0, 60_000.0)], clip).expect("no edge tiles"); + let shared = s.tiles().map(|(_, t)| Arc::as_ptr(t)).collect::>(); + assert!(s.tile_count() == 200 * 200 && shared.len() == 1, "{} tiles, {} distinct", s.tile_count(), shared.len()); + } +} diff --git a/crates/algo/src/selection/marquee.rs b/crates/algo/src/selection/marquee.rs index 7dd861164a..c4830ae134 100644 --- a/crates/algo/src/selection/marquee.rs +++ b/crates/algo/src/selection/marquee.rs @@ -236,6 +236,60 @@ pub fn combine_surfaces(a: &Surface, b: &Surface, area: Rect, f: impl Fn(f32, f3 out } +/// [`combine_surfaces`] for an `f` that keeps `a` wherever `b` reads 0 (Add, Subtract), visiting +/// only `b`'s tiles within `area`: the rest of `a` (its other tiles and its untouched value) is +/// kept as it is, so painting a thin stroke across a huge canvas costs the tiles it paints, not +/// the canvas (#8783). Falls back to [`combine_surfaces`] over `area` for other inputs. +pub fn combine_sparse(a: &Surface, b: &Surface, area: Rect, f: impl Fn(f32, f32) -> f32) -> Surface { + let gray = PixelFormat::GRAY8; + let lut: Vec = (0..=u16::MAX).map(|i| encode8(f(decode8((i >> 8) as u8), decode8(i as u8)))).collect(); + let pair = |x: u8, y: u8| lut.get(usize::from(x) << 8 | usize::from(y)).copied().unwrap_or(0); + if a.format() != gray || b.format() != gray || b.default_bytes() != [0] || !(0..=255u8).all(|v| pair(v, 0) == v) { + return combine_surfaces(a, b, area, f); + } + let da = a.default_bytes().first().copied().unwrap_or(0); + let mut out = a.clone(); + let (full, untouched) = (out.solid_tile(&[1.0]), out.solid_tile(&[decode8(da)])); + let coords: Vec = b.tiles().map(|(c, _)| *c).filter(|c| !c.rect().intersect(&area).is_empty()).collect(); + // Each tile: `Some(tile)` to store, `None` when it reads as `a`'s untouched value. + let built = par_map(coords.len(), |k| { + let tc = *coords.get(k)?; + let rect = tc.rect(); + let tr = rect.intersect(&area); + let tb = b.tile(tc)?; + let mut tile = Tile::clone(a.tile(tc).unwrap_or(&untouched)); + let data = tile.bytes_mut(); + let (x0, x1) = ((tr.x0 - rect.x0) as usize, (tr.x1 - rect.x0) as usize); + for ly in (tr.y0 - rect.y0) as usize..(tr.y1 - rect.y0) as usize { + let (s, e) = (ly * TS + x0, ly * TS + x1); + let (Some(dst), Some(src)) = (data.get_mut(s..e), tb.bytes().get(s..e)) else { continue }; + for (d, y) in dst.iter_mut().zip(src) { + *d = pair(*d, *y); + } + } + let (lo, hi) = tile.bytes().iter().fold((u8::MAX, 0u8), |(lo, hi), v| (lo.min(*v), hi.max(*v))); + Some(( + tc, + if lo == da && hi == da { + None + } else if lo == u8::MAX { + Some(Arc::clone(&full)) + } else { + Some(Arc::new(tile)) + }, + )) + }); + for (tc, tile) in built.into_iter().flatten() { + match tile { + Some(t) => out.put_tiles([(tc, t)]), + // `take_tiles` only allocates for a tile that is missing; this one is there. + None if out.tile(tc).is_some() => drop(out.take_tiles(tc.rect())), + None => {} + } + } + out +} + #[cfg(test)] mod tests { use super::*; @@ -311,4 +365,30 @@ mod tests { } } } + + /// Add and Subtract through only the new shape's tiles give the bytes of the whole-area + /// combination, keep the old selection's other tiles shared, and keep an untouched value + /// (a layer mask's reveal-all) outside them. + #[test] + fn sparse_combination_matches_the_whole_area_one() { + let area = Rect::new(0, 0, 1100, 700); + let mut rect = Surface::new(PixelFormat::GRAY8); + rect.fill_rect(Rect::from_xywh(200, 100, 600, 520), &[1.0]); + let ell = old_ellipse(Rect::from_xywh(-60, 250, 700, 500), area, true); + let far = ellipse_surface(Rect::from_xywh(900, 20, 150, 150), area, true); + let ops: [fn(f32, f32) -> f32; 2] = [|a, b| a.max(b), |a, b| (a - b).max(0.0)]; + for (a, b) in [(&rect, &ell), (&ell, &rect), (&ell, &ell), (&rect, &far)] { + for f in ops { + let sparse = combine_sparse(a, b, area, f); + assert!(sparse.read_region(area) == old_combine(a, b, area, f).read_region(area)); + assert!(sparse.tiles().all(|(_, t)| t.bytes().iter().any(|v| *v != 0)), "no empty tiles kept"); + } + } + let kept = combine_sparse(&rect, &far, area, |a, b| a.max(b)); + let tc = TileCoord::new(1, 1); + assert!(Arc::ptr_eq(kept.tile(tc).expect("kept"), rect.tile(tc).expect("there")), "untouched tiles stay shared"); + let reveal = Surface::with_default(PixelFormat::GRAY8, &[1.0]); + let cut = combine_sparse(&reveal, &far, area, |a, b| (a - b).max(0.0)); + assert!(cut.pixel(5000, 5000)[0] == 1.0 && cut.pixel(975, 95)[0] == 0.0); + } } diff --git a/crates/engine/src/select_mask_edits.rs b/crates/engine/src/select_mask_edits.rs index 6506ac72ea..9fd29a2757 100644 --- a/crates/engine/src/select_mask_edits.rs +++ b/crates/engine/src/select_mask_edits.rs @@ -8,6 +8,8 @@ //! them, OK is one history step, and the UI, CLI, MCP and the control channel all drive them the //! same way. [`apply`] is pure, so the workspace's preview runs exactly what OK commits. +use std::collections::BTreeSet; + use photocraft_algo::matting; use photocraft_algo::segment::{grabcut, quick, subject}; use photocraft_algo::selection::{self as sel, Region, SelectionMode, combine_region}; @@ -183,22 +185,41 @@ fn mode(add: bool) -> SelectionMode { if add { SelectionMode::Add } else { SelectionMode::Subtract } } -/// The polygon through `points` (anti-aliased), over the rows and columns it spans in `canvas`. -fn polygon_region(points: &[(f32, f32)], canvas: Rect) -> Option { - let (mut x0, mut y0, mut x1, mut y1) = (f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY); - for p in points { - (x0, y0, x1, y1) = (x0.min(p.0), y0.min(p.1), x1.max(p.0), y1.max(p.1)); +/// Most tiles the Brush and Lasso edits may paint in all: four times what one may +/// ([`sel::MAX_PAINTED_TILES`]), 1 GiB of GRAY8 tiles. Each is rasterised a tile at a time, so +/// this, not the canvas size, bounds what they allocate (the #8783 review). +pub const MAX_EDIT_TILES: usize = 4 * sel::MAX_PAINTED_TILES; + +/// Refuses Brush and Lasso edits that would paint too many tiles of `canvas`, one by one or in +/// all, before anything is painted. +pub fn check_size(edits: &[SelectionEdit], canvas: Rect) -> Result<()> { + let mut all = BTreeSet::new(); + for e in edits { + let tiles = match e { + SelectionEdit::Brush { points, size, .. } => matting::painted_tiles(points, *size, canvas, sel::MAX_PAINTED_TILES), + SelectionEdit::Lasso { points, .. } => sel::polygon_edge_tiles(points, canvas, sel::MAX_PAINTED_TILES), + _ => continue, + }; + all.extend(tiles.ok_or_else(|| bad("a brush or lasso edit crosses too many tiles of the canvas"))?); + if all.len() > MAX_EDIT_TILES { + return Err(bad("brush and lasso edits cross too many tiles of the canvas")); + } } - if x0 > x1 || y0 > y1 { - return None; + Ok(()) +} + +/// Refuses Refine Edge Brush strokes that would paint more tiles of `canvas` than +/// [`matting::EdgeBrush`] holds. +pub fn check_strokes(strokes: &[matting::EdgeStroke], canvas: Rect) -> Result<()> { + let refused = || EngineError::BadParams { cmd: CMD.into(), msg: "refineStrokes cross too many tiles of the canvas".into() }; + let mut all = BTreeSet::new(); + for s in strokes { + all.extend(matting::painted_tiles(&s.points, s.size, canvas, sel::MAX_PAINTED_TILES).ok_or_else(refused)?); + if all.len() > sel::MAX_PAINTED_TILES { + return Err(refused()); + } } - let clamp = |v: f32| v.clamp(-1e7, 1e7) as i32; - let bbox = Rect::new(clamp(x0.floor()) - 1, clamp(y0.floor()) - 1, clamp(x1.ceil()) + 1, clamp(y1.ceil()) + 1).intersect(&canvas); - if bbox.is_empty() { - return None; - } - let mask: Vec = sel::polygon(points, bbox, true).iter().map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8).collect(); - mask.iter().any(|v| *v > 0).then_some(Region { bbox, mask }) + Ok(()) } /// `base` inverted over `canvas`; `None` when that leaves nothing selected. Tile by tile: the @@ -235,13 +256,22 @@ pub fn apply_one(doc: &Document, layer: Option, base: Option, Some(r) => combine_region(base.as_ref(), Some(&r), m), None => base.clone(), }; + // A painted shape (tile-sparse) added or subtracted, a tile at a time. + let paint = |shape: Option, add: bool| match (shape.filter(|s| s.tile_count() > 0), base.as_ref()) { + (None, _) => base.clone(), + (Some(s), None) => add.then_some(s), + (Some(s), Some(o)) => { + let out = if add { sel::combine_sparse(o, &s, canvas, f32::max) } else { sel::combine_sparse(o, &s, canvas, |a, b| (a - b).max(0.0)) }; + (!out.content_bounds().is_empty()).then_some(out) + } + }; match edit { SelectionEdit::Quick { points, size, add, sample_all_layers } => { let region = with_doc_sampler(doc, layer, *sample_all_layers, |smp, doc| quick::quick_select(smp, doc.bounds(), points, *size)); combine(region, mode(*add)) } - SelectionEdit::Brush { points, size, hardness, add } => combine(matting::brush_region(points, *size, *hardness, canvas), mode(*add)), - SelectionEdit::Lasso { points, add } => combine(polygon_region(points, canvas), mode(*add)), + SelectionEdit::Brush { points, size, hardness, add } => paint(matting::brush_surface(points, *size, *hardness, canvas), *add), + SelectionEdit::Lasso { points, add } => paint(sel::polygon_surface(points, canvas), *add), SelectionEdit::Object { area, add, sample_all_layers } => { let region = with_doc_sampler(doc, layer, *sample_all_layers, |smp, doc| match *area { ObjectArea::Rect(r) => grabcut::object_select(smp, doc.bounds(), r, 160_000), @@ -394,4 +424,40 @@ mod tests { assert!(all.sample_channel(15, 15, 0) > 0.99 && all.sample_channel(70_000, 3, 0) > 0.99); assert!(t.elapsed() < std::time::Duration::from_secs(20), "{:?}", t.elapsed()); } + + /// The #8783 review: a size-1 Brush edit from corner to corner of a 100,000 px canvas sweeps + /// about 141,000 px but its bounding box is 10 billion; it, a Lasso sliver along it and their + /// subtraction paint only the tiles they cross, and keep the selection there was. + #[test] + fn thin_edits_across_a_huge_canvas_paint_only_their_tiles() { + let doc = Document::new("huge", photocraft_doc::Size::new(100_000, 100_000), photocraft_doc::ColorMode::Rgb, photocraft_color::SampleType::U8); + let canvas = doc.bounds(); + let mut small = Surface::new(photocraft_color::PixelFormat::GRAY8); + small.fill_rect(Rect::new(10, 60, 20, 70), &[1.0]); + let diagonal = vec![(0.0, 0.0), (99_999.0, 99_999.0)]; + let brush = SelectionEdit::Brush { points: diagonal.clone(), size: 1.0, hardness: 100.0, add: true }; + let lasso = SelectionEdit::Lasso { points: vec![(0.0, 0.0), (99_999.0, 99_999.0), (0.0, 1.5)], add: true }; + assert!(check_size(&[brush.clone(), lasso.clone()], canvas).is_ok()); + let t = std::time::Instant::now(); + for edit in [&brush, &lasso] { + let s = apply(&doc, None, Some(small.clone()), std::slice::from_ref(edit)).expect("painted"); + assert!(s.tile_count() < 1500, "{}", s.tile_count()); + assert!(s.sample_channel(15, 65, 0) > 0.99 && s.sample_channel(50_000, 50_000, 0) > 0.0 && s.sample_channel(50_000, 40_000, 0) == 0.0); + } + let erased = + apply(&doc, None, Some(small.clone()), &[brush, SelectionEdit::Brush { points: diagonal, size: 9.0, hardness: 100.0, add: false }]).expect("kept"); + assert!(erased.sample_channel(15, 65, 0) > 0.99 && erased.sample_channel(50_000, 50_000, 0) == 0.0); + assert!(t.elapsed() < std::time::Duration::from_secs(20), "{:?}", t.elapsed()); + // A zigzag across more tiles than one edit may paint is refused before painting. + let zigzag: Vec<(f32, f32)> = (0..20).map(|i| (if i % 2 == 0 { 0.0 } else { 99_999.0 }, i as f32 * 5000.0)).collect(); + let over = [SelectionEdit::Brush { points: zigzag.clone(), size: 1.0, hardness: 100.0, add: true }]; + assert!(check_size(&over, canvas).is_err()); + assert!(check_size(&[SelectionEdit::Lasso { points: zigzag, add: true }], canvas).is_err()); + // So are many edits that each fit but cross too many tiles in all. + let rows: Vec = (0..48) + .map(|i| SelectionEdit::Brush { points: vec![(0.0, i as f32 * 2000.0), (99_999.0, i as f32 * 2000.0)], size: 1.0, hardness: 100.0, add: true }) + .collect(); + assert!(rows.iter().all(|e| check_size(std::slice::from_ref(e), canvas).is_ok())); + assert!(check_size(&rows, canvas).is_err()); + } } diff --git a/crates/engine/src/smartselect_cmds.rs b/crates/engine/src/smartselect_cmds.rs index cad2feac28..8d6366360b 100644 --- a/crates/engine/src/smartselect_cmds.rs +++ b/crates/engine/src/smartselect_cmds.rs @@ -422,6 +422,9 @@ fn refine_edge(s: &mut Session, p: &Value) -> Result { output = "newLayerWithMask".into(); } let edits = select_mask_edits::parse(p)?; + // Before anything is painted: a thin stroke across a huge canvas paints few pixels but many tiles. + select_mask_edits::check_size(&edits, d.doc.bounds())?; + select_mask_edits::check_strokes(&strokes, d.doc.bounds())?; let region = with_sampler(s, b(p, "sampleAllLayers", false), |smp, doc| { let mask_surf = active.and_then(|id| doc.layer(id)).and_then(|l| l.mask.as_ref()).map(|m| &m.surface); let target_mask = p.get("target").and_then(Value::as_str) == Some("mask"); @@ -1076,6 +1079,30 @@ mod tests { (0..h).flat_map(|y| (0..w).map(move |x| (cov(s, x, y) * 255.0).round() as u8)).collect() } + /// The #8783 review: Brush and Lasso edits and Refine Edge Brush strokes that would paint + /// too many tiles of a huge canvas are refused before anything is painted, and the selection + /// stays as it was. + #[test] + fn refine_edge_refuses_edits_that_cross_too_many_tiles() { + let mut s = Session::new(); + s.add_document( + Document::new("huge", photocraft_doc::Size::new(100_000, 100_000), photocraft_doc::ColorMode::Rgb, photocraft_color::SampleType::U8), + None, + ); + s.execute("select.rect", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap(); + let before = s.active().and_then(|d| d.doc.selection.clone()).expect("selected"); + let zigzag: Vec<[f32; 2]> = (0..20).map(|i| [if i % 2 == 0 { 0.0 } else { 99_999.0 }, i as f32 * 5000.0]).collect(); + for p in [ + json!({"edits": [{"tool": "brush", "points": zigzag, "size": 1}]}), + json!({"edits": [{"tool": "lasso", "points": zigzag}]}), + json!({"refineStrokes": [{"points": zigzag, "size": 1}]}), + ] { + let e = s.execute("select.refineEdge", p.clone()).expect_err("refused"); + assert!(e.to_string().contains("too many tiles"), "{p}: {e}"); + assert!(s.active().and_then(|d| d.doc.selection.as_ref()) == Some(&before), "{p}: the selection is unchanged"); + } + } + /// Select and Mask's own tools (#7069, #8148): each edit changes the selection before the /// edge is refined, at every depth, and the whole run is one history step. #[test] From f58e2f42753f80deefdae1b4426ec90db39c41a0 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Fri, 23 Oct 2026 14:41:20 +0530 Subject: [PATCH 22/53] fix(algo,engine): refuse a Select and Mask refine too large to allocate refine_mask_with builds its result densely over the selection's bounds (grown by the spread, plus Refine Edge Brush additions). After a thin edit across a huge canvas, or with a whole-canvas selection there, that is billions of pixels. matting::refine_area names that area and select.refineEdge refuses one over MAX_REFINE_AREA (2^30 px) with an error before allocating, leaving the selection unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FszacAJDjznVimaxQktoJT --- crates/algo/src/matting.rs | 21 +++++++++++-- crates/engine/src/smartselect_cmds.rs | 44 +++++++++++++++++++++++++-- 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/crates/algo/src/matting.rs b/crates/algo/src/matting.rs index f9f3036eb3..f4bfccb0b5 100644 --- a/crates/algo/src/matting.rs +++ b/crates/algo/src/matting.rs @@ -413,6 +413,22 @@ where lo.into_iter().zip(hi).map(|(a, b)| a + (b - a) * frac).collect() } +/// Most pixels one refine should cover. Its result is a dense [`Region`] over [`refine_area`] +/// (about two bytes a pixel while it is assembled), so callers refuse a larger refine up front +/// rather than allocate it: 2³⁰ pixels, a 32k × 32k selection. +pub const MAX_REFINE_AREA: u64 = 1 << 30; + +/// The area [`refine_mask`] and [`refine_mask_brushed`] compute and allocate their result over: +/// `content` grown by how far `p` can spread it, plus where `brush` adds, within `canvas`. Empty +/// when `content` misses the canvas. +pub fn refine_area(content: Rect, canvas: Rect, p: &RefineParams, brush: Option<&EdgeBrush>) -> Rect { + let content = content.intersect(&canvas); + if content.is_empty() { + return Rect::EMPTY; + } + content.inflate(p.spread()).union(&brush.map_or(Rect::EMPTY, EdgeBrush::added_bounds)).intersect(&canvas) +} + /// Refines a selection mask. `mask(r)` returns coverage over `r` (row-major); `content` bounds /// its non-zero pixels; the result is clipped to `canvas`. `sampler` supplies the guide image /// (only read when `radius > 0`). Returns `None` when nothing remains selected. @@ -446,11 +462,10 @@ fn refine_mask_with( where F: Fn(&[f32], usize, usize, usize, bool) -> Vec + Copy + Sync + Send, { - let content = content.intersect(&canvas); - if content.is_empty() { + let bbox = refine_area(content, canvas, p, brush); + if bbox.is_empty() { return None; } - let bbox = content.inflate(p.spread()).union(&brush.map_or(Rect::EMPTY, EdgeBrush::added_bounds)).intersect(&canvas); let halo = p.halo() + brush.map_or(0, |b| i32::try_from(b.window() * 4).unwrap_or(i32::MAX)); let mut tiles = Vec::new(); let mut y = bbox.y0; diff --git a/crates/engine/src/smartselect_cmds.rs b/crates/engine/src/smartselect_cmds.rs index 8d6366360b..cfd1b333e0 100644 --- a/crates/engine/src/smartselect_cmds.rs +++ b/crates/engine/src/smartselect_cmds.rs @@ -431,10 +431,20 @@ fn refine_edge(s: &mut Session, p: &Value) -> Result { let start = if target_mask { mask_surf.or(doc.selection.as_ref()) } else { doc.selection.as_ref().or(mask_surf) }; // The workspace's tool edits change the selection before its edge is refined. let edited = if edits.is_empty() { None } else { select_mask_edits::apply(doc, active, start.cloned(), &edits) }; - let sel = if edits.is_empty() { start } else { edited.as_ref() }?; + let Some(sel) = (if edits.is_empty() { start } else { edited.as_ref() }) else { return Ok(None) }; let brush = matting::EdgeBrush::new(&strokes, doc.bounds()); - matting::refine_mask_brushed(smp, &matting::surface_reader(sel), sel.content_bounds(), doc.bounds(), ¶ms, brush.as_ref()) - })?; + // The refined result is dense over the selection's bounds: refuse what would not fit + // rather than allocate it (a thin edit across a huge canvas; the #8783 review). + let content = sel.content_bounds(); + let area = matting::refine_area(content, doc.bounds(), ¶ms, brush.as_ref()); + if u64::from(area.width()) * u64::from(area.height()) > matting::MAX_REFINE_AREA { + return Err(bad( + "select.refineEdge", + &format!("the selection is too large to refine ({} × {} px; at most {} px in all)", area.width(), area.height(), matting::MAX_REFINE_AREA), + )); + } + Ok(matting::refine_mask_brushed(smp, &matting::surface_reader(sel), content, doc.bounds(), ¶ms, brush.as_ref())) + })??; let d = s.active().ok_or(EngineError::NoDocument)?; let active = d.active_layer.filter(|id| d.doc.layer(*id).is_some()); match output.as_str() { @@ -1079,6 +1089,34 @@ mod tests { (0..h).flat_map(|y| (0..w).map(move |x| (cov(s, x, y) * 255.0).round() as u8)).collect() } + /// The #8783 review: a refine whose result would be dense over more than + /// [`matting::MAX_REFINE_AREA`] pixels is refused, not allocated: a thin Brush edit across a + /// huge canvas (its edited selection spans the canvas) and a selection of the whole canvas. + /// The selection stays as it was. + #[test] + fn refine_edge_refuses_a_selection_too_large_to_refine() { + let mut s = Session::new(); + s.add_document( + Document::new("huge", photocraft_doc::Size::new(100_000, 100_000), photocraft_doc::ColorMode::Rgb, photocraft_color::SampleType::U8), + None, + ); + s.execute("select.rect", json!({"x": 10, "y": 60, "width": 10, "height": 10})).unwrap(); + let small = s.active().and_then(|d| d.doc.selection.clone()).expect("selected"); + let diagonal = json!({"edits": [{"tool": "brush", "points": [[0, 0], [99_999, 99_999]], "size": 1}]}); + let t = std::time::Instant::now(); + let e = s.execute("select.refineEdge", diagonal).expect_err("too large to refine"); + assert!(e.to_string().contains("too large to refine"), "{e}"); + assert!(s.active().and_then(|d| d.doc.selection.as_ref()) == Some(&small), "the selection is unchanged"); + // The small selection itself still refines. + s.execute("select.refineEdge", json!({"radius": 4})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("select.all", json!({})).unwrap(); + let all = s.active().and_then(|d| d.doc.selection.clone()).expect("selected"); + assert!(s.execute("select.refineEdge", json!({"radius": 4})).is_err()); + assert!(s.active().and_then(|d| d.doc.selection.as_ref()) == Some(&all)); + assert!(t.elapsed() < std::time::Duration::from_secs(20), "{:?}", t.elapsed()); + } + /// The #8783 review: Brush and Lasso edits and Refine Edge Brush strokes that would paint /// too many tiles of a huge canvas are refused before anything is painted, and the selection /// stays as it was. From 1695f323e6cfd3e94d6678c1848810b49ec78ea1 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 08:38:15 -0400 Subject: [PATCH 23/53] fix(psd): retain pattern budget charges after parse failures (#8767) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep allocation charges monotonic when malformed pattern blocks fail, so later sections cannot reuse budget already consumed. 🤖 Generated with Codebuff Co-authored-by: Codebuff --- crates/psd/src/abr.rs | 49 +++++++++++++++++++++++++++++++------- crates/psd/src/patterns.rs | 29 +++++++++++++++++----- 2 files changed, 63 insertions(+), 15 deletions(-) diff --git a/crates/psd/src/abr.rs b/crates/psd/src/abr.rs index 0a0a41ffbf..6a9f0806c5 100644 --- a/crates/psd/src/abr.rs +++ b/crates/psd/src/abr.rs @@ -435,6 +435,7 @@ fn parse_v6_within(r: &mut Rd, out: &mut AbrFile, budget: &mut Budget) -> Result return Err(PsdError::Unsupported(format!("brush file subversion {}", out.subversion))); } let mut hierarchy = None; + let mut patterns = PatternBudget::default(); while r.left() >= 12 { let sig = r.arr::<4>()?; if &sig != b"8BIM" { @@ -458,16 +459,17 @@ fn parse_v6_within(r: &mut Rd, out: &mut AbrFile, budget: &mut Budget) -> Result match &key { b"samp" => read_samp(data, out.subversion, out, budget)?, b"patt" => { - // Patterns share the samples' budget, so neither can claim it twice. + // Patterns share the samples' budget and one padding budget across every `patt` + // section, so neither can be claimed twice and failed blocks still deduct their + // decoded charges. let limit = u64::try_from(budget.0).unwrap_or(u64::MAX); - let mut patterns = PatternBudget::with_decoded_limit(limit); + patterns.cap_decoded_to(limit); let left = patterns.decoded_left(); - match parse_pattern_block_with(data, &mut patterns) { - Ok(p) => { - let spent = usize::try_from(left.saturating_sub(patterns.decoded_left())).unwrap_or(usize::MAX); - budget.spend(spent)?; - out.patterns.extend(p); - } + let res = parse_pattern_block_with(data, &mut patterns); + let spent = usize::try_from(left.saturating_sub(patterns.decoded_left())).unwrap_or(usize::MAX); + budget.spend(spent)?; + match res { + Ok(p) => out.patterns.extend(p), Err(e) => out.warnings.push(format!("embedded patterns unreadable: {e}")), } } @@ -706,10 +708,39 @@ mod tests { assert_eq!((out.samples.len(), out.patterns.len(), left), (1, 2, 0)); // One byte short: the patterns no longer fit after the sample (they used not to count). let (out, left) = parse_within(189); - assert_eq!((out.samples.len(), out.patterns.len(), left), (1, 0, 89)); + assert_eq!((out.samples.len(), out.patterns.len(), left), (1, 0, 14)); assert!(out.warnings.iter().any(|w| w.contains("embedded patterns unreadable")), "{:?}", out.warnings); } + #[test] + fn failed_pattern_section_does_not_refund_budget_to_later_sections() { + let p = PsdPattern { + mode: 3, + width: 5, + height: 3, + name: "p".into(), + id: "id".into(), + palette: None, + depth: 8, + channels: vec![vec![1; 15], vec![2; 15], vec![3; 15]], + alpha: None, + }; + let valid = write_pattern_block(std::slice::from_ref(&p)).unwrap(); + let mut malformed = valid.clone(); + malformed.extend_from_slice(&100u32.to_be_bytes()); + + let mut body = 2u16.to_be_bytes().to_vec(); + section(&mut body, b"patt", &malformed); + section(&mut body, b"patt", &valid); + let mut out = AbrFile::default(); + let mut budget = Budget(60); + parse_v6_within(&mut Rd::new(&body), &mut out, &mut budget).unwrap(); + + assert!(out.patterns.is_empty(), "a failed block is discarded atomically"); + assert_eq!(budget.0, 0, "the failed block's first 45 bytes and later partial 15-byte charge are retained"); + assert_eq!(out.warnings.iter().filter(|w| w.contains("embedded patterns unreadable")).count(), 2); + } + #[test] fn v1_v2_round_trip_raw_and_rle() { for version in [1u16, 2] { diff --git a/crates/psd/src/patterns.rs b/crates/psd/src/patterns.rs index 7f845f525e..15d88f2ea8 100644 --- a/crates/psd/src/patterns.rs +++ b/crates/psd/src/patterns.rs @@ -56,6 +56,12 @@ impl PatternBudget { PatternBudget { decoded_left: d.decoded_left.min(decoded), padded_left: d.padded_left.min(decoded) } } + /// Cap both decoded and padded bytes to the amount still available to a containing file. + pub fn cap_decoded_to(&mut self, decoded: u64) { + self.decoded_left = self.decoded_left.min(decoded); + self.padded_left = self.padded_left.min(decoded); + } + /// Decoded bytes still available. pub fn decoded_left(&self) -> u64 { self.decoded_left @@ -425,9 +431,8 @@ pub fn parse_pattern_block(data: &[u8]) -> Result> { } /// [`parse_pattern_block`] charging `budget`, so several blocks of one document share one limit. -/// The budget is charged only for patterns that are returned. +/// Charges are monotonic even when a later record in the block fails. pub fn parse_pattern_block_with(data: &[u8], budget: &mut PatternBudget) -> Result> { - let mut left = budget.clone(); let mut r = Rd { b: data, p: 0 }; let mut out = Vec::new(); while r.p + 4 <= data.len() { @@ -436,11 +441,10 @@ pub fn parse_pattern_block_with(data: &[u8], budget: &mut PatternBudget) -> Resu break; } let body = r.take(len)?; - out.push(read_pattern(&mut Rd { b: body, p: 0 }, &mut left)?); + out.push(read_pattern(&mut Rd { b: body, p: 0 }, budget)?); let pad = (4 - len % 4) % 4; r.p = (r.p + pad).min(data.len()); } - *budget = left; Ok(out) } @@ -729,6 +733,18 @@ mod tests { block } + #[test] + fn failed_pattern_block_keeps_partial_charge_for_the_next_block() { + let one = padded_gray_block(100); + let mut malformed = one.clone(); + malformed.extend_from_slice(&100u32.to_be_bytes()); + let mut budget = PatternBudget::with_decoded_limit(15_000); + assert!(parse_pattern_block_with(&malformed, &mut budget).is_err()); + assert_eq!(budget.decoded_left(), 5_000); + assert!(parse_pattern_block_with(&one, &mut budget).is_err()); + assert_eq!(budget.decoded_left(), 5_000); + } + #[test] fn padding_budget_is_shared_by_every_pattern_of_a_parse() { // Each pattern pads 100x100 = 10000 bytes; a 15000-byte budget fits one, not two. Before @@ -741,8 +757,9 @@ mod tests { let two = [one.clone(), one.clone()].concat(); let mut budget = PatternBudget::with_decoded_limit(15_000); assert!(matches!(parse_pattern_block_with(&two, &mut budget), Err(PsdError::LimitExceeded(_)))); - // A failed parse keeps nothing, so it charges nothing. - assert_eq!(budget.decoded_left(), 15_000); + // Charges are monotonic: the first pattern's 10000 bytes stay charged even when the second + // record fails. + assert_eq!(budget.decoded_left(), 5_000); // Two blocks of one document share a budget too. let mut budget = PatternBudget::with_decoded_limit(15_000); From ecff046ea13bc73378a408fb4f242db02d476f3f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 09:27:48 -0400 Subject: [PATCH 24/53] fix(algo): avoid repeated copies of duplicate EXIF text (#8771) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(algo): avoid copying duplicate EXIF text payloads Keep the first valid text value for each EXIF tag, preventing duplicate entries from repeatedly decoding and copying large attacker-controlled strings. 🤖 Generated with Codebuff Co-Authored-By: Codebuff * test(exif): count text decodes so the duplicate-tag test observes skipped payloads --------- Co-authored-by: Codebuff --- crates/algo/src/exif.rs | 56 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/crates/algo/src/exif.rs b/crates/algo/src/exif.rs index d18bcdec9e..15c81fbe9c 100644 --- a/crates/algo/src/exif.rs +++ b/crates/algo/src/exif.rs @@ -87,6 +87,8 @@ impl Tiff<'_> { } } fn text(&self, typ: u16, count: u32, field: usize) -> Option { + #[cfg(test)] + TEXT_DECODES.with(|c| c.set(c.get() + 1)); if typ != 2 { return None; } @@ -97,6 +99,9 @@ impl Tiff<'_> { } } +#[cfg(test)] +thread_local!(static TEXT_DECODES: std::cell::Cell = const { std::cell::Cell::new(0) }); + /// Reads [`CameraInfo`] from a TIFF-structured EXIF payload (an `Exif\0\0` prefix is skipped). pub fn read(exif: &[u8]) -> CameraInfo { let b = exif.strip_prefix(b"Exif\0\0").unwrap_or(exif); @@ -117,15 +122,17 @@ pub fn read(exif: &[u8]) -> CameraInfo { seen.push(off); for (tag, typ, count, field) in t.ifd(off) { match tag { - 0x010F => info.make = t.text(typ, count, field), - 0x0110 => info.model = t.text(typ, count, field), + // Duplicate TIFF text tags can point at the same large payload. Keep the first + // recognized value and do not repeatedly decode/copy later duplicates. + 0x010F if info.make.is_none() => info.make = t.text(typ, count, field), + 0x0110 if info.model.is_none() => info.model = t.text(typ, count, field), 0x829A => info.exposure_time = t.number(typ, count, field), 0x829D => info.f_number = t.number(typ, count, field), 0x8827 => info.iso = t.number(typ, count, field), 0x920A => info.focal_length = t.number(typ, count, field), 0xA405 => info.focal_length_35mm = t.number(typ, count, field).filter(|v| *v > 0.0), - 0xA434 => info.lens = t.text(typ, count, field), - 0x9003 => info.date_taken = t.text(typ, count, field), + 0xA434 if info.lens.is_none() => info.lens = t.text(typ, count, field), + 0x9003 if info.date_taken.is_none() => info.date_taken = t.text(typ, count, field), 0x8769 => { if let Some(p) = t.u32(field) { queue.push(p as usize); @@ -252,6 +259,47 @@ mod tests { /// Hostile payloads: huge counts, offsets near `u32::MAX`, self-referencing IFDs and random /// bytes read as `None` fields instead of panicking or overflowing (usize is 32 bits on wasm). + #[test] + fn duplicate_text_tags_keep_the_first_value_without_copying_large_duplicates() { + let value = vec![b'X'; 2 * 1024 * 1024]; + let mut b = b"II*\0".to_vec(); + b.extend_from_slice(&8u32.to_le_bytes()); + // IFD0: Make="M", a duplicate Make pointing at a 2 MiB payload, and the Exif IFD link. + b.extend_from_slice(&3u16.to_le_bytes()); + b.extend_from_slice(&0x010Fu16.to_le_bytes()); + b.extend_from_slice(&2u16.to_le_bytes()); + b.extend_from_slice(&2u32.to_le_bytes()); + b.extend_from_slice(b"M\0\0\0"); + b.extend_from_slice(&0x010Fu16.to_le_bytes()); + b.extend_from_slice(&2u16.to_le_bytes()); + b.extend_from_slice(&(value.len() as u32).to_le_bytes()); + b.extend_from_slice(&80u32.to_le_bytes()); + b.extend_from_slice(&0x8769u16.to_le_bytes()); + b.extend_from_slice(&4u16.to_le_bytes()); + b.extend_from_slice(&1u32.to_le_bytes()); + b.extend_from_slice(&50u32.to_le_bytes()); + b.extend_from_slice(&0u32.to_le_bytes()); + // Exif IFD at 50: DateTimeOriginal="D" and the same large duplicate payload at offset 80. + b.extend_from_slice(&2u16.to_le_bytes()); + b.extend_from_slice(&0x9003u16.to_le_bytes()); + b.extend_from_slice(&2u16.to_le_bytes()); + b.extend_from_slice(&2u32.to_le_bytes()); + b.extend_from_slice(b"D\0\0\0"); + b.extend_from_slice(&0x9003u16.to_le_bytes()); + b.extend_from_slice(&2u16.to_le_bytes()); + b.extend_from_slice(&(value.len() as u32).to_le_bytes()); + b.extend_from_slice(&80u32.to_le_bytes()); + b.extend_from_slice(&0u32.to_le_bytes()); + b.extend_from_slice(&value); + + TEXT_DECODES.with(|c| c.set(0)); + let info = read(&b); + assert_eq!(info.make.as_deref(), Some("M")); + assert_eq!(info.date_taken.as_deref(), Some("D")); + // One decode per tag: duplicates must be skipped before `text` copies their payload. + assert_eq!(TEXT_DECODES.with(|c| c.get()), 2); + } + #[test] fn hostile_payloads_never_panic() { let entry = |tag: u16, typ: u16, count: u32, value: u32| { From 56d7009c783727b856da52d2001859e24c14d2b0 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 09:27:54 -0400 Subject: [PATCH 25/53] fix(paint): validate gray tile dimensions safely (#8772) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(paint): check tile dimensions before allocation math Reject overflowing tile dimensions during deserialization and treat invalid in-memory tiles as empty safe previews and mipmaps. 🤖 Generated with Codebuff Co-Authored-By: Codebuff * test(tile): assert the address-space overflow error on 32-bit targets --------- Co-authored-by: Codebuff --- crates/paint/src/tile.rs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/crates/paint/src/tile.rs b/crates/paint/src/tile.rs index 38ca38410e..8ea0e776b6 100644 --- a/crates/paint/src/tile.rs +++ b/crates/paint/src/tile.rs @@ -40,7 +40,7 @@ impl GrayTile { self.data.iter().map(|&v| f32::from(v) / 65535.0).collect() } pub fn is_valid(&self) -> bool { - self.width > 0 && self.height > 0 && self.data.len() == self.width as usize * self.height as usize + self.width > 0 && self.height > 0 && (self.width as usize).checked_mul(self.height as usize) == Some(self.data.len()) } /// A box-filtered copy whose larger side is at most `max_side` (the whole tile when it is @@ -157,7 +157,9 @@ impl<'de> Deserialize<'de> for GrayTile { fn deserialize>(d: D) -> Result { use serde::de::Error; let t = GrayTileIn::deserialize(d)?; - let n = t.width as usize * t.height as usize; + let Some(n) = (t.width as usize).checked_mul(t.height as usize) else { + return Err(D::Error::custom("tile dimensions overflow the address space")); + }; let data: Vec = match t.data { DataIn::B64(s) => { let b = b64_decode(&s).ok_or_else(|| D::Error::custom("invalid base64 in tile data"))?; @@ -232,6 +234,9 @@ pub struct Mips { impl Mips { pub fn new(t: &GrayTile) -> Self { + if !t.is_valid() { + return Self { levels: vec![(1, 1, vec![0.0])] }; + } let mut levels = vec![(t.width as usize, t.height as usize, t.to_f32())]; while let Some((w, h, v)) = levels.last() { if *w <= 1 && *h <= 1 { @@ -335,6 +340,18 @@ mod tests { let f: GrayTile = serde_json::from_str(r#"{"width":2,"height":1,"data":[0.0,1.0]}"#).unwrap(); assert_eq!(f.data, vec![0, 65535].into()); assert!(serde_json::from_str::(r#"{"width":2,"height":2,"data":[0.0,1.0]}"#).is_err()); + assert!(serde_json::from_str::(r#"{"width":4294967295,"height":4294967295,"data":[0.0]}"#).is_err()); + let invalid = GrayTile { width: u32::MAX, height: u32::MAX, data: vec![1].into() }; + assert!(!invalid.is_valid()); + assert_eq!(Mips::new(&invalid).levels, vec![(1, 1, vec![0.0])]); + } + + /// `u32::MAX * u32::MAX` only overflows `usize` on 32-bit targets (wasm); 64-bit hits the length check. + #[cfg(target_pointer_width = "32")] + #[test] + fn oversized_dimensions_report_address_space_overflow() { + let err = serde_json::from_str::(r#"{"width":4294967295,"height":4294967295,"data":[0.0]}"#).unwrap_err(); + assert!(err.to_string().contains("tile dimensions overflow the address space")); } #[test] From 96042b8e7a7fcaf5d3d37997aaac824022a7def5 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 09:40:04 -0400 Subject: [PATCH 26/53] fix(text): bound untrusted font and PSD text processing (#8768) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(text): bound font paths and imported text processing Reject encoded path tricks and cap resource-heavy font and PSD text processing to keep untrusted inputs bounded. 🤖 Generated with Codebuff Co-Authored-By: Codebuff * fix(text): reject percent-encoded DEL in served paths; satisfy clippy chunks_exact lint --------- Co-authored-by: Codebuff --- apps/photocraft-web/Cargo.toml | 2 +- apps/photocraft-web/src/web.rs | 21 ++-- crates/text/src/cjk.rs | 39 +++++- crates/text/src/fonts.rs | 62 ++++++++++ crates/text/src/layout.rs | 8 +- crates/text/src/psd.rs | 184 ++++++++++++++++++++--------- crates/text/src/psd_styles.rs | 10 +- crates/text/src/render.rs | 135 +++++++++++++++++---- crates/text/src/served.rs | 78 ++++++++++-- crates/ui-egui/src/served_fonts.rs | 2 +- 10 files changed, 430 insertions(+), 111 deletions(-) diff --git a/apps/photocraft-web/Cargo.toml b/apps/photocraft-web/Cargo.toml index 4ecad80ae4..279d13b453 100644 --- a/apps/photocraft-web/Cargo.toml +++ b/apps/photocraft-web/Cargo.toml @@ -34,7 +34,7 @@ rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "DomRect", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException", "Response", "IdbFactory", "IdbOpenDbRequest", "IdbDatabase", "IdbRequest", "IdbTransaction", "IdbTransactionMode", "IdbObjectStore", "IdbCursor", "IdbCursorWithValue", "RequestInit", "Navigator"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "DomRect", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException", "Response", "IdbFactory", "IdbOpenDbRequest", "IdbDatabase", "IdbRequest", "IdbTransaction", "IdbTransactionMode", "IdbObjectStore", "IdbCursor", "IdbCursorWithValue", "RequestInit", "RequestMode", "Navigator"] } [dev-dependencies] serde_json = { workspace = true } diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index fc17612908..87e6eaf2e8 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -233,20 +233,21 @@ fn browser_languages() -> Vec { web_sys::window().map(|w| w.navigator().languages().iter().filter_map(|l| l.as_string()).collect()).unwrap_or_default() } -/// GET `url` (relative to the page) and read the whole body. With `integrity` (a Subresource -/// Integrity value), the browser rejects a body whose hash differs. +/// GET `url` (a relative path on the same origin) and read the whole body. With `integrity` (a +/// Subresource Integrity value), the browser rejects a body whose hash differs. async fn fetch_bytes(url: &str, integrity: Option<&str>) -> Result, String> { use wasm_bindgen_futures::JsFuture; + if !served_fonts::is_relative_path(url) { + return Err("url must be a relative path".into()); + } let js = |e: wasm_bindgen::JsValue| format!("{e:?}"); let window = web_sys::window().ok_or("no window")?; - let request = match integrity { - Some(sri) => { - let init = web_sys::RequestInit::new(); - init.set_integrity(sri); - window.fetch_with_str_and_init(url, &init) - } - None => window.fetch_with_str(url), - }; + let init = web_sys::RequestInit::new(); + init.set_mode(web_sys::RequestMode::SameOrigin); + if let Some(sri) = integrity { + init.set_integrity(sri); + } + let request = window.fetch_with_str_and_init(url, &init); let resp: web_sys::Response = JsFuture::from(request).await.map_err(js)?.dyn_into().map_err(|_| "not a Response")?; if !resp.ok() { return Err(format!("HTTP {}", resp.status())); diff --git a/crates/text/src/cjk.rs b/crates/text/src/cjk.rs index f0e3193baa..c0e632d983 100644 --- a/crates/text/src/cjk.rs +++ b/crates/text/src/cjk.rs @@ -509,19 +509,48 @@ pub fn mac_pingfang() -> Option { None } +/// Maximum collection faces and cumulative `name` records inspected by [`face_index_for_family`]. +const MAX_COLLECTION_FACES: usize = 256; +const MAX_NAME_RECORDS: usize = 8192; + /// Face index in a font file (or collection) whose family name is `family`; 0 when `family` is /// empty or no face matches. Never panics on malformed data. pub fn face_index_for_family(bytes: &[u8], family: &str) -> u32 { - use skrifa::{MetadataProvider, raw::FileRef, string::StringId}; + use skrifa::{ + MetadataProvider, + raw::{FileRef, types::Tag}, + string::StringId, + }; if family.is_empty() { return 0; } let Ok(FileRef::Collection(c)) = FileRef::new(bytes) else { return 0 }; - for (i, f) in c.iter().enumerate() { + let mut seen_faces = std::collections::HashSet::new(); + let mut seen_names = std::collections::HashSet::new(); + let mut budget = MAX_NAME_RECORDS; + for (i, f) in c.iter().take(MAX_COLLECTION_FACES).enumerate() { + if let Some(off_bytes) = bytes.get(12 + i * 4..12 + (i + 1) * 4) + && let Ok(arr) = <[u8; 4]>::try_from(off_bytes) + && !seen_faces.insert(u32::from_be_bytes(arr)) + { + continue; + } let Ok(f) = f else { continue }; - let named = [StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME].iter().any(|id| f.localized_strings(*id).any(|s| s.chars().eq(family.chars()))); - if named { - return i as u32; + if let Some(rec) = f.table_directory.table_records().iter().find(|r| r.tag() == Tag::new(b"name")) + && !seen_names.insert((rec.offset(), rec.length())) + { + continue; + } + for id in [StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME] { + for s in f.localized_strings(id) { + let Some(rem) = budget.checked_sub(1) else { + return 0; + }; + budget = rem; + if s.chars().eq(family.chars()) { + return i as u32; + } + } } } 0 diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 893fd29ed8..9f43c705b1 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -298,6 +298,9 @@ impl FontDb { /// Registers every face in `blob`, under `family` when given, else under the family each /// face's `name` table gives. fn register_blob(&mut self, blob: Blob, family: Option<&str>) -> Vec { + let Some(blob) = sanitize_font_blob(blob) else { + return Vec::new(); + }; let added = self.fcx.collection.register_fonts(blob, family.map(|f| FontInfoOverride { family_name: Some(f), ..Default::default() })); let mut names = Vec::new(); for (id, _) in added { @@ -551,6 +554,65 @@ impl FontDb { } } +/// Maximum faces and cumulative variation axes admitted from a single font blob. +const MAX_COLLECTION_FACES: usize = 256; +const MAX_COLLECTION_AXES: usize = 4096; + +/// Validates `blob` before handing it to `fontique`, deduplicating repeated TTC table-directory +/// offsets and rejecting collections with excessive faces or variation axes. +fn sanitize_font_blob(blob: Blob) -> Option> { + let data = blob.as_ref(); + if data.len() > 256 * 1024 * 1024 { + return None; + } + match FileRef::new(data).ok()? { + FileRef::Font(font) => { + if font.axes().len() > MAX_COLLECTION_AXES { + return None; + } + Some(blob) + } + FileRef::Collection(collection) => { + let n = usize::try_from(collection.len()).ok()?; + if n == 0 || n > MAX_COLLECTION_FACES { + return None; + } + let dir_end = 12usize.checked_add(n.checked_mul(4)?)?; + let offsets_raw = data.get(12..dir_end)?; + let mut unique_offsets: Vec = Vec::with_capacity(n); + let mut total_axes = 0usize; + for (i, chunk) in offsets_raw.as_chunks::<4>().0.iter().enumerate() { + let off = u32::from_be_bytes(*chunk); + if unique_offsets.contains(&off) { + continue; + } + let idx = u32::try_from(i).ok()?; + let Ok(font) = collection.get(idx) else { + continue; + }; + total_axes = total_axes.checked_add(font.axes().len())?; + if total_axes > MAX_COLLECTION_AXES { + return None; + } + unique_offsets.push(off); + } + if unique_offsets.is_empty() { + return None; + } + if unique_offsets.len() == n { + return Some(blob); + } + let mut bytes = data.to_vec(); + bytes.get_mut(8..12)?.copy_from_slice(&(unique_offsets.len() as u32).to_be_bytes()); + for (slot, off) in unique_offsets.iter().enumerate() { + let pos = 12 + slot * 4; + bytes.get_mut(pos..pos + 4)?.copy_from_slice(&off.to_be_bytes()); + } + Some(Blob::new(Arc::new(bytes))) + } + } +} + /// Make a collection expose only the requested face without rewriting any font tables. /// TTC table offsets are absolute; changing its face directory preserves them verbatim. fn isolated_face(blob: Blob, index: u32) -> Option> { diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 8ca0b6c2aa..5809e360e7 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -597,12 +597,13 @@ impl Layouter { // fetches it (`served`); it joins the fallback stack once it arrives. crate::served::request_for_text(&ptext); let fallback: Vec = fonts.fallback_stack().map(str::to_string).collect(); + let si0 = style_at(prange.start); + let si_end = run_starts.partition_point(|&rs| rs < content_end).max(si0 + 1).min(out.styles.len()); // Shapes the paragraph, each tab widened by its letter spacing in `tab_spacing` (px). let build = |lcx: &mut LayoutContext, fcx: &mut parley::FontContext, tab_spacing: &[f32]| -> Layout { let mut b = lcx.ranged_builder(fcx, &ptext, 1.0, false); // Paragraph-start style as the default (covers the direction mark and empty // paragraphs), then every run piece intersecting this paragraph. - let si0 = style_at(prange.start); for p in style_props(&out.styles[si0], k, &fallback, si0 as u32, small_caps[si0]) { b.push_default(p); } @@ -612,7 +613,7 @@ impl Layouter { if is_box { b.push_default(StyleProperty::OverflowWrap(parley::style::OverflowWrap::BreakWord)); } - for (ri, st) in out.styles.iter().enumerate() { + for (ri, st) in out.styles.iter().enumerate().take(si_end).skip(si0) { let rs = run_starts[ri]; let (a, z) = (rs.max(prange.start), (rs + runs[ri].len).min(content_end)); if a >= z { @@ -675,8 +676,7 @@ impl Layouter { // width divided by it (#5565). Mixed scales break at the largest, so no line // overflows the box. let break_scale = if is_box { - let si0 = style_at(prange.start); - (0..out.styles.len()) + (si0..si_end) .filter(|&ri| { let rs = run_starts.get(ri).copied().unwrap_or(0); let end = rs.saturating_add(runs.get(ri).map_or(0, |r| r.len)); diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index 442f32a238..43ccbd1233 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -435,15 +435,22 @@ fn descriptor_rect(text: &Descriptor, key: &str) -> Option<[f64; 4]> { (bounds.iter().all(|v| v.is_finite()) && bounds[2] > bounds[0] && bounds[3] > bounds[1]).then_some(bounds) } +const MAX_ENGINE_RUNS: usize = 65_536; + pub(crate) fn arr_f(v: Option<&E>) -> Vec { - v.and_then(E::as_array).map(|a| a.iter().filter_map(E::as_f64).collect()).unwrap_or_default() + v.and_then(E::as_array).map(|a| a.iter().take(MAX_ENGINE_RUNS).filter_map(E::as_f64).collect()).unwrap_or_default() } -/// Converts UTF-16 run lengths over `text` into UTF-8 byte lengths. +/// Converts UTF-16 run lengths over `text` into UTF-8 byte lengths, stopping after the first run +/// once `text` is exhausted so trailing runs past the text aren't processed. pub(crate) fn utf16_to_byte_lengths(text: &str, lens: &[f64]) -> Vec { - let mut out = Vec::with_capacity(lens.len()); + let mut out = Vec::with_capacity(lens.len().min(text.len().saturating_add(1))); let mut chars = text.chars().peekable(); - for &l in lens { + let mut exhausted = false; + for &l in lens.iter().take(MAX_ENGINE_RUNS) { + if exhausted { + break; + } let mut units = l.max(0.0) as usize; let mut bytes = 0; while units > 0 { @@ -452,9 +459,15 @@ pub(crate) fn utf16_to_byte_lengths(text: &str, lens: &[f64]) -> Vec { units = units.saturating_sub(c.len_utf16()); bytes += c.len_utf8(); } - None => break, + None => { + exhausted = true; + break; + } } } + if chars.peek().is_none() { + exhausted = true; + } out.push(bytes); } out @@ -495,17 +508,22 @@ fn apply_engine_data(layer: &mut TextLayer, e: &E, txt: Option<&str>, k: f32) { // Paragraph runs first: a character run's defaults depend on its paragraph. let prun = e.path(&["EngineDict", "ParagraphRun"]); - let psheets: Vec<&E> = - prun.and_then(|r| r.get("RunArray")).and_then(E::as_array).map(|a| a.iter().filter_map(|x| x.get("ParagraphSheet")).collect()).unwrap_or_default(); let plens16 = arr_f(prun.and_then(|r| r.get("RunLengthArray"))); let plens = utf16_to_byte_lengths(&ed_text, &plens16); + let max_pruns = plens.len().max(1); + let psheets: Vec<&E> = prun + .and_then(|r| r.get("RunArray")) + .and_then(E::as_array) + .map(|a| a.iter().filter_map(|x| x.get("ParagraphSheet")).take(max_pruns).collect()) + .unwrap_or_default(); + let plens16 = &plens16[..plens16.len().min(psheets.len())]; let empty = E::Dict(Vec::new()); - // Per paragraph run: its merged base properties and its default character style. - let para_bases: Vec<(E, Option<&E>)> = psheets + // Per paragraph run: its borrowed base property layers and its default character style. + let para_bases: Vec<([Option<&E>; 2], Option<&E>)> = psheets .iter() .map(|sheet| { let style = named(para_set, sheet).and_then(|i| para_set.get(i)); - let base = merged(&[style.and_then(|s| s.get("Properties")), normal_para.and_then(|s| s.get("Properties"))]); + let base = [style.and_then(|s| s.get("Properties")), normal_para.and_then(|s| s.get("Properties"))]; let char_default = [Some(*sheet), style] .into_iter() .flatten() @@ -517,15 +535,19 @@ fn apply_engine_data(layer: &mut TextLayer, e: &E, txt: Option<&str>, k: f32) { .iter() .zip(¶_bases) .zip(plens) - .map(|((sheet, (base, _)), len)| ParagraphRun { len, style: para_style(Some(base), sheet.get("Properties").unwrap_or(&empty), k) }) + .map(|((sheet, (base, _)), len)| ParagraphRun { len, style: para_style(base, sheet.get("Properties").unwrap_or(&empty), k) }) .collect(); // Character runs. let srun = e.path(&["EngineDict", "StyleRun"]); - let ssheets: Vec<&E> = - srun.and_then(|r| r.get("RunArray")).and_then(E::as_array).map(|a| a.iter().filter_map(|x| x.get("StyleSheet")).collect()).unwrap_or_default(); let slens16 = arr_f(srun.and_then(|r| r.get("RunLengthArray"))); let slens = utf16_to_byte_lengths(&ed_text, &slens16); + let max_sruns = slens.len().max(1); + let ssheets: Vec<&E> = srun + .and_then(|r| r.get("RunArray")) + .and_then(E::as_array) + .map(|a| a.iter().filter_map(|x| x.get("StyleSheet")).take(max_sruns).collect()) + .unwrap_or_default(); // The paragraph run each character run starts in (UTF-16 offsets). let para_of = |at: f64| -> Option { let mut end = 0.0; @@ -538,14 +560,14 @@ fn apply_engine_data(layer: &mut TextLayer, e: &E, txt: Option<&str>, k: f32) { plens16.len().checked_sub(1) }; let mut at16 = 0.0; - let bases: Vec = ssheets + let bases: Vec<[Option<&E>; 3]> = ssheets .iter() .enumerate() .map(|(i, sheet)| { let own = named(style_set, sheet).and_then(|i| style_set.get(i)); let para_default = para_of(at16).and_then(|p| para_bases.get(p)).and_then(|(_, c)| *c); at16 += slens16.get(i).copied().unwrap_or(0.0); - merged(&[own, para_default, normal_style].map(|s| s.and_then(|s| s.get("StyleSheetData")))) + [own, para_default, normal_style].map(|s| s.and_then(|s| s.get("StyleSheetData"))) }) .collect(); let sdata: Vec<&E> = ssheets.iter().map(|s| s.get("StyleSheetData").unwrap_or(&empty)).collect(); @@ -554,12 +576,12 @@ fn apply_engine_data(layer: &mut TextLayer, e: &E, txt: Option<&str>, k: f32) { .zip(&bases) .zip(&slens) .map(|((d, base), len)| { - let auto = lookup(d, Some(base), "AutoKerning").and_then(E::as_bool) != Some(false); - let kern = lookup(d, Some(base), "Kerning").and_then(E::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0) as f32; + let auto = lookup(d, base, "AutoKerning").and_then(E::as_bool) != Some(false); + let kern = lookup(d, base, "Kerning").and_then(E::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0) as f32; (*len, auto, kern) }) .collect(); - layer.runs = sdata.iter().zip(&bases).zip(slens).map(|((d, base), len)| TextRun { len, style: char_style(Some(base), d, &fonts, k) }).collect(); + layer.runs = sdata.iter().zip(&bases).zip(slens).map(|((d, base), len)| TextRun { len, style: char_style(base, d, &fonts, k) }).collect(); layer.runs = kerning_from_pairs(&layer.text, layer.char_runs(), &incoming); // Shape. @@ -574,26 +596,11 @@ fn apply_engine_data(layer: &mut TextLayer, e: &E, txt: Option<&str>, k: f32) { } } -/// The dictionaries in `levels` as one: a key comes from the first level that has it. -fn merged(levels: &[Option<&E>]) -> E { - let mut out: Vec<(String, E)> = Vec::new(); - for level in levels.iter().flatten() { - if let E::Dict(kv) = level { - for (k, v) in kv { - if !out.iter().any(|(o, _)| o == k) { - out.push((k.clone(), v.clone())); - } - } - } - } - E::Dict(out) +fn lookup<'a>(run: &'a E, base: &[Option<&'a E>], key: &str) -> Option<&'a E> { + run.get(key).or_else(|| base.iter().flatten().find_map(|b| b.get(key))) } -fn lookup<'a>(run: &'a E, base: Option<&'a E>, key: &str) -> Option<&'a E> { - run.get(key).or_else(|| base.and_then(|b| b.get(key))) -} - -pub(crate) fn char_style(base: Option<&E>, d: &E, fonts: &[String], k: f32) -> CharStyle { +pub(crate) fn char_style(base: &[Option<&E>], d: &E, fonts: &[String], k: f32) -> CharStyle { let g = |key: &str| lookup(d, base, key); let num = |key: &str| g(key).and_then(E::as_f64); // An f32 that a huge finite f64 overflowed to infinity is dropped too. @@ -658,7 +665,7 @@ pub(crate) fn char_style(base: Option<&E>, d: &E, fonts: &[String], k: f32) -> C s } -pub(crate) fn para_style(base: Option<&E>, d: &E, k: f32) -> ParagraphStyle { +pub(crate) fn para_style(base: &[Option<&E>], d: &E, k: f32) -> ParagraphStyle { let num = |key: &str| lookup(d, base, key).and_then(E::as_f64); let numk = |key: &str, k: f32| num(key).map(|v| v as f32 * k).filter(|v| v.is_finite()); ParagraphStyle { @@ -1239,6 +1246,12 @@ pub fn build_txt2_at(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>, dpi /// own objects hold a few hundred (611 at most in the file from #7698). const TEMPLATE_NODES: usize = 4096; +/// Cumulative node and byte budgets across all run dictionaries cloned while refitting one `Txt2` +/// object (`runs_per` and `runs_to_length`), so an edited object with a large preserved run and +/// many output paragraphs falls back to a fresh object instead of amplifying memory. +const MAX_REFIT_RUN_NODES: usize = 16_384; +const MAX_REFIT_RUN_BYTES: usize = 1 << 20; + /// Whether `v` holds at most `budget` values, counting down `budget` (stops at the first one over). fn fits(v: &E, budget: &mut usize) -> bool { let Some(left) = budget.checked_sub(1) else { return false }; @@ -1250,6 +1263,27 @@ fn fits(v: &E, budget: &mut usize) -> bool { } } +/// Whether `v` fits within the remaining `(nodes, bytes)` budget, deducting its nodes and string +/// bytes as it walks. +fn fits_budget(v: &E, nodes: &mut usize, bytes: &mut usize) -> bool { + let Some(left) = nodes.checked_sub(1) else { return false }; + *nodes = left; + match v { + E::Dict(items) => items.iter().all(|(k, x)| { + let Some(rem) = bytes.checked_sub(k.len()) else { return false }; + *bytes = rem; + fits_budget(x, nodes, bytes) + }), + E::Array(items) => items.iter().all(|x| fits_budget(x, nodes, bytes)), + E::String(s) | E::Name(s) => { + let Some(rem) = bytes.checked_sub(s.len()) else { return false }; + *bytes = rem; + true + } + _ => true, + } +} + /// Whether the text object `object` has a view naming one frame (`/1 /0 [ << /0 n >> ]`), as /// Photoshop writes it. fn names_one_frame(object: &E) -> bool { @@ -1277,7 +1311,8 @@ fn new_object(template: &E, at: usize, text: &str) -> Option { } } if let Some(runs) = view.path(&["1", "0"]).and_then(E::as_array) { - let fitted = runs_per(runs, ¶graphs, "1")?; + let (mut nodes, mut bytes) = (MAX_REFIT_RUN_NODES, MAX_REFIT_RUN_BYTES); + let fitted = runs_per(runs, ¶graphs, "1", &mut nodes, &mut bytes)?; view.get_mut("1")?.set("0", E::Array(fitted)); } Some(object) @@ -1322,6 +1357,7 @@ fn edited_object(object: &E, text: &str) -> Option { let total = utf16_len(text) as usize; let paragraphs: Vec = text.split_inclusive('\r').map(|p| p.encode_utf16().count()).collect(); let E::Dict(entries) = model else { return None }; + let (mut nodes, mut bytes) = (MAX_REFIT_RUN_NODES, MAX_REFIT_RUN_BYTES); for (key, value) in entries.iter_mut() { if key.as_str() == "0" { *value = E::String(text.to_string()); @@ -1330,7 +1366,11 @@ fn edited_object(object: &E, text: &str) -> Option { let Some(runs) = value.get("0").and_then(E::as_array).filter(|r| r.iter().all(|x| matches!(x, E::Dict(_)) && x.get("1").is_some())) else { continue; }; - let fitted = if key.as_str() == "5" { runs_per(runs, ¶graphs, "1")? } else { runs_to_length(runs, total, "1")? }; + let fitted = if key.as_str() == "5" { + runs_per(runs, ¶graphs, "1", &mut nodes, &mut bytes)? + } else { + runs_to_length(runs, total, "1", &mut nodes, &mut bytes)? + }; value.set("0", E::Array(fitted)); } Some(object) @@ -1357,6 +1397,7 @@ fn td_edited_object(object: &E, text: &str) -> Option { } let total = utf16_len(text) as usize; let paragraphs: Vec = text.split_inclusive('\r').map(|p| p.encode_utf16().count()).collect(); + let (mut nodes, mut bytes) = (MAX_REFIT_RUN_NODES, MAX_REFIT_RUN_BYTES); for (key, value) in entries.iter_mut() { if key.as_str() == "Text" { *value = E::String(text.to_string()); @@ -1372,9 +1413,9 @@ fn td_edited_object(object: &E, text: &str) -> Option { let fitted = match runs { E::Array(runs) if runs.iter().all(|x| matches!(x, E::Dict(_)) && x.get("Length").is_some()) => { if key.as_str() == "ParagraphRun" { - runs_per(runs, ¶graphs, "Length")? + runs_per(runs, ¶graphs, "Length", &mut nodes, &mut bytes)? } else { - runs_to_length(runs, total, "Length")? + runs_to_length(runs, total, "Length", &mut nodes, &mut bytes)? } } _ => return None, @@ -1385,13 +1426,18 @@ fn td_edited_object(object: &E, text: &str) -> Option { } /// `runs` (`<< … /1 length >>`, the length key named `len_key`) remade with the lengths `lens`: -/// run i keeps the content of the file's run i, else of its last one. `None` for an empty `runs`. -fn runs_per(runs: &[E], lens: &[usize], len_key: &str) -> Option> { +/// run i keeps the content of the file's run i, else of its last one. `None` for an empty `runs` +/// or when the cumulative `nodes` / `bytes` budget is exceeded. +fn runs_per(runs: &[E], lens: &[usize], len_key: &str, nodes: &mut usize, bytes: &mut usize) -> Option> { let last = runs.last()?; lens.iter() .enumerate() .map(|(i, &len)| { - let mut r = runs.get(i).unwrap_or(last).clone(); + let src = runs.get(i).unwrap_or(last); + if !fits_budget(src, nodes, bytes) { + return None; + } + let mut r = src.clone(); r.set(len_key, E::Int(i64::try_from(len).ok()?)); Some(r) }) @@ -1400,14 +1446,18 @@ fn runs_per(runs: &[E], lens: &[usize], len_key: &str) -> Option> { /// `runs` (`<< … /1 length >>`, the length key named `len_key`) covering `total` UTF-16 units: /// the leading runs as they were, cut where `total` ends, the last kept run extended when `total` -/// is longer. Empty runs are dropped. `None` when `runs` is empty or a length isn't a count. -fn runs_to_length(runs: &[E], total: usize, len_key: &str) -> Option> { +/// is longer. Empty runs are dropped. `None` when `runs` is empty, a length isn't a count, or the +/// cumulative `nodes` / `bytes` budget is exceeded. +fn runs_to_length(runs: &[E], total: usize, len_key: &str, nodes: &mut usize, bytes: &mut usize) -> Option> { let mut out: Vec<(E, usize)> = Vec::new(); let mut left = total; for r in runs { let len = r.get(len_key).and_then(E::as_i64).and_then(|l| usize::try_from(l).ok())?; let take = len.min(left); if take > 0 { + if !fits_budget(r, nodes, bytes) { + return None; + } out.push((r.clone(), take)); left -= take; } @@ -1415,7 +1465,13 @@ fn runs_to_length(runs: &[E], total: usize, len_key: &str) -> Option> { if left > 0 { match out.last_mut() { Some(last) => last.1 = last.1.checked_add(left)?, - None => out.push((runs.last()?.clone(), left)), + None => { + let last = runs.last()?; + if !fits_budget(last, nodes, bytes) { + return None; + } + out.push((last.clone(), left)); + } } } out.into_iter() @@ -1750,6 +1806,26 @@ pub fn build_tysh(layer: &TextLayer, dpi: f32, ink: Option<[f32; 4]>) -> Vec write_tysh(&TySh { transform: layer.transform, text, warp, bounds }) } +#[cfg(test)] +mod run_length_safety_tests { + use super::*; + + #[test] + fn utf16_lengths_stop_after_text_is_consumed() { + assert_eq!(utf16_to_byte_lengths("é😀", &[1.0, 2.0, 1.0, 1.0e300]), [2, 4]); + assert_eq!(utf16_to_byte_lengths("", &[1.0, 1.0e300]), [0]); + assert_eq!(utf16_to_byte_lengths("abc", &[0.0, 1.0, 0.0, 1.0, 1.0, 1.0e300]), [0, 1, 0, 1, 1]); + let zero_runs = vec![0.0; MAX_ENGINE_RUNS + 1]; + assert_eq!(utf16_to_byte_lengths("a", &zero_runs).len(), MAX_ENGINE_RUNS); + } + + #[test] + fn engine_run_length_array_conversion_is_bounded() { + let values = E::Array(vec![E::Real(0.0); MAX_ENGINE_RUNS + 1]); + assert_eq!(arr_f(Some(&values)).len(), MAX_ENGINE_RUNS); + } +} + #[cfg(test)] mod transform_safety_tests { use super::*; @@ -1777,17 +1853,17 @@ mod nonfinite_tests { #[test] fn non_finite_style_numbers_fall_back_to_defaults() { let d = parse(b"<< /FontSize 1e999 /Tracking nan /HorizontalScale inf /Kerning -inf /FillColor << /Values [1.0 nan 0.0 0.0] >> >>").unwrap(); - let s = char_style(None, &d, &[], 1.0); + let s = char_style(&[], &d, &[], 1.0); let dflt = CharStyle::default(); assert_eq!(s.size_pt, dflt.size_pt); assert_eq!((s.tracking, s.horizontal_scale, s.kern), (0.0, 1.0, 0.0)); assert_eq!(s.color, Color::BLACK); // A finite f64 too big for f32 is dropped as well. let d = parse(b"<< /FontSize 1e300 /StartIndent inf >> ").unwrap(); - assert_eq!(char_style(None, &d, &[], 1.0).size_pt, dflt.size_pt); - assert_eq!(para_style(None, &d, 1.0).start_indent_pt, 0.0); + assert_eq!(char_style(&[], &d, &[], 1.0).size_pt, dflt.size_pt); + assert_eq!(para_style(&[], &d, 1.0).start_indent_pt, 0.0); let d = parse(b"<< /FontSize 24.0 >>").unwrap(); - assert_eq!(char_style(None, &d, &[], 1.0).size_pt, 24.0); + assert_eq!(char_style(&[], &d, &[], 1.0).size_pt, 24.0); } /// #5645: `FontBaseline` (1 superscript, 2 subscript) is read and written back. @@ -1795,11 +1871,11 @@ mod nonfinite_tests { fn font_baseline_round_trips() { use photocraft_doc::text::FontBaseline; let d = parse(b"<< /FontBaseline 2 >>").unwrap(); - assert_eq!(char_style(None, &d, &[], 1.0).baseline, FontBaseline::Subscript); + assert_eq!(char_style(&[], &d, &[], 1.0).baseline, FontBaseline::Subscript); let d = parse(b"<< /FontBaseline 1 >>").unwrap(); - assert_eq!(char_style(None, &d, &[], 1.0).baseline, FontBaseline::Superscript); + assert_eq!(char_style(&[], &d, &[], 1.0).baseline, FontBaseline::Superscript); let s = CharStyle { baseline: FontBaseline::Subscript, ..Default::default() }; - let back = char_style(None, &style_sheet_data(&s, 0, 1.0), &[], 1.0); + let back = char_style(&[], &style_sheet_data(&s, 0, 1.0), &[], 1.0); assert_eq!(back.baseline, FontBaseline::Subscript); } diff --git a/crates/text/src/psd_styles.rs b/crates/text/src/psd_styles.rs index 6f953f1cec..b28949a22f 100644 --- a/crates/text/src/psd_styles.rs +++ b/crates/text/src/psd_styles.rs @@ -87,9 +87,9 @@ pub fn read_style_sheets(tysh: &[u8], dpi: f32) -> Option { let normal_p = res.get("TheNormalParagraphSheet").and_then(E::as_i64).and_then(sheet_index).unwrap_or(0); let empty = E::dict(); let base_data = sset.get(normal_c).and_then(|s| s.get("StyleSheetData")); - let normal_char = char_style(None, base_data.unwrap_or(&empty), &fonts, k); + let normal_char = char_style(&[], base_data.unwrap_or(&empty), &fonts, k); let base_props = pset.get(normal_p).and_then(|s| s.get("Properties")); - let normal_para = para_style(None, base_props.unwrap_or(&empty), k); + let normal_para = para_style(&[], base_props.unwrap_or(&empty), k); let mut out = PsdStyleSheets { normal_char, normal_para, ..Default::default() }; for (i, s) in sset.iter().enumerate() { if i == normal_c { @@ -97,7 +97,7 @@ pub fn read_style_sheets(tysh: &[u8], dpi: f32) -> Option { } let name = s.get("Name").and_then(E::as_str).unwrap_or("").to_string(); let data = s.get("StyleSheetData").unwrap_or(&empty); - out.char_sheets.push((i, name, char_style(base_data, data, &fonts, k))); + out.char_sheets.push((i, name, char_style(&[base_data], data, &fonts, k))); } for (i, s) in pset.iter().enumerate() { if i == normal_p { @@ -106,8 +106,8 @@ pub fn read_style_sheets(tysh: &[u8], dpi: f32) -> Option { let name = s.get("Name").and_then(E::as_str).unwrap_or("").to_string(); let props = s.get("Properties").unwrap_or(&empty); let dss = s.get("DefaultStyleSheet").and_then(E::as_i64).and_then(sheet_index).filter(|i| *i != normal_c); - let cs = dss.and_then(|d| sset.get(d)).map(|d| char_style(base_data, d.get("StyleSheetData").unwrap_or(&empty), &fonts, k)); - out.para_sheets.push((i, name, para_style(base_props, props, k), cs)); + let cs = dss.and_then(|d| sset.get(d)).map(|d| char_style(&[base_data], d.get("StyleSheetData").unwrap_or(&empty), &fonts, k)); + out.para_sheets.push((i, name, para_style(&[base_props], props, k), cs)); } let snames = names(res.get("StyleSheetSet")); let pnames = names(res.get("ParagraphSheetSet")); diff --git a/crates/text/src/render.rs b/crates/text/src/render.rs index fe32982d9d..394e340faa 100644 --- a/crates/text/src/render.rs +++ b/crates/text/src/render.rs @@ -21,6 +21,8 @@ pub const FAUX_ITALIC_DEG: f32 = 12.0; pub const FAUX_BOLD_RADIUS: f32 = 0.036; /// Largest raster we produce (pixels), as a guard against absurd sizes. const MAX_PIXELS: u64 = 256 * 1024 * 1024; +/// Cumulative coverage-pixel work budget across all colour and faux-bold passes of one render. +const MAX_PASS_PIXELS: u64 = MAX_PIXELS * 4; /// Peak working bytes one rasterization may keep live. [`MAX_PIXELS`] does not bound them: the /// accumulator and the output surface are both `stride` deep, and a faux-bold pass keeps one more /// coverage buffer live, so a CMYK bold render reserves several times what a plain grey one does @@ -135,6 +137,54 @@ fn is_bold(layout: &TextLayout, g: &PlacedGlyph) -> bool { layout.styles.get(g.style as usize).is_some_and(|s| s.faux_bold) || face_bold } +fn glyph_is_placed(layout: &TextLayout, g: &PlacedGlyph, warp: Option<&Warp>) -> bool { + let Some(face) = layout.faces.get(g.face as usize) else { + return false; + }; + let Ok(font) = skrifa::FontRef::from_index(face.font.data.as_ref(), face.font.index) else { + return false; + }; + if font.outline_glyphs().get(GlyphId::new(g.id)).is_none() { + return false; + } + if let Some(w) = warp { + let coords: Vec = face.coords.iter().map(|&c| NormalizedCoord::from_bits(c)).collect(); + if matches!(w.place_glyph(glyph_center(layout, g, &font, face.size_px, &coords)), Placement::Hidden) { + return false; + } + } + true +} + +/// Distinct visible colours (`alpha > 0.0`) used by placed glyphs or decorations, each paired with +/// whether any placed glyph of that colour uses faux/synthetic bold. +fn visible_color_passes(layout: &TextLayout, warp: Option<&Warp>) -> Vec<(Color, bool)> { + let mut colors: Vec<(Color, bool)> = Vec::new(); + let mut record = |c: Color, bold: bool| { + if !(c.alpha.is_finite() && c.alpha > 0.0) { + return; + } + match colors.iter_mut().find(|(existing, _)| *existing == c) { + Some((_, b)) => *b |= bold, + None => colors.push((c, bold)), + } + }; + for g in &layout.glyphs { + let Some(st) = layout.styles.get(g.style as usize) else { continue }; + if !(st.color.alpha.is_finite() && st.color.alpha > 0.0) { + continue; + } + if glyph_is_placed(layout, g, warp) { + record(st.color, is_bold(layout, g)); + } + } + for d in &layout.decorations { + let Some(st) = layout.styles.get(d.style as usize) else { continue }; + record(st.color, false); + } + colors +} + fn draw_pass(layout: &TextLayout, transform: &Xform, sink: &mut impl LineSink, only_color: Option<&Color>, warp: Option<&Warp>, pass: Pass) { for g in &layout.glyphs { let st = &layout.styles[g.style as usize]; @@ -302,6 +352,19 @@ pub fn rasterize(layout: &TextLayout, transform: &Affine, format: PixelFormat, a /// [`rasterize`] with the glyph outlines bent by `warp` (Type › Warp Text). pub fn rasterize_warped(layout: &TextLayout, transform: &Affine, format: PixelFormat, antialias: AntiAlias, warp: Option<&Warp>) -> Rendered { + rasterize_passes(layout, transform, format, antialias, warp, true) +} + +/// [`rasterize_warped`] with every colour's coverage either confined to that colour's own ink +/// (`per_colour`) or spanning the whole text, the oracle the tests compare it with (#8602). +pub(crate) fn rasterize_passes( + layout: &TextLayout, + transform: &Affine, + format: PixelFormat, + antialias: AntiAlias, + warp: Option<&Warp>, + per_colour: bool, +) -> Rendered { let format = PixelFormat { alpha: true, ..format }; let rect = ink_rect_warped(layout, transform, warp); let (w, h) = (rect.width() as usize, rect.height() as usize); @@ -309,10 +372,14 @@ pub fn rasterize_warped(layout: &TextLayout, transform: &Affine, format: PixelFo if w == 0 || h == 0 || (w as u64) * (h as u64) > MAX_PIXELS { return Rendered { surface, rect: Rect::new(0, 0, 0, 0) }; } + let colors = visible_color_passes(layout, warp); + if colors.is_empty() { + return Rendered { surface, rect: Rect::new(0, 0, 0, 0) }; + } let n = format.mode.color_channels(); let stride = n + 1; - let bold = layout.glyphs.iter().any(|g| is_bold(layout, g)); - if !fits_raster_budget(w, h, stride, format.sample, bold, MAX_RASTER_BYTES) { + let any_bold = colors.iter().any(|&(_, b)| b); + if !fits_raster_budget(w, h, stride, format.sample, any_bold, MAX_RASTER_BYTES) { return nothing(format); } // Premultiplied accumulation. @@ -320,26 +387,45 @@ pub fn rasterize_warped(layout: &TextLayout, transform: &Affine, format: PixelFo return nothing(format); }; let xf = Xform([1.0, 0.0, 0.0, 1.0, -rect.x0 as f64, -rect.y0 as f64]).mul(&Xform(transform.m)); - let mut colors: Vec = Vec::new(); - for st in &layout.styles { - if !colors.contains(&st.color) { - colors.push(st.color); + let whole = Rect::new(0, 0, w as i32, h as i32); + let mut pass_pixels = 0u64; + for &(c, color_bold) in &colors { + // Rasterise each colour only over its own glyphs (every offset of faux bold included): + // a full-size pass per colour made text with many coloured runs cost colours x area + // (#8602). Outside its ink a colour's coverage is zero, so the result is the same. + let sub = if per_colour { + let mut b = Bounds::default(); + draw_pass(layout, &xf, &mut b, Some(&c), warp, Pass::All); + b.rect.map(rect_from_bounds).unwrap_or(Rect::EMPTY).intersect(&whole) + } else { + whole + }; + if sub.is_empty() { + continue; } - } - for c in &colors { - let Some(mut cov) = Coverage::try_new(w, h) else { + let (sw, sh) = (sub.width() as usize, sub.height() as usize); + let passes = 1 + if color_bold { BOLD_OFFSETS.len() } else { 0 }; + let Some(cost) = (sw as u64).checked_mul(sh as u64).and_then(|px| px.checked_mul(passes as u64)) else { return nothing(format); }; - draw_pass(layout, &xf, &mut cov, Some(c), warp, Pass::Plain); + let Some(total) = pass_pixels.checked_add(cost).filter(|&t| t <= MAX_PASS_PIXELS) else { + return nothing(format); + }; + pass_pixels = total; + let xf = Xform([1.0, 0.0, 0.0, 1.0, -sub.x0 as f64, -sub.y0 as f64]).mul(&xf); + let Some(mut cov) = Coverage::try_new(sw, sh) else { + return nothing(format); + }; + draw_pass(layout, &xf, &mut cov, Some(&c), warp, Pass::Plain); let Some(mut cov) = cov.finish() else { return nothing(format); }; - if bold { + if color_bold { for k in 0..BOLD_OFFSETS.len() { - let Some(mut bc) = Coverage::try_new(w, h) else { + let Some(mut bc) = Coverage::try_new(sw, sh) else { return nothing(format); }; - draw_pass(layout, &xf, &mut bc, Some(c), warp, Pass::Bold(k)); + draw_pass(layout, &xf, &mut bc, Some(&c), warp, Pass::Bold(k)); let Some(bold_cov) = bc.finish() else { return nothing(format); }; @@ -348,9 +434,9 @@ pub fn rasterize_warped(layout: &TextLayout, transform: &Affine, format: PixelFo } } } - let comps = color_in(&format, c); + let comps = color_in(&format, &c); let a = c.alpha.clamp(0.0, 1.0); - composite(&mut acc, &cov, w, stride, &comps, a, antialias); + composite(&mut acc, w, sub, &cov, stride, &comps, a, antialias); } // Unpremultiply. unpremultiply(&mut acc, n, stride, format.sample); @@ -359,12 +445,19 @@ pub fn rasterize_warped(layout: &TextLayout, transform: &Affine, format: PixelFo Rendered { surface, rect } } -/// Composites one colour's coverage over the premultiplied accumulator, after [`shape_antialias_coverage`]. -/// A pixel's maths reads and writes only its own slots, so the rows are done in any -/// order — one per thread — for exactly the same bytes. -fn composite(acc: &mut [f32], cov: &[f32], width: usize, stride: usize, comps: &[f32], a: f32, antialias: AntiAlias) { +/// Composites one colour's coverage, covering `sub` of a `width`-pixel-wide premultiplied +/// accumulator, after [`shape_antialias_coverage`]. A pixel's maths reads and writes only its own +/// slots, so the rows are done in any order — one per thread — for exactly the same bytes. +#[allow(clippy::too_many_arguments)] +fn composite(acc: &mut [f32], width: usize, sub: Rect, cov: &[f32], stride: usize, comps: &[f32], a: f32, antialias: AntiAlias) { let n = comps.len(); + let (x0, x1) = (sub.x0.max(0) as usize * stride, sub.x1.max(0) as usize * stride); + let (y0, sw, sh) = (sub.y0.max(0) as usize, sub.width() as usize, sub.height() as usize); + if sw == 0 || sh == 0 { + return; + } let row = |acc: &mut [f32], cov: &[f32]| { + let Some(acc) = acc.get_mut(x0..x1) else { return }; for (cv, px) in cov.iter().zip(acc.chunks_exact_mut(stride)) { let cv = shape_antialias_coverage(*cv, antialias); let s = cv * a; @@ -379,9 +472,9 @@ fn composite(acc: &mut [f32], cov: &[f32], width: usize, stride: usize, comps: & } }; #[cfg(not(target_arch = "wasm32"))] - acc.par_chunks_mut(width * stride).zip(cov.par_chunks(width)).for_each(|(a, c)| row(a, c)); + acc.par_chunks_mut(width * stride).skip(y0).take(sh).zip(cov.par_chunks(sw)).for_each(|(a, c)| row(a, c)); #[cfg(target_arch = "wasm32")] - acc.chunks_mut(width * stride).zip(cov.chunks(width)).for_each(|(a, c)| row(a, c)); + acc.chunks_mut(width * stride).skip(y0).take(sh).zip(cov.chunks(sw)).for_each(|(a, c)| row(a, c)); } /// Divides the premultiplied accumulator back to straight colour. Each pixel stands alone. diff --git a/crates/text/src/served.rs b/crates/text/src/served.rs index 0198aea97a..69897329b7 100644 --- a/crates/text/src/served.rs +++ b/crates/text/src/served.rs @@ -40,25 +40,60 @@ pub struct ServedFont { pub fn parse_manifest(text: &str) -> (Vec, Vec) { let mut fonts = Vec::new(); let mut skipped = Vec::new(); - for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) { - let f: Vec<&str> = line.split(" | ").map(str::trim).collect(); + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + let raw_fields: Vec<&str> = trimmed.split(" | ").collect(); + let f: Vec<&str> = raw_fields.iter().map(|s| s.trim()).collect(); + let raw_file = raw_fields.get(2).copied().unwrap_or(""); + let valid_file = raw_file == f.get(2).copied().unwrap_or("") && is_relative_path(raw_file); match f.as_slice() { - [family, _style, file, rest @ ..] if !family.is_empty() && is_relative_path(file) => { + [family, _style, file, rest @ ..] if !family.is_empty() && valid_file => { let scripts = rest.first().map(|s| s.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect()).unwrap_or_default(); let integrity = rest.get(3).and_then(|sha| sri(sha)); fonts.push(ServedFont { family: (*family).to_string(), file: (*file).to_string(), scripts, integrity }); } - [_, _, file, ..] if !is_relative_path(file) => skipped.push(format!("{line}: the file must be a relative path inside the site")), - _ => skipped.push(format!("{line}: expected `family | style | file | …`")), + [_, _, _, ..] if !valid_file => skipped.push(format!("{trimmed}: the file must be a relative path inside the site")), + _ => skipped.push(format!("{trimmed}: expected `family | style | file | …`")), } } (fonts, skipped) } /// A relative path that stays inside the site: no scheme, no leading `/`, no `..` segment, no -/// query or fragment. -fn is_relative_path(p: &str) -> bool { - !p.is_empty() && !p.starts_with('/') && !p.contains(['\\', '?', '#', ':']) && p.split('/').all(|seg| !seg.is_empty() && seg != "." && seg != "..") +/// query or fragment, no ASCII control characters or whitespace, and no percent-encoded control, +/// whitespace or separator characters. +pub fn is_relative_path(p: &str) -> bool { + if p.is_empty() + || p.starts_with('/') + || p.bytes().any(|b| b <= b' ' || b == 0x7f || matches!(b, b'\\' | b'?' | b'#' | b':')) + || !p.split('/').all(|seg| !seg.is_empty() && seg != "." && seg != "..") + { + return false; + } + let b = p.as_bytes(); + let mut decoded = Vec::with_capacity(b.len()); + let mut i = 0; + while i < b.len() { + if b[i] == b'%' { + let Some(hex) = p.get(i + 1..i + 3) else { return false }; + let Ok(byte) = u8::from_str_radix(hex, 16) else { return false }; + // Never allow encoded path syntax (including '%' for a second decode), ASCII + // separators, or controls. High bytes are allowed only as valid UTF-8 below. + if byte <= b' ' || byte == 0x7f || matches!(byte, b'%' | b'/' | b'\\' | b'.' | b'?' | b'#' | b':') { + return false; + } + decoded.push(byte); + i = i.saturating_add(2); + } else { + let Some(&byte) = b.get(i) else { return false }; + decoded.push(byte); + } + i = i.saturating_add(1); + } + std::str::from_utf8(&decoded).is_ok() } /// The scripts a served font can be the fallback for: those the bundled fonts (Inter, which covers @@ -318,8 +353,31 @@ mod tests { assert_eq!(skipped.len(), 9, "{skipped:?}"); } - /// One test for the whole queue: the catalog and the queue are process-wide, and parallel - /// tests taking each other's requests would be flaky. + #[test] + fn rejects_encoded_path_separators_and_malformed_escapes() { + for path in [ + "fonts/%2e%2e/private.ttf", + "fonts%2fprivate.ttf", + "fonts%5cprivate.ttf", + "fonts/%00.ttf", + "fonts/a%7F.ttf", + "fonts/%20.ttf", + "fonts/%zz.ttf", + "fonts/%2.ttf", + "fonts/%C0%AF.ttf", + "fonts/%25%32%65%25%32%65/private.ttf", + "fonts/%C3%28.ttf", + ] { + assert!(!is_relative_path(path), "accepted {path}"); + } + for path in ["fonts/OpenSans[wdth,wght].ttf", "fonts/é.ttf", "fonts/%C3%A9.ttf"] { + assert!(is_relative_path(path), "rejected {path}"); + } + let (fonts, skipped) = parse_manifest("A | Regular | fonts/%2e%2e/a.ttf\nB | Regular | fonts/%zz.ttf\n"); + assert!(fonts.is_empty()); + assert_eq!(skipped.len(), 2); + } + #[test] fn requests_are_limited_to_the_catalog_and_reach_layout() { let served = "Served Test Sans"; diff --git a/crates/ui-egui/src/served_fonts.rs b/crates/ui-egui/src/served_fonts.rs index 6273383c75..b7ae085f41 100644 --- a/crates/ui-egui/src/served_fonts.rs +++ b/crates/ui-egui/src/served_fonts.rs @@ -5,7 +5,7 @@ use std::sync::{Mutex, PoisonError}; use photocraft_doc::{DocId, LayerContent, LayerId, TextLayer}; -pub use photocraft_text::served::{ServedFont, add_families, add_fonts, parse_manifest, request_for_languages, take_requests}; +pub use photocraft_text::served::{ServedFont, add_families, add_fonts, is_relative_path, parse_manifest, request_for_languages, take_requests}; use crate::PhotocraftApp; From 92262d595030e75f70be32631b51dbe00c1d1eb9 Mon Sep 17 00:00:00 2001 From: jturnbach <70334515+jturnbach@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:54:42 -0400 Subject: [PATCH 27/53] fix(cms): confirm ICC profile identity by bytes, not the 64-bit hash alone (#8863) Profile::content_hash is a fixed-key 64-bit SipHash of the ICC bytes, so a crafted profile can share another profile's hash (about 2^32 work). Two places took an equal hash as proof of an identical profile: - transform::cached keyed its process-wide cache on the two hashes and the options, so a colliding profile was handed the other profile's cached transform and converted with the wrong colours. - Profile::same_colors returned true on equal hashes, so a colliding profile was taken for the other one and a needed conversion was skipped. Add Profile::same_bytes, which compares the ICC bytes (an Arc pointer check first, then the hash, then the bytes). same_colors uses it instead of the hash. Each transform cache entry now keeps the ICC bytes of its two profiles; a hit counts only when they match, and a colliding pair gets its own transform without replacing the cached entry. The transform is built after the cache lock is released, as before. The regression tests give Display P3 the content hash of sRGB (a stand-in for a found collision): before the fix, same_colors called them the same and the cache returned sRGB's transform for P3; now both see P3's colours. Fixes #8613 Co-authored-by: John Turnbach Co-authored-by: Claude Opus 5.5 --- crates/cms/README.md | 3 +- crates/cms/src/profile.rs | 59 +++++++++++++++++++++++++++++++++++-- crates/cms/src/transform.rs | 23 +++++++++++---- 3 files changed, 77 insertions(+), 8 deletions(-) diff --git a/crates/cms/README.md b/crates/cms/README.md index 9109f48ed2..b67eb6e8a8 100644 --- a/crates/cms/README.md +++ b/crates/cms/README.md @@ -27,7 +27,8 @@ t.eval(&[1.0, 0.0, 0.0], &mut out); // exact single colou * `Profile::to_bytes()`: original bytes of parsed profiles (byte-exact), or an ICC v4.3 encoding. * `Profile::same_colors(&other)`: the same colour space whatever the encoding (every sample of a device grid within ΔE 0.5 through both), e.g. Photoshop's sRGB IEC61966-2.1 and the built-in - sRGB. `content_hash()` compares bytes and suits cache keys only. + sRGB. `same_bytes(&other)` compares the ICC bytes. `content_hash()` is a 64-bit hash of them + that another profile can share: a cache key, confirmed with `same_bytes`. ### Evaluation diff --git a/crates/cms/src/profile.rs b/crates/cms/src/profile.rs index cdd7d5273a..99284772f3 100644 --- a/crates/cms/src/profile.rs +++ b/crates/cms/src/profile.rs @@ -211,7 +211,9 @@ impl Profile { self.color_space.channels() } - /// Hash of the profile's ICC bytes (stable within a process; used as a cache key). + /// Hash of the profile's ICC bytes (stable within a process; used as a cache key). It is a + /// fixed-key 64-bit hash, so a crafted profile can share another's: equal hashes only narrow + /// the search, [`Profile::same_bytes`] decides. pub fn content_hash(&self) -> u64 { *self.hash.get_or_init(|| { use std::hash::{Hash, Hasher}; @@ -221,6 +223,16 @@ impl Profile { }) } + /// Whether `self` and `other` have the same ICC bytes. + pub fn same_bytes(&self, other: &Profile) -> bool { + if let (Some(a), Some(b)) = (&self.bytes, &other.bytes) + && Arc::ptr_eq(a, b) + { + return true; + } + self.content_hash() == other.content_hash() && self.to_bytes() == other.to_bytes() + } + /// Whether `self` and `other` describe the same colours: same colour space, and every sample /// of a device-value grid has the same colour through both (relative colorimetric, within /// [`SAME_COLORS_MAX_DELTA_E`]). Different encodings of one space are the same colours: the @@ -228,7 +240,7 @@ impl Profile { /// parametric curves) differ in every byte. Colours are compared rather than round-tripped /// because CMYK → PCS → CMYK is not an identity even through a single profile. pub fn same_colors(&self, other: &Profile) -> bool { - if self.content_hash() == other.content_hash() { + if self.same_bytes(other) { return true; } let n = self.channels(); @@ -818,6 +830,7 @@ fn parse_text(d: &[u8]) -> String { #[cfg(test)] mod tests { use super::*; + use crate::builtin::Builtin; fn be(v: u32) -> [u8; 4] { v.to_be_bytes() @@ -903,4 +916,46 @@ mod tests { d.extend(be(u32::MAX)); assert_eq!(parse_text(&d), ""); } + + /// `p` with the content hash of `other`: the hash is a fixed-key 64-bit SipHash, so a + /// crafted profile can have another's (#8613). Setting it stands in for finding one. + fn with_hash_of(p: &Profile, other: &Profile) -> Profile { + let p = Profile { hash: std::sync::OnceLock::from(other.content_hash()), ..p.clone() }; + assert_eq!(p.content_hash(), other.content_hash()); + p + } + + /// #8613: a profile that shares sRGB's hash is not taken for sRGB. + #[test] + fn a_hash_collision_is_not_the_same_colours() { + let srgb = Builtin::Srgb.profile(); + let p3 = with_hash_of(Builtin::DisplayP3.profile(), srgb); + assert!(!p3.same_bytes(srgb)); + assert!(!p3.same_colors(srgb)); + // Controls: equal bytes are the same, whatever object holds them. + assert!(srgb.same_bytes(srgb)); + assert!(srgb.same_bytes(&Profile::parse(&srgb.to_bytes()).unwrap())); + assert!(Builtin::DisplayP3.profile().same_colors(Builtin::DisplayP3.profile())); + } + + /// #8613: the transform cache does not hand a colliding profile the other's transform. + #[test] + fn a_hash_collision_does_not_get_the_cached_transform() { + use crate::transform::{Transform, TransformOptions, cached}; + let (srgb, lab) = (Builtin::Srgb.profile(), Builtin::LabD50.profile()); + let p3 = with_hash_of(Builtin::DisplayP3.profile(), srgb); + let opts = TransformOptions::default(); + let green = [0.0, 1.0, 0.0]; + let eval = |t: &Transform| { + let mut out = [0f32; 3]; + t.eval(&green, &mut out); + out + }; + let want = eval(&Transform::with_options(Builtin::DisplayP3.profile(), lab, opts).unwrap()); + assert_ne!(want, eval(&Transform::with_options(srgb, lab, opts).unwrap()), "P3 green is not sRGB green"); + let sr = eval(&cached(srgb, lab, opts).unwrap()); + assert_eq!(eval(&cached(&p3, lab, opts).unwrap()), want); + // Control: sRGB still gets its own transform after the colliding lookup. + assert_eq!(eval(&cached(srgb, lab, opts).unwrap()), sr); + } } diff --git a/crates/cms/src/transform.rs b/crates/cms/src/transform.rs index f30c17c82e..3ce59687a7 100644 --- a/crates/cms/src/transform.rs +++ b/crates/cms/src/transform.rs @@ -703,20 +703,33 @@ pub fn black_point(p: &Profile, intent: Intent, _input: bool) -> Result>, + dst: Arc>, + transform: Arc, +} + +/// Process-wide cache of transforms keyed by profile content and options. The key is the +/// profiles' 64-bit hashes, which a crafted profile can share with another (#8613), so a hit +/// counts only when the bytes match too; a colliding pair gets its own, uncached transform. pub fn cached(src: &Profile, dst: &Profile, opts: TransformOptions) -> Result, CmsError> { - static CACHE: OnceLock>>> = OnceLock::new(); + static CACHE: OnceLock>> = OnceLock::new(); let key = (src.content_hash(), dst.content_hash(), opts); + let (src_bytes, dst_bytes) = (src.to_bytes(), dst.to_bytes()); let cache = CACHE.get_or_init(Default::default); - if let Some(t) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&key) { - return Ok(t.clone()); + let hit = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&key).map(|e| (e.src == src_bytes && e.dst == dst_bytes).then(|| e.transform.clone())); + match hit { + Some(Some(t)) => return Ok(t), + Some(None) => return Ok(Arc::new(Transform::with_options(src, dst, opts)?)), + None => {} } let t = Arc::new(Transform::with_options(src, dst, opts)?); let mut c = cache.lock().unwrap_or_else(|e| e.into_inner()); if c.len() > 64 { c.clear(); } - c.insert(key, t.clone()); + c.insert(key, Entry { src: src_bytes, dst: dst_bytes, transform: t.clone() }); Ok(t) } From f346b5c8f4cccc349c74cb1bcfe54e0596096feb Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 09:56:08 -0400 Subject: [PATCH 28/53] fix(vector): bound hostile geometry and raster work (#8769) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(vector): bound hostile geometry raster work Guard geometry and tracing operations against malformed dimensions and degenerate inputs with bounded work and safe failures. 🤖 Generated with Codebuff Co-Authored-By: Codebuff * fix(vector): address review (knot cap on split, relative identity tol, non-vacuous test) * revert(geom): splitting past the quilt limit is intentional (smart_map test) * fix(geom): cap identity tolerance at 1e-6 px --------- Co-authored-by: Codebuff --- crates/geom/src/warp.rs | 102 ++++++++++++++++++++++++++++++------ crates/vector/src/lib.rs | 25 ++++++--- crates/vector/src/raster.rs | 70 +++++++++++++++++++++++-- crates/vector/src/shapes.rs | 2 +- crates/vector/src/tests.rs | 13 +++++ crates/vector/src/trace.rs | 90 ++++++++++++++++++++++++++++--- 6 files changed, 266 insertions(+), 36 deletions(-) diff --git a/crates/geom/src/warp.rs b/crates/geom/src/warp.rs index 8ba2bdd043..4e10ed8c08 100644 --- a/crates/geom/src/warp.rs +++ b/crates/geom/src/warp.rs @@ -474,38 +474,50 @@ impl BezierMesh { /// Control points per row. pub fn nx(&self) -> usize { - 3 * (self.us.len() - 1) + 1 + 3usize.saturating_mul(self.us.len().saturating_sub(1)).saturating_add(1) } /// Control point rows. pub fn ny(&self) -> usize { - 3 * (self.vs.len() - 1) + 1 + 3usize.saturating_mul(self.vs.len().saturating_sub(1)).saturating_add(1) } pub fn point(&self, i: usize, j: usize) -> [f64; 2] { - self.points[j * self.nx() + i] + self.points.get(j.saturating_mul(self.nx()).saturating_add(i)).copied().unwrap_or([0.0; 2]) } /// Checks the invariants (knots and point count); malformed data from files is rejected. pub fn is_valid(&self) -> bool { - let knots_ok = |k: &[f64]| k.len() >= 2 && k[0] == 0.0 && k[k.len() - 1] == 1.0 && k.windows(2).all(|w| w[1] > w[0]); - knots_ok(&self.us) - && knots_ok(&self.vs) - && self.points.len() == self.nx() * self.ny() - && self.points.iter().all(|p| p[0].is_finite() && p[1].is_finite()) + let knots_ok = |k: &[f64]| (2..=65).contains(&k.len()) && k[0] == 0.0 && k[k.len() - 1] == 1.0 && k.windows(2).all(|w| w[1] > w[0]); + if !knots_ok(&self.us) || !knots_ok(&self.vs) { + return false; + } + let Some(nx) = (self.us.len() - 1).checked_mul(3).and_then(|v| v.checked_add(1)) else { + return false; + }; + let Some(ny) = (self.vs.len() - 1).checked_mul(3).and_then(|v| v.checked_add(1)) else { + return false; + }; + let Some(expected) = nx.checked_mul(ny) else { + return false; + }; + self.points.len() == expected && self.points.iter().all(|p| p[0].is_finite() && p[1].is_finite()) } fn locate(knots: &[f64], s: f64) -> (usize, f64) { - let n = knots.len() - 1; - let s = s.clamp(0.0, 1.0); - let mut i = 0; - while i + 1 < n && s > knots[i + 1] { - i += 1; + if knots.len() < 2 { + return (0, 0.0); } + let n = knots.len() - 1; + let s = if s.is_finite() { s.clamp(0.0, 1.0) } else { 0.0 }; + let i = knots[1..n].partition_point(|&k| k < s); let w = knots[i + 1] - knots[i]; (i, if w > 0.0 { (s - knots[i]) / w } else { 0.0 }) } /// Evaluates the surface at normalised `(s, t)`. pub fn eval(&self, s: f64, t: f64) -> [f64; 2] { + if self.us.len() < 2 || self.vs.len() < 2 { + return [0.0; 2]; + } let (pi, a) = Self::locate(&self.us, s); let (pj, b) = Self::locate(&self.vs, t); let (ba, bb) = (bernstein(a), bernstein(b)); @@ -513,7 +525,10 @@ impl BezierMesh { let mut out = [0.0; 2]; for (j, wb) in bb.iter().enumerate() { for (i, wa) in ba.iter().enumerate() { - let p = self.points[(3 * pj + j) * nx + 3 * pi + i]; + let idx = (3 * pj + j).saturating_mul(nx).saturating_add(3 * pi + i); + let Some(&p) = self.points.get(idx) else { + return [0.0; 2]; + }; let k = wa * wb; out[0] += p[0] * k; out[1] += p[1] * k; @@ -949,8 +964,29 @@ impl Warp { WarpStyle::Custom => match &self.mesh { None => true, Some(m) => { - let id = BezierMesh::fit(&|s, t| self.box_point(s, t), m.us.clone(), m.vs.clone()); - m.points.iter().zip(&id.points).all(|(a, b)| (a[0] - b[0]).abs() < 1e-9 && (a[1] - b[1]).abs() < 1e-9) + if !m.is_valid() { + return false; + } + let nx = m.nx(); + let (pc, pr) = (m.us.len() - 1, m.vs.len() - 1); + for pj in 0..pr { + for j in 0..4 { + let v = m.vs[pj] + (m.vs[pj + 1] - m.vs[pj]) * j as f64 / 3.0; + for pi in 0..pc { + for i in 0..4 { + let u = m.us[pi] + (m.us[pi + 1] - m.us[pi]) * i as f64 / 3.0; + let expected = self.box_point(u, v); + let actual = m.points[(3 * pj + j) * nx + 3 * pi + i]; + // Relative to the magnitude so the fit's rounding at large origins isn't rejected. + let tol = |e: f64| (1e-9 * e.abs().max(1.0)).min(1e-6); + if (actual[0] - expected[0]).abs() >= tol(expected[0]) || (actual[1] - expected[1]).abs() >= tol(expected[1]) { + return false; + } + } + } + } + } + true } }, _ => self.style_warp().is_none(), @@ -1384,4 +1420,38 @@ mod tests { let ob = Warp::none(B).output_bounds(); assert!((ob[0] - 10.0).abs() < 1e-9 && (ob[3] - 70.0).abs() < 1e-9); } + + #[test] + fn is_identity_rejects_malformed_custom_mesh_without_allocating() { + let us: Vec = (0..=1000).map(|i| i as f64 / 1000.0).collect(); + let vs: Vec = (0..=1000).map(|i| i as f64 / 1000.0).collect(); + let malformed = BezierMesh { us, vs, points: Vec::new() }; + assert!(!malformed.is_valid()); + let w = Warp::custom(malformed.clone(), B); + assert!(!w.is_identity()); + assert_eq!(malformed.eval(0.5, 0.5), [0.0, 0.0]); + } + + #[test] + fn locate_binary_search_matches_linear_scan() { + let knots = [0.0, 0.1, 0.25, 0.5, 0.85, 1.0]; + let linear = |s: f64| { + let n = knots.len() - 1; + let s = if s.is_finite() { s.clamp(0.0, 1.0) } else { 0.0 }; + let mut i = 0; + while i + 1 < n && s > knots[i + 1] { + i += 1; + } + let w = knots[i + 1] - knots[i]; + (i, if w > 0.0 { (s - knots[i]) / w } else { 0.0 }) + }; + for k in -10..=110 { + let s = k as f64 / 100.0; + assert_eq!(BezierMesh::locate(&knots, s), linear(s), "s={s}"); + } + for &s in &knots { + assert_eq!(BezierMesh::locate(&knots, s), linear(s), "knot={s}"); + } + assert_eq!(BezierMesh::locate(&knots, f64::NAN), linear(f64::NAN)); + } } diff --git a/crates/vector/src/lib.rs b/crates/vector/src/lib.rs index ac5fe8aad5..2492414d2a 100644 --- a/crates/vector/src/lib.rs +++ b/crates/vector/src/lib.rs @@ -156,11 +156,14 @@ fn tile_bands(r: Rect) -> Vec { if r.is_empty() { return out; } - let mut y = r.y0.div_euclid(TILE_SIZE) * TILE_SIZE; - while y < r.y1 { - let band = Rect::new(r.x0, y.max(r.y0), r.x1, (y + TILE_SIZE).min(r.y1)); + let step = i64::from(TILE_SIZE); + let (y0, y1) = (i64::from(r.y0), i64::from(r.y1)); + let mut y = y0.div_euclid(step) * step; + while y < y1 { + let next = (y + step).min(y1); + let band = Rect::new(r.x0, y.max(y0) as i32, r.x1, next as i32); out.push(band); - y += TILE_SIZE; + y = next; } out } @@ -170,9 +173,15 @@ fn tile_bands(r: Rect) -> Vec { fn band_tiles(band: Rect, data: &[f32], channels: usize, keep: impl Fn(&[f32]) -> bool) -> Vec<(Rect, Vec)> { let w = band.width() as usize; let mut out = Vec::new(); - let mut x = band.x0.div_euclid(TILE_SIZE) * TILE_SIZE; - while x < band.x1 { - let tr = Rect::new(x.max(band.x0), band.y0, (x + TILE_SIZE).min(band.x1), band.y1); + if band.is_empty() { + return out; + } + let step = i64::from(TILE_SIZE); + let (x0, x1) = (i64::from(band.x0), i64::from(band.x1)); + let mut x = x0.div_euclid(step) * step; + while x < x1 { + let next = (x + step).min(x1); + let tr = Rect::new(x.max(x0) as i32, band.y0, next as i32, band.y1); let tw = tr.width() as usize; let mut buf = Vec::with_capacity(tw * tr.height() as usize * channels); let mut any = false; @@ -187,7 +196,7 @@ fn band_tiles(band: Rect, data: &[f32], channels: usize, keep: impl Fn(&[f32]) - if any { out.push((tr, buf)); } - x += TILE_SIZE; + x = next; } out } diff --git a/crates/vector/src/raster.rs b/crates/vector/src/raster.rs index eb6c4a943b..1b0d5fd49b 100644 --- a/crates/vector/src/raster.rs +++ b/crates/vector/src/raster.rs @@ -19,6 +19,11 @@ use photocraft_geom::Rect; /// a 16-bit step (7.6e-6), so 8- and 16-bit output is unchanged by dropping it. const RESIDUE: f32 = 1e-6; +/// Maximum sub-band cuts within a single pixel row before adjacent cuts are coalesced, and +/// cumulative edge-evaluation budget per row to prevent quadratic work on degenerate inputs. +const MAX_ROW_CUTS: usize = 256; +const MAX_ROW_EDGE_WORK: usize = 262_144; + #[derive(Clone, Copy, Debug)] struct Edge { x0: f64, @@ -235,9 +240,36 @@ impl Rasterizer { } st.cuts.sort_by(f64::total_cmp); st.cuts.dedup(); + if st.cuts.len() > MAX_ROW_CUTS { + let step = (st.cuts.len() - 1).div_ceil(MAX_ROW_CUTS - 1); + let last = st.cuts[st.cuts.len() - 1]; + let mut thinned = Vec::with_capacity(MAX_ROW_CUTS); + for (idx, &c) in st.cuts.iter().enumerate() { + if idx % step == 0 { + thinned.push(c); + } + } + if thinned.last().copied() != Some(last) { + thinned.push(last); + } + st.cuts = thinned; + } + st.active.sort_unstable_by(|&a, &b| self.edges[a].y0.total_cmp(&self.edges[b].y0)); + st.sub_active.clear(); + st.work = 0; + let mut next_sub = 0usize; let cuts = std::mem::take(&mut st.cuts); for c in cuts.windows(2) { - self.band(c[0], c[1], rect.x0, &mut st, 0); + let (a, b) = (c[0], c[1]); + while next_sub < st.active.len() && self.edges[st.active[next_sub]].y0 < b { + let ei = st.active[next_sub]; + if self.edges[ei].y1 > a { + st.sub_active.push(ei); + } + next_sub += 1; + } + st.sub_active.retain(|&ei| self.edges[ei].y1 > a); + self.band(a, b, rect.x0, &mut st, 0); } st.cuts = cuts; let row_out = &mut out[row * w..(row + 1) * w]; @@ -259,13 +291,14 @@ impl Rasterizer { } st.band.clear(); let mid = 0.5 * (a + b); - for &i in &st.active { + for &i in &st.sub_active { let e = &self.edges[i]; if e.y0 <= mid && e.y1 >= mid { let (xa, xb) = (e.x_at(a), e.x_at(b)); st.band.push(BandEdge { xa, xb, xm: 0.5 * (xa + xb), idx: i as u32 }); } } + st.work = st.work.saturating_add(st.band.len()); if st.band.is_empty() { if self.inverted { add_full(&mut st.acc, b - a); @@ -275,7 +308,7 @@ impl Rasterizer { st.band.sort_by(|p, q| p.xm.total_cmp(&q.xm)); // An inversion of the order at the band's top or bottom means two edges cross inside // it: split there so the order is constant in every piece. - if depth < 24 { + if depth < 24 && st.work < MAX_ROW_EDGE_WORK { for k in 1..st.band.len() { let (p, q) = (st.band[k - 1], st.band[k]); let top = p.xa - q.xa; @@ -330,16 +363,26 @@ struct BandEdge { struct RowState { active: Vec, + sub_active: Vec, cuts: Vec, band: Vec, wind: Vec, /// Cell accumulator: `width + 1` cells (the extra one absorbs spill-over). acc: Vec, + work: usize, } impl RowState { fn new(comps: usize, width: usize) -> Self { - RowState { active: Vec::new(), cuts: Vec::new(), band: Vec::new(), wind: vec![0; comps.max(1)], acc: vec![0.0; width + 1] } + RowState { + active: Vec::new(), + sub_active: Vec::new(), + cuts: Vec::new(), + band: Vec::new(), + wind: vec![0; comps.max(1)], + acc: vec![0.0; width + 1], + work: 0, + } } } @@ -519,4 +562,23 @@ mod tests { assert_eq!(r.pixel_bounds(), Some(Rect::new(1, 2, 5, 6))); } + + #[test] + fn many_short_segments_in_single_row_complete_quickly() { + let mut r = Rasterizer::new(false); + let n = 4000; + let mut poly = Vec::with_capacity(n * 2); + for i in 0..n { + let y = 0.01 + 0.98 * (i as f64 / n as f64); + let x = if i % 2 == 0 { 1.0 } else { 3.0 }; + poly.push((x, y)); + } + poly.push((5.0, 0.99)); + poly.push((5.0, 0.01)); + r.add_component(&[poly], PathOp::Combine, FillRule::NonZero); + + let cov = r.render(Rect::new(0, 0, 8, 2)); + assert_eq!(cov.len(), 16); + assert!(cov.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v))); + } } diff --git a/crates/vector/src/shapes.rs b/crates/vector/src/shapes.rs index dfeae26e81..d259d27871 100644 --- a/crates/vector/src/shapes.rs +++ b/crates/vector/src/shapes.rs @@ -142,7 +142,7 @@ pub fn fillet(prev: [f64; 2], v: [f64; 2], next: [f64; 2], r: f64) -> Option std::f64::consts::FRAC_PI_2 - 1e-6 { + if !(1e-6..=std::f64::consts::FRAC_PI_2 - 1e-6).contains(&half) { return None; } let r = r.min(l1.min(l2) / 2.0 * half.tan()); diff --git a/crates/vector/src/tests.rs b/crates/vector/src/tests.rs index 18b2f6f18a..8ef86f18ef 100644 --- a/crates/vector/src/tests.rs +++ b/crates/vector/src/tests.rs @@ -570,3 +570,16 @@ fn thin_antialiased_edges_still_count_toward_bounds() { let c = r.render(Rect::new(0, 0, 102, 10)); assert!(c[100] > 5e-5 && c[101] == 0.0, "{} {}", c[100], c[101]); } + +#[test] +fn tile_bands_and_band_tiles_near_i32_max_do_not_overflow() { + let r = Rect::new(0, i32::MAX - 10, 10, i32::MAX); + let bands = tile_bands(r); + assert_eq!(bands, vec![r]); + + let band = Rect::new(i32::MAX - 10, 0, i32::MAX, 2); + let data = vec![1.0f32; (band.width() * band.height()) as usize]; + let tiles = band_tiles(band, &data, 1, |p| p[0] > 0.0); + assert_eq!(tiles.len(), 1); + assert_eq!(tiles[0].0, band); +} diff --git a/crates/vector/src/trace.rs b/crates/vector/src/trace.rs index 1a547bfb0b..1557af863d 100644 --- a/crates/vector/src/trace.rs +++ b/crates/vector/src/trace.rs @@ -37,14 +37,26 @@ pub fn trace_mask(values: &[f32], rect: Rect, outside: f32, tolerance: f64) -> P /// Closed iso-contours at 0.5 through pixel centres. pub fn contours(values: &[f32], rect: Rect, outside: f32) -> Vec> { + if rect.is_empty() { + return Vec::new(); + } let w = rect.width() as i64; let h = rect.height() as i64; - let get = |x: i64, y: i64| -> f32 { if x < 0 || y < 0 || x >= w || y >= h { outside } else { values[(y * w + x) as usize] } }; + let Some(need) = (w as usize).checked_mul(h as usize) else { + return Vec::new(); + }; + if values.len() < need { + return Vec::new(); + } + let clean = |v: f32| if v.is_finite() { v } else { 0.0 }; + let outside = clean(outside); + let get = |x: i64, y: i64| -> f32 { if x < 0 || y < 0 || x >= w || y >= h { outside } else { clean(values[(y * w + x) as usize]) } }; // Edge ids: horizontal edge between (x,y)-(x+1,y) = (x, y, 0); vertical (x,y)-(x,y+1) = (x, y, 1). let lvl = 0.5f32; let point = |a: (i64, i64), b: (i64, i64)| -> P { let (va, vb) = (get(a.0, a.1), get(b.0, b.1)); - let t = if (vb - va).abs() > 1e-9 { f64::from((lvl - va) / (vb - va)).clamp(0.0, 1.0) } else { 0.5 }; + let raw_t = if (vb - va).abs() > 1e-9 { f64::from((lvl - va) / (vb - va)) } else { 0.5 }; + let t = if raw_t.is_finite() { raw_t.clamp(0.0, 1.0) } else { 0.5 }; let (ax, ay) = (a.0 as f64 + 0.5, a.1 as f64 + 0.5); let (bx, by) = (b.0 as f64 + 0.5, b.1 as f64 + 0.5); (f64::from(rect.x0) + ax + (bx - ax) * t, f64::from(rect.y0) + ay + (by - ay) * t) @@ -156,14 +168,18 @@ fn norm(a: P) -> P { /// an open polyline, where the walk stops at the ends. fn walk(lp: &[P], i: usize, window: f64, fwd: bool, closed: bool) -> P { let n = lp.len(); + let max_steps = n.min(256); let mut acc = 0.0; let mut cur = i; - for _ in 0..n { + for _ in 0..max_steps { if !closed && (if fwd { cur + 1 >= n } else { cur == 0 }) { break; } let nx = if fwd { (cur + 1) % n } else { (cur + n - 1) % n }; let d = len(sub(lp[nx], lp[cur])); + if !d.is_finite() || !window.is_finite() { + break; + } if acc + d >= window { let t = (window - acc) / d.max(1e-12); return add(lp[cur], mul(sub(lp[nx], lp[cur]), t)); @@ -178,6 +194,9 @@ fn walk(lp: &[P], i: usize, window: f64, fwd: bool, closed: bool) -> P { /// ends of an open polyline are never corners. fn corners(lp: &[P], window: f64, closed: bool) -> Vec { let n = lp.len(); + if n < 3 || !window.is_finite() || lp.iter().any(|p| !p.0.is_finite() || !p.1.is_finite()) { + return Vec::new(); + } let cos_thresh = (60f64).to_radians().cos(); let turn: Vec = (0..n) .map(|i| { @@ -190,21 +209,33 @@ fn corners(lp: &[P], window: f64, closed: bool) -> Vec { }) .collect(); let mut out = Vec::new(); + let max_neighbor_steps = n.min(256); + let mut work_budget = n.saturating_mul(64).max(1024); for i in 0..n { if turn[i] >= cos_thresh { continue; } + if work_budget == 0 { + break; + } // Local minimum of the cosine within ±window (ties: first index). let mut is_min = true; let mut acc = 0.0; let mut j = i; - while acc < window { + let mut steps = 0usize; + while acc < window && steps < max_neighbor_steps && work_budget > 0 { if !closed && j + 1 >= n { break; } let nx = (j + 1) % n; - acc += len(sub(lp[nx], lp[j])); + let d = len(sub(lp[nx], lp[j])); + if !d.is_finite() { + break; + } + acc += d; j = nx; + steps += 1; + work_budget -= 1; if j == i { break; } @@ -215,13 +246,20 @@ fn corners(lp: &[P], window: f64, closed: bool) -> Vec { } acc = 0.0; j = i; - while is_min && acc < window { + steps = 0; + while is_min && acc < window && steps < max_neighbor_steps && work_budget > 0 { if !closed && j == 0 { break; } let pv = (j + n - 1) % n; - acc += len(sub(lp[j], lp[pv])); + let d = len(sub(lp[j], lp[pv])); + if !d.is_finite() { + break; + } + acc += d; j = pv; + steps += 1; + work_budget -= 1; if j == i { break; } @@ -239,6 +277,9 @@ fn corners(lp: &[P], window: f64, closed: bool) -> Vec { /// Fits a closed loop with cubic Béziers; `window` is the corner detection window. fn fit_closed(lp: &[P], tol: f64, window: f64) -> Subpath { let n = lp.len(); + if n < 3 || lp.iter().any(|p| !p.0.is_finite() || !p.1.is_finite()) { + return Subpath { closed: true, knots: Vec::new(), op: PathOp::Combine }; + } let cs = corners(lp, window, true); let mut segs: Vec<[P; 4]> = Vec::new(); let mut corner_at_start: Vec = Vec::new(); @@ -617,4 +658,39 @@ mod tests { let sp = fit_trace(&long, -5.0, false).unwrap(); assert!(sp.knots.len() >= 2 && sp.knots.iter().all(|k| k.anchor.x.is_finite() && k.out_ctrl.y.is_finite())); } + + #[test] + fn corners_have_bounded_work_for_many_tiny_segments() { + let points: Vec

= (0..20_000).map(|i| (i as f64 * 0.01, if i % 2 == 0 { 0.0 } else { 1e-5 })).collect(); + let selected = corners(&points, 100.0, false); + assert!(selected.len() <= points.len()); + } + + #[test] + fn contours_reject_empty_rect_and_clean_non_finite_values() { + assert!(contours(&[], Rect::new(0, 0, 0, 1_000_000_000), 0.0).is_empty()); + assert!(contours(&[], Rect::new(0, 0, 1_000_000_000, 0), 0.0).is_empty()); + assert!(contours(&[1.0], Rect::new(0, 0, 10, 10), 0.0).is_empty()); + + let (w, h) = (6, 6); + let mut v = vec![0.0f32; w * h]; + for y in 1..5 { + for x in 1..5 { + v[y * w + x] = f32::INFINITY; + } + } + v[2 * w + 2] = f32::NAN; + v[w + 1] = 1.0; + v[w + 2] = 1.0; + v[2 * w + 1] = 1.0; + let path = trace_mask(&v, Rect::new(0, 0, w as i32, h as i32), f32::NAN, 1.0); + assert!(path.subpaths.iter().any(|sp| !sp.knots.is_empty())); + for sp in &path.subpaths { + for k in &sp.knots { + assert!(k.anchor.x.is_finite() && k.anchor.y.is_finite()); + assert!(k.in_ctrl.x.is_finite() && k.in_ctrl.y.is_finite()); + assert!(k.out_ctrl.x.is_finite() && k.out_ctrl.y.is_finite()); + } + } + } } From f27656b9109af338c64d6a9a031b14f3db1ecbbf Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 09:57:10 -0400 Subject: [PATCH 29/53] fix: restrict droplet shim permissions and annotate test range (SonarCloud) (#8870) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generated droplet shim only needs to run as its creator, so 0700 keeps other local accounts from reading the CLI path it invokes. The reversed range in the arrow-key test is the case under test, not an accident. 🤖 Generated with Codebuff Co-authored-by: Codebuff --- crates/engine/src/automate_cmds.rs | 4 +++- crates/ui-egui/src/widgets.rs | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/automate_cmds.rs b/crates/engine/src/automate_cmds.rs index 2256a76cda..800ff954e0 100644 --- a/crates/engine/src/automate_cmds.rs +++ b/crates/engine/src/automate_cmds.rs @@ -358,7 +358,9 @@ fn write_shim(droplet: &str) -> Result { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o755)); + // The shim only has to run as the user who created it; 0o755 would also let every other + // local account read and execute it. + let _ = std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o700)); } Ok(shim) } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index d7ab90b2e9..223d0cfa8e 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -2367,7 +2367,8 @@ mod tests { #[test] fn arrow_keys_on_a_field_with_a_reversed_range_do_not_panic() { - let mut h = focused(5.0, 10.0..=0.0); + // The empty range is the case under test — arrow keys must not panic on it. + let mut h = focused(5.0, 10.0..=0.0); // NOSONAR let _ = press(&mut h, Modifiers::NONE, Key::ArrowUp); let mut h = focused(5.0, f32::NAN..=10.0); let _ = press(&mut h, Modifiers::NONE, Key::ArrowDown); From ed1b6f43142f5e9d2d7b8a9fc7de2853cc4b86ed Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 23 Oct 2026 09:59:59 -0400 Subject: [PATCH 30/53] ci: run FreeBSD and Windows ARM64 checks on pushes to release The release playbook runs the slow platform checks nightly and on every push to release, so a regression shows up before a release is published. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/freebsd.yml | 2 ++ .github/workflows/windows-arm64.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 56d1ad0831..f15ec0e8d8 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -9,6 +9,8 @@ name: FreeBSD # or dependency change: a drifted dependency still shows up within a day. PRs that change the # FreeBSD packaging itself still run it. on: + push: + branches: [release] schedule: - cron: "17 2 * * *" pull_request: diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 30a07f4cd5..1022c54215 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -7,6 +7,8 @@ name: Windows ARM64 # Nightly on main (dependency and runner-image drift shows up within a day), and on PRs that # change the Windows packaging itself. on: + push: + branches: [release] pull_request: paths: - .github/workflows/windows-arm64.yml From e6d6fb45a40250f518d2e824236a8fdcaa4472c8 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 23 Oct 2026 09:59:59 -0400 Subject: [PATCH 31/53] Release 0.15.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 62 +++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5c96f56119..d08ca6f450 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4638,7 +4638,7 @@ dependencies = [ [[package]] name = "photocraft" -version = "0.14.0" +version = "0.15.0" dependencies = [ "arboard", "async-io", @@ -4684,7 +4684,7 @@ dependencies = [ [[package]] name = "photocraft-affinity" -version = "0.14.0" +version = "0.15.0" dependencies = [ "crc32fast", "flate2", @@ -4694,7 +4694,7 @@ dependencies = [ [[package]] name = "photocraft-algo" -version = "0.14.0" +version = "0.15.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4707,7 +4707,7 @@ dependencies = [ [[package]] name = "photocraft-automation" -version = "0.14.0" +version = "0.15.0" dependencies = [ "base64 0.22.1", "cap-std", @@ -4730,7 +4730,7 @@ dependencies = [ [[package]] name = "photocraft-cli" -version = "0.14.0" +version = "0.15.0" dependencies = [ "photocraft-automation", "photocraft-codecs", @@ -4745,7 +4745,7 @@ dependencies = [ [[package]] name = "photocraft-cms" -version = "0.14.0" +version = "0.15.0" dependencies = [ "moxcms", "rayon", @@ -4753,7 +4753,7 @@ dependencies = [ [[package]] name = "photocraft-codecs" -version = "0.14.0" +version = "0.15.0" dependencies = [ "exr", "flate2", @@ -4774,7 +4774,7 @@ dependencies = [ [[package]] name = "photocraft-color" -version = "0.14.0" +version = "0.15.0" dependencies = [ "photocraft-cms", "serde", @@ -4782,7 +4782,7 @@ dependencies = [ [[package]] name = "photocraft-compose" -version = "0.14.0" +version = "0.15.0" dependencies = [ "criterion", "photocraft-color", @@ -4797,7 +4797,7 @@ dependencies = [ [[package]] name = "photocraft-doc" -version = "0.14.0" +version = "0.15.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4808,7 +4808,7 @@ dependencies = [ [[package]] name = "photocraft-engine" -version = "0.14.0" +version = "0.15.0" dependencies = [ "blake3", "chrono", @@ -4847,7 +4847,7 @@ dependencies = [ [[package]] name = "photocraft-format" -version = "0.14.0" +version = "0.15.0" dependencies = [ "blake3", "crc32fast", @@ -4870,14 +4870,14 @@ dependencies = [ [[package]] name = "photocraft-geom" -version = "0.14.0" +version = "0.15.0" dependencies = [ "serde", ] [[package]] name = "photocraft-gpu" -version = "0.14.0" +version = "0.15.0" dependencies = [ "half", "log", @@ -4895,14 +4895,14 @@ dependencies = [ [[package]] name = "photocraft-heif" -version = "0.14.0" +version = "0.15.0" dependencies = [ "heic-rs", ] [[package]] name = "photocraft-io" -version = "0.14.0" +version = "0.15.0" dependencies = [ "blake3", "craft-docpath", @@ -4932,18 +4932,18 @@ dependencies = [ [[package]] name = "photocraft-ipp" -version = "0.14.0" +version = "0.15.0" [[package]] name = "photocraft-ops" -version = "0.14.0" +version = "0.15.0" dependencies = [ "photocraft-doc", ] [[package]] name = "photocraft-paint" -version = "0.14.0" +version = "0.15.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4954,7 +4954,7 @@ dependencies = [ [[package]] name = "photocraft-plugins" -version = "0.14.0" +version = "0.15.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4969,7 +4969,7 @@ dependencies = [ [[package]] name = "photocraft-psd" -version = "0.14.0" +version = "0.15.0" dependencies = [ "flate2", "photocraft-psd", @@ -4980,7 +4980,7 @@ dependencies = [ [[package]] name = "photocraft-raster" -version = "0.14.0" +version = "0.15.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4991,7 +4991,7 @@ dependencies = [ [[package]] name = "photocraft-raw" -version = "0.14.0" +version = "0.15.0" dependencies = [ "flate2", "image", @@ -5006,7 +5006,7 @@ dependencies = [ [[package]] name = "photocraft-tablet" -version = "0.14.0" +version = "0.15.0" dependencies = [ "block2 0.6.2", "objc2 0.6.4", @@ -5018,7 +5018,7 @@ dependencies = [ [[package]] name = "photocraft-testkit" -version = "0.14.0" +version = "0.15.0" dependencies = [ "memory-stats", "serde_json", @@ -5026,7 +5026,7 @@ dependencies = [ [[package]] name = "photocraft-text" -version = "0.14.0" +version = "0.15.0" dependencies = [ "flate2", "parley", @@ -5041,7 +5041,7 @@ dependencies = [ [[package]] name = "photocraft-ui-egui" -version = "0.14.0" +version = "0.15.0" dependencies = [ "base64 0.22.1", "craft-print", @@ -5084,7 +5084,7 @@ dependencies = [ [[package]] name = "photocraft-vector" -version = "0.14.0" +version = "0.15.0" dependencies = [ "photocraft-color", "photocraft-doc", @@ -5095,7 +5095,7 @@ dependencies = [ [[package]] name = "photocraft-web" -version = "0.14.0" +version = "0.15.0" dependencies = [ "eframe", "egui", @@ -5117,7 +5117,7 @@ dependencies = [ [[package]] name = "photocraft-web-boot" -version = "0.14.0" +version = "0.15.0" dependencies = [ "flate2", "js-sys", @@ -8713,7 +8713,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] name = "xtask" -version = "0.14.0" +version = "0.15.0" dependencies = [ "chrono", "serde", diff --git a/Cargo.toml b/Cargo.toml index 1215d84570..a50eabf7b1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"] [workspace.package] -version = "0.14.0" +version = "0.15.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From 7d9abd95afa9576d9c9890c56ab8c0c3d729bffb Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 16:18:57 +0000 Subject: [PATCH 32/53] CI: the git-sources check parses .cargo/config and .cargo/config.toml A source replacement, a patch or a path override in either cargo config never shows up in Cargo.lock, so the check refuses them. Both files are parsed as TOML, as Cargo reads them. --- .github/workflows/ci.yml | 19 +++++++++++++++---- .github/workflows/release.yml | 19 +++++++++++++++---- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8ca27d160..6ca1e7e50e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,10 +55,21 @@ jobs: run: | set -euo pipefail test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; } - # Source replacement and patches in cargo config never show up in Cargo.lock. - if grep -qE '^[[:space:]]*(replace-with|\[patch)' .cargo/config.toml 2>/dev/null; then - echo ".cargo/config.toml replaces or patches a source"; exit 1 - fi + # Source replacement, patches and path overrides in cargo config never show up + # in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key. + # Cargo still reads the legacy .cargo/config. + python3 - <<'PY' + import os, sys, tomllib + for path in (".cargo/config", ".cargo/config.toml"): + if not os.path.exists(path): + continue + config = tomllib.load(open(path, "rb")) + replaced = [name for name, source in config.get("source", {}).items() + if isinstance(source, dict) and "replace-with" in source] + if replaced or "patch" in config or "paths" in config: + print(f"{path} replaces, patches or overrides a source") + sys.exit(1) + PY # Fails when Cargo.lock is out of date with the manifests. cargo metadata --locked --format-version 1 > /dev/null python3 - <<'PY' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 99028d8a38..72b0461076 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,10 +29,21 @@ jobs: run: | set -euo pipefail test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; } - # Source replacement and patches in cargo config never show up in Cargo.lock. - if grep -qE '^[[:space:]]*(replace-with|\[patch)' .cargo/config.toml 2>/dev/null; then - echo ".cargo/config.toml replaces or patches a source"; exit 1 - fi + # Source replacement, patches and path overrides in cargo config never show up + # in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key. + # Cargo still reads the legacy .cargo/config. + python3 - <<'PY' + import os, sys, tomllib + for path in (".cargo/config", ".cargo/config.toml"): + if not os.path.exists(path): + continue + config = tomllib.load(open(path, "rb")) + replaced = [name for name, source in config.get("source", {}).items() + if isinstance(source, dict) and "replace-with" in source] + if replaced or "patch" in config or "paths" in config: + print(f"{path} replaces, patches or overrides a source") + sys.exit(1) + PY # Fails when Cargo.lock is out of date with the manifests. cargo metadata --locked --format-version 1 > /dev/null python3 - <<'PY' From 066ad384cd2a8371b00ccec4b5f78d47c020d2f3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 16:40:24 +0000 Subject: [PATCH 33/53] Tests: refuse a PHOTOCRAFT_SWEEP_PART that isn't a part number --- crates/testkit/src/sweep.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/testkit/src/sweep.rs b/crates/testkit/src/sweep.rs index 1c4664bcb4..ae3552226e 100644 --- a/crates/testkit/src/sweep.rs +++ b/crates/testkit/src/sweep.rs @@ -19,9 +19,12 @@ impl Sweep { return Self { part: 0, parts: 1 }; } let parts = parts.max(1); - let part = match std::env::var("PHOTOCRAFT_SWEEP_PART").ok().and_then(|v| v.parse::().ok()) { - Some(p) => p % parts, - None => SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs() / 86_400).unwrap_or_default() % parts, + let part = match std::env::var("PHOTOCRAFT_SWEEP_PART") { + Ok(v) => match v.parse::() { + Ok(p) if p < parts => p, + _ => panic!("PHOTOCRAFT_SWEEP_PART={v:?} is not a part number below {parts}"), + }, + Err(_) => SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs() / 86_400).unwrap_or_default() % parts, }; eprintln!("sweep part {part} of {parts}: replay with PHOTOCRAFT_SWEEP_PART={part}, run every part with PHOTOCRAFT_FULL_SWEEP=1"); Self { part, parts } From 20ad9f1f4e8802b00877d786b321470d66ec25f4 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 16:40:24 +0000 Subject: [PATCH 34/53] Docs: the final check before a PR runs the full sweep --- docs/development.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/development.md b/docs/development.md index 3ba7fc8ed6..dc153c4e5a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -60,7 +60,7 @@ cargo nextest run --workspace # parallel across binaries (cargo ins cargo test --doc --workspace # nextest does not run doctests ``` -[cargo-nextest](https://nexte.st) runs each test in its own process. `.config/nextest.toml` puts the GPU tests (`photocraft-gpu`, and ui-egui's integration and canvas/GPU tests) in a group of at most 4 at a time, because each process opens its own wgpu device and the in-process `gpu_lock()` no longer serializes them. On a 32-thread Windows machine the full workspace went from 297 s (`cargo test`) to about 120 s (#2204). Ignored tests run with `cargo nextest run --run-ignored only`. The longest sweeps (`photocraft-testkit`'s `Sweep`) run a quarter of their cases, a different quarter each UTC day; `PHOTOCRAFT_FULL_SWEEP=1` runs all of them, and `PHOTOCRAFT_SWEEP_PART=` replays the part a failing run printed. CI runs it with `--profile ci` (fail-fast, a 10-minute hang timeout), plus `cargo test --doc` for the doctests (#6117); a full `cargo test` stays the final check before a PR. +[cargo-nextest](https://nexte.st) runs each test in its own process. `.config/nextest.toml` puts the GPU tests (`photocraft-gpu`, and ui-egui's integration and canvas/GPU tests) in a group of at most 4 at a time, because each process opens its own wgpu device and the in-process `gpu_lock()` no longer serializes them. On a 32-thread Windows machine the full workspace went from 297 s (`cargo test`) to about 120 s (#2204). Ignored tests run with `cargo nextest run --run-ignored only`. The longest sweeps (`photocraft-testkit`'s `Sweep`) run a quarter of their cases, a different quarter each UTC day; `PHOTOCRAFT_FULL_SWEEP=1` runs all of them, and `PHOTOCRAFT_SWEEP_PART=` replays the part a failing run printed. CI runs it with `--profile ci` (fail-fast, a 10-minute hang timeout), plus `cargo test --doc` for the doctests (#6117); `PHOTOCRAFT_FULL_SWEEP=1 cargo test --workspace` stays the final check before a PR. ### Shortcut capture without a GPU, and native snapshots From 1f472d61eaa1b66635cb900c03e7cd832ad4e862 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 16:49:11 +0000 Subject: [PATCH 35/53] Tests: report a bad PHOTOCRAFT_SWEEP_PART with assert!, which testkit's lints allow --- crates/testkit/src/sweep.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/testkit/src/sweep.rs b/crates/testkit/src/sweep.rs index ae3552226e..863a5c9029 100644 --- a/crates/testkit/src/sweep.rs +++ b/crates/testkit/src/sweep.rs @@ -20,10 +20,11 @@ impl Sweep { } let parts = parts.max(1); let part = match std::env::var("PHOTOCRAFT_SWEEP_PART") { - Ok(v) => match v.parse::() { - Ok(p) if p < parts => p, - _ => panic!("PHOTOCRAFT_SWEEP_PART={v:?} is not a part number below {parts}"), - }, + Ok(v) => { + let p = v.parse::().unwrap_or(u64::MAX); + assert!(p < parts, "PHOTOCRAFT_SWEEP_PART={v:?} is not a part number below {parts}"); + p + } Err(_) => SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs() / 86_400).unwrap_or_default() % parts, }; eprintln!("sweep part {part} of {parts}: replay with PHOTOCRAFT_SWEEP_PART={part}, run every part with PHOTOCRAFT_FULL_SWEEP=1"); From c9892c8f8d45b904ce0c9648d4fd4f1e6a96ae8f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 14:12:04 -0400 Subject: [PATCH 36/53] security(codecs): refuse implausibly short truncated JPEG scans; charge WebP frame ICC copies A truncated JPEG scan under a large declared frame was filled out by the backend to the full admitted size; reject it when the file cannot plausibly account for the pixels. Animated WebP frames each keep a copy of the ICC profile, so the aggregate admission now counts it. --- crates/codecs/src/codecs/jpeg.rs | 25 +++++++++++++++++++++++++ crates/codecs/src/codecs/webp.rs | 4 +++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/codecs/src/codecs/jpeg.rs b/crates/codecs/src/codecs/jpeg.rs index cf1c9fca43..26a47c13d6 100644 --- a/crates/codecs/src/codecs/jpeg.rs +++ b/crates/codecs/src/codecs/jpeg.rs @@ -182,6 +182,10 @@ fn err(e: impl std::fmt::Display) -> CodecError { CodecError::malformed(F, e) } +/// Most pixels one byte of a truncated file may account for. Even a flat image costs a few bits +/// per 8×8 block, and truncated photographs are far above this. +const MAX_TRUNCATED_PIXELS_PER_BYTE: u64 = 2048; + pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result { let end = data_end(bytes); if end == DataEnd::Empty { @@ -204,6 +208,11 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result _ => (ColorSpace::RGB, ChannelLayout::Rgb), }; limits.check(w, h, layout, SampleType::U8)?; + // The backend fills whatever the scan no longer describes, so a short scan under a large + // frame is a gigabyte of synthesized output from a few kilobytes of file. + if end == DataEnd::Truncated && u64::from(w) * u64::from(h) / MAX_TRUNCATED_PIXELS_PER_BYTE > bytes.len() as u64 { + return Err(err("the truncated scan is too short for the frame it declares")); + } dec.set_options(dec.options().jpeg_set_out_colorspace(out_cs)); let mut px = dec.decode().map_err(err)?; let expected = w as usize * h as usize * layout.channels(); @@ -435,6 +444,22 @@ mod tests { bytes.get(2..11) == Some(b"\xff\xe0\0\x10JFIF\0") } + /// A tiny truncated scan under a frame patched to 20000×20000 is refused before the backend + /// fills 400 million pixels from it; the same file with its real size still recovers. + #[test] + fn a_short_truncated_scan_cannot_declare_a_huge_frame() { + let img = Image::from_raw(16, 16, ChannelLayout::Rgb, SampleType::U8, vec![128; 16 * 16 * 3]).unwrap(); + let full = crate::encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap(); + let cut = &full[..full.len() - 4]; + assert_eq!(data_end(cut), DataEnd::Truncated); + assert!(crate::decode_as(Format::Jpeg, cut).is_ok()); + let sof = cut.windows(2).position(|w| w == [0xFF, 0xC0] || w == [0xFF, 0xC2]).unwrap(); + let mut huge = cut.to_vec(); + huge[sof + 5..sof + 9].copy_from_slice(&[0x4E, 0x20, 0x4E, 0x20]); + let e = crate::decode_as(Format::Jpeg, &huge).unwrap_err(); + assert!(e.to_string().contains("too short"), "{e}"); + } + /// CMYK JPEG output must omit JFIF while retaining Adobe's CMYK marker. #[test] fn cmyk_jpeg_omits_jfif_but_keeps_adobe_cmyk_marker() { diff --git a/crates/codecs/src/codecs/webp.rs b/crates/codecs/src/codecs/webp.rs index f109f62e96..42afce3140 100644 --- a/crates/codecs/src/codecs/webp.rs +++ b/crates/codecs/src/codecs/webp.rs @@ -85,7 +85,9 @@ pub(crate) fn decode_frames(bytes: &[u8], limits: &Limits) -> Result= MAX_GIF_FRAMES { return Err(CodecError::LimitExceeded(format!("more than {MAX_GIF_FRAMES} frames"))); } - if (size as u64).saturating_mul(out.len() as u64 + 1) > limits.max_alloc { + // Every frame keeps its own copy of the file's ICC profile along with its pixels. + let frame_bytes = (size as u64).saturating_add(icc.as_ref().map_or(0, |p| p.len() as u64)); + if frame_bytes.saturating_mul(out.len() as u64 + 1) > limits.max_alloc { return Err(CodecError::LimitExceeded(format!("{} frames of {w}x{h} exceed max_alloc {}", out.len() + 1, limits.max_alloc))); } let delay_ms = match dec.read_frame(&mut buf) { From b11ecc6ebf6699f53d0982c3f6c37a975ea9b17d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 14:30:21 -0400 Subject: [PATCH 37/53] security(affinity): charge shared-object expansion against document-wide budgets (#8938) Reject zero-width fixed-record arrays, borrow pixel state arrays instead of collecting them per occurrence, charge FiEf slots, curve records and layer name bytes across the document, bound mixed-text expansion before appending, and cap distinct allocation-table ids (deletion records now hold no Entry). --- crates/affinity/src/container.rs | 12 +++++++++--- crates/affinity/src/geometry.rs | 28 +++++++++++++++++++++++++++- crates/affinity/src/model.rs | 27 ++++++++++++++++++++++----- crates/affinity/src/raster.rs | 20 +++++++++++++------- crates/affinity/src/stream.rs | 3 ++- crates/affinity/src/tests.rs | 11 +++++++++++ crates/affinity/src/text.rs | 10 ++++++++-- 7 files changed, 92 insertions(+), 19 deletions(-) diff --git a/crates/affinity/src/container.rs b/crates/affinity/src/container.rs index 880b7236d1..0250cc4f77 100644 --- a/crates/affinity/src/container.rs +++ b/crates/affinity/src/container.rs @@ -15,6 +15,8 @@ const DOCUMENT_KIND: &[u8; 4] = b"nsrP"; /// A save appends one table; real files have one to a few hundred. const MAX_TABLES: usize = 4096; +/// Distinct ids the allocation tables may name; each costs a map slot even when only deleted. +const MAX_ENTRIES: usize = 1 << 22; /// zstd window ceiling: real documents use a few MiB; this stops a header from reserving gigabytes. const MAX_ZSTD_WINDOW: u64 = 64 << 20; @@ -164,7 +166,7 @@ impl<'a> Archive<'a> { pub fn open(bytes: &'a [u8], limits: Limits) -> Result { let header = header(bytes)?; // Newest record per id: (table date, table index, record). - let mut latest: HashMap)> = HashMap::new(); + let mut latest: HashMap>)> = HashMap::new(); let mut names: HashMap = HashMap::new(); let mut seen = HashSet::new(); let mut next = header.fat_offset; @@ -222,7 +224,7 @@ impl<'a> Archive<'a> { } else { stored }; - record = Some(Entry { id, name: String::new(), offset, size, compressed, crc, compression }); + record = Some(Box::new(Entry { id, name: String::new(), offset, size, compressed, crc, compression })); } 2 => {} _ => return Err(Error::Malformed("unknown allocation record")), @@ -235,6 +237,10 @@ impl<'a> Archive<'a> { // Equal dates: the first table in the chain (the newest) wins, as in afread. let replace = latest.get(&id).is_none_or(|(d, _, _)| date > *d); if replace { + // Deletion records are five bytes each: cap what distinct ids may retain. + if latest.len() >= MAX_ENTRIES && !latest.contains_key(&id) { + return Err(Error::Limit("too many archive entries")); + } latest.insert(id, (date, tables, record)); } } @@ -248,7 +254,7 @@ impl<'a> Archive<'a> { for (id, (_, _, record)) in latest { if let (Some(mut e), Some(name)) = (record, names.get(&id)) { e.name = name.clone(); - entries.insert(e.name.clone(), e); + entries.insert(e.name.clone(), *e); } } Ok(Self { bytes, header, entries, revisions: tables, extracted: 0, limits }) diff --git a/crates/affinity/src/geometry.rs b/crates/affinity/src/geometry.rs index 6825abd511..c62c206f58 100644 --- a/crates/affinity/src/geometry.rs +++ b/crates/affinity/src/geometry.rs @@ -9,17 +9,21 @@ const MAX_NODES: usize = 4_000_000; const MAX_COMPOUND_DEPTH: usize = 64; const MAX_OUTLINE_VISITS: usize = 500_000; const MAX_OUTLINE_POINTS: usize = 4_000_000; +/// Curve records decoded across the document. A shared curve is decoded again for each +/// occurrence, and records that only move handles add no points. +const MAX_OUTLINE_RECORDS: usize = 8_000_000; /// Geometry is expanded per occurrence, since parametric outlines can depend on their world /// transform. Bound work across the entire document instead of memoizing by object id alone. pub(crate) struct Budget { visits_left: usize, points_left: usize, + records_left: usize, } impl Default for Budget { fn default() -> Self { - Self { visits_left: MAX_OUTLINE_VISITS, points_left: MAX_OUTLINE_POINTS } + Self { visits_left: MAX_OUTLINE_VISITS, points_left: MAX_OUTLINE_POINTS, records_left: MAX_OUTLINE_RECORDS } } } @@ -30,6 +34,12 @@ impl Budget { Ok(()) } + /// Charge curve records before they are decoded. + fn records(&mut self, n: usize) -> Result<(), &'static str> { + self.records_left = self.records_left.checked_sub(n).ok_or("curve geometry budget exhausted")?; + Ok(()) + } + /// Charge both leaf geometry and each copy into a compound before allocating that copy. fn path(&mut self, path: &Path) -> Result<(), &'static str> { let points = path.subpaths.iter().fold(0usize, |n, s| n.saturating_add(1).saturating_add(s.segments.len().saturating_mul(3))); @@ -152,6 +162,10 @@ pub(crate) fn curves(r: &mut Reader, crvs: ObjId) -> Option { r.warn("curves with more than four million nodes"); return None; } + if let Err(reason) = r.geometry_budget.records(records.len()) { + r.warn(reason); + return None; + } let anchors = anchors(records)?; let radii = if index == 0 { corners.as_ref() } else { None }; if let Some(sp) = subpath(&anchors, closed, radii) { @@ -258,3 +272,15 @@ fn corner_radii(fields: Vec<&Value>) -> Option> { .collect(), ) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn curve_records_are_charged_across_the_document() { + let mut b = Budget::default(); + assert!(b.records(MAX_OUTLINE_RECORDS).is_ok()); + assert_eq!(b.records(1), Err("curve geometry budget exhausted")); + } +} diff --git a/crates/affinity/src/model.rs b/crates/affinity/src/model.rs index f77c970ae2..1d8e5918be 100644 --- a/crates/affinity/src/model.rs +++ b/crates/affinity/src/model.rs @@ -288,6 +288,8 @@ const MAX_NODES: usize = 500_000; /// nodes, so a shared group holding a large null-filled child array could still be rescanned /// many times; this bounds that work. Generous relative to MAX_NODES for real documents. const MAX_CHILD_SLOTS: usize = 8_000_000; +/// Bytes of layer names the model may retain across all occurrences. +const MAX_NAME_BYTES: usize = 64 << 20; /// Read a document: archive, `doc.dat` and the model. pub fn read(bytes: &[u8], limits: Limits) -> Result { @@ -304,6 +306,7 @@ pub(crate) struct Reader<'s, 'a, 'b> { warnings: BTreeMap, nodes: usize, child_slots: usize, + name_bytes: usize, active: HashSet, pub(crate) geometry_budget: crate::geometry::Budget, /// Decoded pixels still available ([`Limits::max_pixel_bytes`] less what the layers read so @@ -320,6 +323,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { warnings: BTreeMap::new(), nodes: 0, child_slots: 0, + name_bytes: 0, active: HashSet::new(), geometry_budget: crate::geometry::Budget::default(), pixels_left: limits.max_pixel_bytes, @@ -394,10 +398,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { // Charge every raw child slot (null slots included) against a document-wide budget // before filtering. A shared group with a large null-filled Chld array could otherwise // be rescanned through each reference without the node limit noticing. - self.child_slots = self.child_slots.saturating_add(s.array_len(parent, b"Chld").unwrap_or(0)); - if self.child_slots > MAX_CHILD_SLOTS { - return Err(Error::Limit("too many child slots in the layer tree")); - } + self.charge_slots(s.array_len(parent, b"Chld").unwrap_or(0))?; for c in s.objs(parent, b"Chld") { if s.is(c, b"MRst") { // A mask layer inside a group or layer masks that container. @@ -419,6 +420,15 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { Ok((out, mask)) } + /// Counts raw array slots (null ones included) against [`MAX_CHILD_SLOTS`]. + fn charge_slots(&mut self, n: usize) -> Result<(), Error> { + self.child_slots = self.child_slots.saturating_add(n); + if self.child_slots > MAX_CHILD_SLOTS { + return Err(Error::Limit("too many child slots in the layer tree")); + } + Ok(()) + } + fn node(&mut self, id: ObjId, parent: Affine, depth: usize) -> Result, Error> { let s = self.s; if depth > MAX_TREE_DEPTH { @@ -437,7 +447,13 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { let local = s.floats::<6>(id, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY); let world = local.then(parent); let class = s.class(id).unwrap_or(Tag(0)); - let name = s.str(id, b"Desc").unwrap_or_default().to_string(); + // A shared layer's name is copied for every occurrence, so charge the bytes first. + let name = s.str(id, b"Desc").unwrap_or_default(); + self.name_bytes = self.name_bytes.saturating_add(name.len()); + if self.name_bytes > MAX_NAME_BYTES { + return Err(Error::Limit("layer names too large")); + } + let name = name.to_string(); let visible = s.bool(id, b"Visi").unwrap_or(true); let locked = s.bool(id, b"Edtb").is_some_and(|e| !e); let opacity = s.f64(id, b"Opac").map_or(1.0, |o| o.clamp(0.0, 1.0)); @@ -460,6 +476,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { let (mask, attached_mask) = self.attached_masks(id, world)?; let pixel_mask = attached_mask.or(child_mask); self.active.remove(&id); + self.charge_slots(s.array_len(id, b"FiEf").unwrap_or(0))?; if s.objs(id, b"FiEf").iter().any(|e| s.bool(*e, b"Enab") != Some(false)) { self.warn("layer effects (shadows, glows, outlines, bevels…)"); } diff --git a/crates/affinity/src/raster.rs b/crates/affinity/src/raster.rs index bb1ecc112f..f2b99c195e 100644 --- a/crates/affinity/src/raster.rs +++ b/crates/affinity/src/raster.rs @@ -181,24 +181,30 @@ fn plane( let height = s.int(bitmap, b"BmpH").and_then(|v| usize::try_from(v).ok()).unwrap_or(0); let tw = s.int(bitmap, &tag(b"TWi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| (width * bps).div_ceil(TILE)); let th = s.int(bitmap, &tag(b"THi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| height.div_ceil(TILE)); - let states: Vec = match s.field(bitmap, &tag(b"Sta", c)) { - Some(Value::Array(a)) => a.iter().map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(), - _ => Vec::new(), + // Borrow the file-controlled arrays: collecting the whole state array first costs its full + // length on every occurrence of a shared layer, however small the declared grid is. + let states: &[Value] = match s.field(bitmap, &tag(b"Sta", c)) { + Some(Value::Array(a)) => a, + _ => &[], }; - let tiles = s.objs(bitmap, &tag(b"Idx", c)); + let mut tiles = match s.field(bitmap, &tag(b"Idx", c)) { + Some(Value::Array(a)) => a.as_slice(), + _ => &[], + } + .iter() + .filter_map(|v| if let Value::Obj(Some(o)) = v { Some(*o) } else { None }); let row = (w as usize) * bps; let mut out = vec![0u8; row * h as usize]; let (bx0, bx1) = (x0 as usize * bps, (x0 + w) as usize * bps); let (py0, py1) = (y0 as usize, (y0 + h) as usize); - let mut next = 0usize; for (t, state) in states.iter().enumerate().take(tw.saturating_mul(th)) { + let state = &if let Value::UInt(u) = state { u8::try_from(*u).unwrap_or(255) } else { 255 }; let (ty, tx) = (t / tw.max(1), t % tw.max(1)); let fill = match state { 0 | 1 => None, 2 => Some(0xFF), 4 => { - let Some(blck) = tiles.get(next).copied() else { return Err(Error::Malformed("missing pixel tile")) }; - next += 1; + let Some(blck) = tiles.next() else { return Err(Error::Malformed("missing pixel tile")) }; let (ox, oy) = (tx * TILE, ty * TILE); if ox >= bx1 || ox + TILE <= bx0 || oy >= py1 || oy + TILE <= py0 { continue; diff --git a/crates/affinity/src/stream.rs b/crates/affinity/src/stream.rs index 2ffe12b7e5..7c7d47017b 100644 --- a/crates/affinity/src/stream.rs +++ b/crates/affinity/src/stream.rs @@ -332,7 +332,8 @@ impl Parser<'_> { 0x2c => { let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("array length"))?; let size = usize::from(self.c.u16()?); - if n.checked_mul(size).is_none_or(|b| b > self.c.remaining()) { + // A zero width passes the length check for any count, then costs a Value per record. + if size == 0 || n.checked_mul(size).is_none_or(|b| b > self.c.remaining()) { return Err(Error::Malformed("array longer than its data")); } let mut v = self.vec(n)?; diff --git a/crates/affinity/src/tests.rs b/crates/affinity/src/tests.rs index a5517653de..c932aac399 100644 --- a/crates/affinity/src/tests.rs +++ b/crates/affinity/src/tests.rs @@ -302,6 +302,17 @@ mod archive { } } + #[test] + fn fixed_records_of_zero_width_are_rejected_before_any_value_is_made() { + // Four million records of no bytes each pass a length check that multiplies by the width. + let mut f = vec![0x80 | 0x2c]; + f.extend(tag(b"Big_").0.to_le_bytes()); + f.extend(4_000_000u32.to_le_bytes()); + f.extend(0u16.to_le_bytes()); + let d = synth::stream(&[(tag(b"Big_"), F::Raw(f))]); + assert_eq!(stream::parse(&d).unwrap_err(), Error::Malformed("array longer than its data")); + } + #[test] fn a_small_compressed_entry_cannot_make_the_stream_allocate_gigabytes() { // Each decoded value takes about 40 bytes, and a bool array packs eight in a byte: 16 MiB diff --git a/crates/affinity/src/text.rs b/crates/affinity/src/text.rs index c34e4e436a..3b09c75124 100644 --- a/crates/affinity/src/text.rs +++ b/crates/affinity/src/text.rs @@ -26,11 +26,17 @@ pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option { None => { // Inline objects (page numbers, anchors) each take one index before the segment text. let mut v = Vec::new(); + // One past the allowance is enough to trip the check below; shared segments + // repeated by reference must not expand beyond it first. + let room = MAX_CHARS.saturating_sub(total).saturating_add(1); for seg in s.objs(glyphs, b"Mixd") { + if v.len() >= room { + break; + } let inline = s.objs(seg, b"Glys").len(); - v.extend(std::iter::repeat_n('\u{FFFC}', inline)); + v.extend(std::iter::repeat_n('\u{FFFC}', inline.min(room - v.len()))); placeholders |= inline > 0; - v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars()); + v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars().take(room.saturating_sub(v.len()))); } v } From 9acf9a0cf923183181cda4e54d443a637f3be6cb Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 18:32:21 +0000 Subject: [PATCH 38/53] CI: the Linux gate runs through the suite's shared gate workflow --- .github/workflows/ci.yml | 307 ++++++++++++++++++--------------------- Cargo.toml | 6 +- docs/development.md | 2 +- 3 files changed, 142 insertions(+), 173 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ca1e7e50e..bf1a9c04a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,14 @@ name: CI +# Quality gates for every push to main and every pull request. The Linux gate runs through the +# suite's shared gate workflow in craft-common-actions; the Windows legs, the test variants and +# the corpus, wasm, web and fuzz jobs are PhotoCraft's own. on: push: branches: [main] pull_request: - types: [opened, synchronize, reopened, ready_for_review] + # `labeled` so that adding `full-ci` to an open PR starts a run with Windows in it. + types: [opened, synchronize, reopened, labeled] workflow_dispatch: inputs: fuzz: @@ -15,6 +19,10 @@ on: description: "Seconds per fuzz target" default: "600" +# The token is only used to check out the code: no job writes to the repository. +permissions: + contents: read + env: CARGO_TERM_COLOR: always RUST_BACKTRACE: 1 @@ -22,35 +30,33 @@ env: # bypasses that, lp fails loudly on a printer that does not exist. PHOTOCRAFT_NO_PRINT: "1" LPDEST: photocraft-no-such-printer - # Debug and test builds keep line tables (file:line in panic backtraces) but drop the rest of the - # debug info: faster codegen and links, and a smaller target dir (the Linux test job ran out of - # disk with full debug info). The Windows test legs drop debuginfo entirely (see the test job). - CARGO_PROFILE_DEV_DEBUG: line-tables-only -# A newer push to a PR branch cancels its older run. Runs on main are never cancelled: a merge -# that lands while main's run is in progress waits for it, so every main state that gets a run is -# checked to the end. (Cancelling here left main unverified whenever merges came close together.) +# A newer push to a PR branch cancels its older run. Every push to main gets a run of its own that +# is never cancelled, so each state of main is checked to the end. Adding any label but `full-ci` +# gets a group of its own, so it neither runs anything nor cancels a run. concurrency: - group: ci-${{ github.ref }} + group: ci-${{ github.event_name == 'pull_request' && (github.event.action != 'labeled' || github.event.label.name == 'full-ci') && github.ref || github.run_id }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: - # Which OSes the test job runs on. main, manual runs and PRs labelled `full-ci` get Linux and - # Windows. Other PRs get Linux, plus Windows when they touch code whose behaviour differs by - # platform: the app shell, UI, text and fonts, GPU, pen input, native saves, build scripts or - # the lockfile. Format changes must exercise the Windows-only publication barriers (#8034). - # macOS is tested only by release.yml. + # Whether the Windows legs and the web build run. main, manual runs and PRs labelled `full-ci` + # get both. Other PRs get Windows when they touch code whose behaviour differs by platform: the + # app shell, UI, text and fonts, GPU, pen input, native saves, build scripts or the lockfile. + # Format changes must exercise the Windows-only publication barriers (#8034). macOS is tested + # only by release.yml. platforms: + if: github.event.action != 'labeled' || github.event.label.name == 'full-ci' timeout-minutes: 10 name: select platforms runs-on: ubuntu-latest outputs: - legs: ${{ steps.pick.outputs.legs }} + windows: ${{ steps.pick.outputs.windows }} web: ${{ steps.pick.outputs.web }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 + persist-credentials: false - name: Git dependencies come from storytold run: | set -euo pipefail @@ -88,17 +94,8 @@ jobs: BASE: ${{ github.event.pull_request.base.sha }} FULL: ${{ contains(github.event.pull_request.labels.*.name, 'full-ci') }} run: | - # No macOS on PRs or main: the org's 5 macOS slots are kept for release builds, and - # release.yml builds and tests macOS (a failing release is fixed and rebuilt). - # Windows tests run as two legs in parallel: the unit tests and the integration tests. As - # one job they took ~28 min on the Windows runner; the legs take ~13 and ~15. Splitting - # the integration tests over a third runner (nextest --partition) saved ~2 min of wall - # time but cost ~7 runner-minutes and one more Windows slot per PR. - linux='{"os":"ubuntu-latest","part":"all"}' - win() { printf '{"os":"windows-latest","part":"%s"}' "$1"; } - all="[$linux,$(win unit),$(win integration)]" if [ "$EVENT" != pull_request ] || [ "$FULL" = true ]; then - echo "legs=$all" >> "$GITHUB_OUTPUT" + echo "windows=true" >> "$GITHUB_OUTPUT" echo "web=true" >> "$GITHUB_OUTPUT" exit 0 fi @@ -111,66 +108,98 @@ jobs: echo "web=false" >> "$GITHUB_OUTPUT" fi if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/|crates/(ui-egui|text|gpu|tablet|heif|format)/|Cargo\.lock$|(.*/)?build\.rs$|\.github/workflows/ci\.yml$)'; then - echo "legs=$all" >> "$GITHUB_OUTPUT" + echo "windows=true" >> "$GITHUB_OUTPUT" else - echo "legs=[$linux]" >> "$GITHUB_OUTPUT" + echo "windows=false" >> "$GITHUB_OUTPUT" fi - test: - timeout-minutes: 100 - name: test (${{ matrix.os }}${{ matrix.part != 'all' && format(', {0}', matrix.part) || '' }}) + # The Linux gate. Its rust-cache entry is the one `test (without heif)` restores. + gate: + if: github.event.action != 'labeled' || github.event.label.name == 'full-ci' + # Tracks @main so suite-wide CI fixes reach every app without a PR in each repository. + uses: storytold/craft-common-actions/.github/workflows/gate.yml@main + with: + # egui/winit/rfd. + packages: >- + libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev + libgtk-3-dev + # The tests build with the optional craft-fonts input (crates/text/build.rs), so the + # Japanese font tests that skip without it run. Same commit as release.yml. + craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + # The wasm32 build is its own job. + targets: "" + # Cargo.toml's dev profile already keeps line tables only. + dev-debug: "" + env: | + PHOTOCRAFT_NO_PRINT=1 + LPDEST=photocraft-no-such-printer + timeout-minutes: 100 + # `--features heif`: official builds open HEIC photos via the optional photocraft-heif crate. + # `--lib --bins --tests` leaves out the examples, which cargo test would build and link but + # never run (Clippy's `--all-targets` still checks they compile). nextest skips doctests, so + # they run last. The `ci` profile fails fast and terminates hung tests (.config/nextest.toml). + # docs/scorecard.md must match its sources; the check reads committed files only. + # panic_hunt: every command against adversarial params must return Err, never panic or hang + # (AGENTS.md Rule 9). It is `#[ignore]`d (slow), so the test run skips it; the same packages, + # features and targets keep it from rebuilding anything. + command: | + cargo fmt --all -- --check + cargo clippy --workspace --all-targets --locked --features heif -- -D warnings + cargo xtask layers + cargo xtask scorecard --check + cargo nextest run --profile ci --workspace --locked --features heif --lib --bins --tests + cargo nextest run --profile ci --workspace --locked --features heif --lib --bins --tests \ + --run-ignored only -E 'binary_id(photocraft-engine::panic_hunt)' + cargo test --workspace --locked --features heif --doc + + # Windows tests run as two legs in parallel: the unit tests and the integration tests. As one job + # they took ~28 min on the Windows runner; the legs take ~13 and ~15. Splitting the integration + # tests over a third runner (nextest --partition) saved ~2 min of wall time but cost ~7 + # runner-minutes and one more Windows slot per PR. They stay outside the shared gate for sccache, + # which needs its own action step. + windows: needs: platforms - runs-on: ${{ matrix.os }} + if: needs.platforms.outputs.windows == 'true' + timeout-minutes: 100 + name: test (windows-latest, ${{ matrix.part }}) + runs-on: windows-latest strategy: fail-fast: false matrix: - include: ${{ fromJSON(needs.platforms.outputs.legs) }} + part: [unit, integration] env: # No debuginfo on Windows: writing PDBs for ~200 test binaries cost 2.2 of ~21 build minutes. # A panic still names its file and line; only backtrace frames lose theirs (Linux keeps # line-tables-only, so a backtrace with lines is one OS away). - CARGO_PROFILE_DEV_DEBUG: ${{ startsWith(matrix.os, 'windows') && '0' || 'line-tables-only' }} + CARGO_PROFILE_DEV_DEBUG: "0" steps: - - uses: actions/checkout@v4 - - # Unused toolchains in the image (Android SDK, .NET, GHC, CodeQL) are deleted in the - # background: the space is free before the craft-fonts build needs it, and nothing waits. - - name: Install Linux system dependencies (egui/winit/rfd) - if: runner.os == 'Linux' - run: | - nohup sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /usr/local/.ghcup \ - /opt/hostedtoolcache/CodeQL /dev/null 2>&1 & - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \ - libgl1-mesa-dev libgtk-3-dev + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: - components: clippy, rustfmt + components: clippy - # Only main saves caches; PRs restore main's. Every PR used to save its own set (up to - # 3.8 GB), usable by that PR only, so the repo's 10 GB cache budget kept evicting main's. + # Only main saves caches; PRs restore main's, so the repo's 10 GB cache budget keeps main's. # A failing test still saves: the cache holds compiled dependencies, not test results. - # The Windows legs share one key (same dependencies); the integration leg saves it, - # because it also runs Clippy and so leaves Clippy's dependency metadata in it. + # Both legs share one key (same dependencies); the integration leg saves it, because it also + # runs Clippy and so leaves Clippy's dependency metadata in it. - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: - save-if: ${{ github.ref == 'refs/heads/main' && (matrix.part == 'all' || matrix.part == 'integration') }} + save-if: ${{ github.ref == 'refs/heads/main' && matrix.part == 'integration' }} cache-on-failure: true # sccache caches the workspace crates' compiled libraries, which rust-cache leaves out: # warm, each Windows leg built 4.5 min faster. Like rust-cache, only main writes (PRs read # main's entries); a full set is ~90 MB, so it barely touches the 10 GB budget (#2688). # Test executables can't be cached, so a PR still links its tests. - - name: Install sccache (Windows) - if: runner.os == 'Windows' + - name: Install sccache uses: Mozilla-Actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 with: version: v0.18.0 - - name: Use sccache (Windows) - if: runner.os == 'Windows' + - name: Use sccache env: MAIN: ${{ github.ref == 'refs/heads/main' }} run: | @@ -179,149 +208,80 @@ jobs: if [ "$MAIN" != true ]; then echo "SCCACHE_GHA_RW_MODE=READ_ONLY" >> "$GITHUB_ENV"; fi shell: bash - - name: Format - if: runner.os == 'Linux' - run: cargo fmt --all -- --check - - # Keep formatting, strict Clippy, layering and scorecard checks ahead of tests. - name: Clippy - if: matrix.part == 'all' || matrix.part == 'integration' + if: matrix.part == 'integration' run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings - name: Dependency layering - if: matrix.part == 'all' || matrix.part == 'integration' + if: matrix.part == 'integration' run: cargo xtask layers - # docs/scorecard.md must match its sources (checklists, budgets, baseline, corpus floors, - # prefs audit). Static: it reads committed files only, no benchmarks run here. - - name: Scorecard up to date - if: runner.os == 'Linux' - run: cargo xtask scorecard --check - - # nextest runs the test binaries in parallel (cargo test runs them one at a time), with the - # GPU tests capped by the `gpu` group in .config/nextest.toml (#2204). - - uses: taiki-e/install-action@83ac0ad63c0167e6f06796fab0fce28db1bf3db0 # v2.87.22 + - uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29 with: - tool: cargo-nextest@0.9.146 + tool: cargo-nextest@0.9.149 - # `--features heif`: official builds open HEIC photos via the optional photocraft-heif crate. - # `--lib --bins --tests` leaves out the examples, which cargo test would build and link but - # never run (Clippy's `--all-targets` still checks they compile). nextest skips doctests, so - # they get their own step. - # - # On Windows the unit leg runs `--lib --bins` and the integration leg `--test '*'` (every + # The unit leg runs `--lib --bins` and the integration leg `--test '*'` (every # integration-test target); the feature selection stays the same, so both legs share the - # dependency cache. The `ci` profile fails fast and terminates hung tests - # (.config/nextest.toml); assertion failures are not retried. + # dependency cache. - name: Test env: PART: ${{ matrix.part }} run: | case "$PART" in unit) targets=(--lib --bins) ;; - integration) targets=(--test '*') ;; - *) targets=(--lib --bins --tests) ;; + *) targets=(--test '*') ;; esac cargo nextest run --profile ci --workspace --locked --features heif "${targets[@]}" shell: bash - # Every command against adversarial params must return Err, never panic or hang (AGENTS.md - # Rule 9). It is `#[ignore]`d (slow), so Test skips it. Same packages, features and targets as - # Test, so nothing rebuilds: `--test panic_hunt` would drop the other crates' dev-dependency - # features and recompile the whole graph. The `ci` profile terminates it if it hangs. - - name: Panic hunt - if: runner.os == 'Linux' - run: >- - cargo nextest run --profile ci --workspace --locked --features heif --lib --bins --tests - --run-ignored only -E 'binary_id(photocraft-engine::panic_hunt)' - - name: Doctests - if: matrix.part == 'all' || matrix.part == 'unit' + if: matrix.part == 'unit' run: cargo test --workspace --locked --features heif --doc - # The Windows tests used to be one job named `test (windows-latest)`; they're now legs named - # `test (windows-latest, unit)` and `test (windows-latest, integration)`. This job keeps the old - # name reporting, so a required status check on it (none today) would keep working. It fails - # unless the whole test matrix and both Linux variants passed. `needs` reports one result - # for all matrix legs; when a PR runs no Windows legs it still checks every Linux result. - windows-gate: - timeout-minutes: 5 - name: test (windows-latest) - needs: [platforms, test, test-variants] - if: ${{ !cancelled() }} - runs-on: ubuntu-latest + # The same codecs without the optional decoder: HEIC must stay a clear "not in this build" error + # for distributors who leave it out. Runs beside the gate and restores the gate's dependency + # cache without saving one: the dependencies are the same, and the 10 GB cache budget is tight + # (#2688). + test-without-heif: + if: github.event.action != 'labeled' || github.event.label.name == 'full-ci' + timeout-minutes: 60 + name: test (without heif) + # The gate's runner: its cache entry is keyed on the OS. + runs-on: ubuntu-24.04 + env: + # The gate sets these; rust-cache hashes CARGO_* and RUST* variables into its key. + CARGO_INCREMENTAL: 0 + CARGO_NET_RETRY: 10 steps: - - env: - PLATFORMS: ${{ needs.platforms.result }} - TEST: ${{ needs.test.result }} - VARIANTS: ${{ needs.test-variants.result }} - run: | - echo "platforms: $PLATFORMS, test: $TEST, variants: $VARIANTS" - [ "$PLATFORMS" = success ] && [ "$TEST" = success ] && [ "$VARIANTS" = success ] + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - # The two other builds of the Linux tests, in parallel with the test job instead of after it (they - # added ~10 min to its critical path). Each restores the test job's cache (`shared-key: test` is - # that job's key) and saves none: the dependencies are the same, and the 10 GB cache budget is - # tight (#2688). - test-variants: - timeout-minutes: 100 - name: test (${{ matrix.variant }}) - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - variant: [without heif, with craft-fonts] - steps: - - uses: actions/checkout@v4 - - - name: Install Linux system dependencies (egui/winit/rfd) - run: | - nohup sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /usr/local/.ghcup \ - /opt/hostedtoolcache/CodeQL /dev/null 2>&1 & - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \ - libgl1-mesa-dev libgtk-3-dev + - name: Install Linux build dependencies + uses: storytold/craft-common-actions/actions/install-apt-packages@main + with: + packages: >- + libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev + libgtk-3-dev - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + # gate.yml keys its cache `-rust--gate`. - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: - shared-key: test + prefix-key: v1-rust + shared-key: ${{ hashFiles('Cargo.toml') }}-gate save-if: false - - uses: taiki-e/install-action@83ac0ad63c0167e6f06796fab0fce28db1bf3db0 # v2.87.22 + - uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29 with: - tool: cargo-nextest@0.9.146 + tool: cargo-nextest@0.9.149 - # The same codecs without the optional decoder: HEIC must stay a clear "not in this build" - # error for distributors who leave it out. - name: Test without heif - if: matrix.variant == 'without heif' run: cargo nextest run -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked --profile ci - # The tests built with the optional craft-fonts input (crates/text/build.rs), so the Japanese - # font tests that skip without it run. Keep the test job's exact `--workspace --features heif - # --lib --bins --tests`, so the restored dependencies match and only the workspace rebuilds. - # Filter execution to the three crates that read the optional font input (#6127). - - name: Check out craft-fonts - if: matrix.variant == 'with craft-fonts' - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 - path: craft-fonts - - - name: Test with craft-fonts - if: matrix.variant == 'with craft-fonts' - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - run: >- - cargo nextest run --profile ci --workspace --locked --features heif --lib --bins --tests - -E 'package(photocraft-text) | package(photocraft-io) | package(photocraft-ui-egui)' - corpus: + if: github.event.action != 'labeled' || github.event.label.name == 'full-ci' timeout-minutes: 30 # Real-file corpus tests (cargo feature `corpus`), on every PR and on main. The corpora are # pinned in xtask/src/corpus_pins.rs and sha256-verified (xtask/*.sha256) after both a cache @@ -337,7 +297,9 @@ jobs: CARGO_PROFILE_RELEASE_LTO: "false" CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "16" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: @@ -345,7 +307,7 @@ jobs: save-if: ${{ github.ref == 'refs/heads/main' }} cache-on-failure: true - name: Restore test corpora - uses: actions/cache@v4 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: corpus/ key: corpus-${{ hashFiles('xtask/src/corpus_pins.rs', 'xtask/*.sha256') }} @@ -355,11 +317,14 @@ jobs: run: cargo xtask test-corpus wasm: + if: github.event.action != 'labeled' || github.event.label.name == 'full-ci' timeout-minutes: 30 name: wasm32 check runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: targets: wasm32-unknown-unknown @@ -382,7 +347,9 @@ jobs: # Match release.yml so PR results reflect what would actually ship. TRUNK_VERSION: 0.21.14 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: targets: wasm32-unknown-unknown @@ -450,7 +417,9 @@ jobs: - { crate: affinity, name: import_preview, args: "--features io" } - { crate: format, name: load } steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: dtolnay/rust-toolchain@2c76c034f9e32a5d6e2c104dc4df23a22476db04 # nightly - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: @@ -464,7 +433,7 @@ jobs: run: cargo fuzz run ${{ matrix.target.name }} ${{ matrix.target.args }} -- -max_total_time="$FUZZ_SECONDS" - name: Upload crash artifacts if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: fuzz-${{ matrix.target.crate }}-${{ matrix.target.name }} path: crates/${{ matrix.target.crate }}/fuzz/artifacts diff --git a/Cargo.toml b/Cargo.toml index 025d2a218d..154ca201f6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -81,9 +81,9 @@ debug = false [profile.dev] opt-level = 1 # Line tables keep file:line in panic backtraces but drop variable info, which roughly halves link -# time and PDB size for the test binaries (#2204). CI sets the same through -# CARGO_PROFILE_DEV_DEBUG (and no debuginfo at all for the Windows tests). To inspect locals in a -# debugger, build with CARGO_PROFILE_DEV_DEBUG=true. +# time and PDB size for the test binaries (#2204). CI's Windows legs build with no debuginfo at all +# (CARGO_PROFILE_DEV_DEBUG=0). To inspect locals in a debugger, build with +# CARGO_PROFILE_DEV_DEBUG=true. debug = "line-tables-only" # Test builds compile ui-egui without optimization: its library and unit tests were the biggest diff --git a/docs/development.md b/docs/development.md index dc153c4e5a..4b47202d8e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -110,7 +110,7 @@ CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo test --workspace # runs the Japa - **UI:** the Japanese fonts (BIZ UDPGothic Regular first) are the first Japanese fallback in the lazy CJK loader (`crates/ui-egui/src/cjk_fonts.rs`), ahead of the system Japanese fonts and in the same locale script order, appended last to every egui family with the usual baseline alignment. - **Type tool:** the text engine registers them in `FontDb::new` (so also with no system fonts) and puts them first in the Japanese slot of the locale-ordered fallback list: BIZ UDPGothic for sans runs, Shippori Mincho / BIZ UDMincho for serif runs. - **Web:** the wasm32 build never embeds craft-fonts, even with `CRAFT_FONTS_DIR` set: measured on 2026-10-06, the UI face alone took the release wasm from 24.19 MB to 28.87 MB, over the 24 MiB gate in `packaging/web/package.sh`. Instead it loads fonts served next to it, on demand: `fonts/manifest.txt` beside `index.html`, in craft-fonts' manifest format (`crates/text/src/served.rs`; hosting: `packaging/web/README.md` › Fonts). `PHOTOCRAFT_WEB_FONTS_DIR="$PWD/../craft-fonts" packaging/web/package.sh` puts craft-fonts there. Served families are in the font menus and are fetched when picked or when a document's text needs them; they aren't in the script fallback list yet, so Japanese text set in a Latin font still needs a Japanese font picked (#1615). -- Tests that need the fonts skip with a message when `CRAFT_FONTS` is empty; CI runs them again with `CRAFT_FONTS_DIR` set, in its own Linux job (`test (with craft-fonts)`). Desktop releases check out craft-fonts at the commit pinned in `.github/workflows/release.yml` (`CRAFT_FONTS_REF`; ci.yml pins the same commit) and ship each font's `OFL.txt` as `OFL-.txt`. +- Tests that need the fonts skip with a message when `CRAFT_FONTS` is empty; CI's Linux gate builds with `CRAFT_FONTS_DIR` set and `CRAFT_FONTS_REQUIRED=1`, so they run there. Desktop releases check out craft-fonts at the commit pinned in `.github/workflows/release.yml` (`CRAFT_FONTS_REF`; ci.yml pins the same commit) and ship each font's `OFL.txt` as `OFL-.txt`. ## One instance per user From 6da36b61f3eeb758090f1e4155292032b5dea7dd Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 14:41:09 -0400 Subject: [PATCH 39/53] release: the git-sources gate moves into craft-common-actions (storytold/craft-common-actions#78) (#8952) The check is shared at release.yml@main now; keep only the reusable call. --- .github/workflows/release.yml | 40 ----------------------------------- 1 file changed, 40 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 72b0461076..bfdca9e3e9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,47 +18,7 @@ concurrency: cancel-in-progress: false jobs: - git-sources: - name: Git dependencies come from storytold - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Git dependencies come from storytold - run: | - set -euo pipefail - test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; } - # Source replacement, patches and path overrides in cargo config never show up - # in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key. - # Cargo still reads the legacy .cargo/config. - python3 - <<'PY' - import os, sys, tomllib - for path in (".cargo/config", ".cargo/config.toml"): - if not os.path.exists(path): - continue - config = tomllib.load(open(path, "rb")) - replaced = [name for name, source in config.get("source", {}).items() - if isinstance(source, dict) and "replace-with" in source] - if replaced or "patch" in config or "paths" in config: - print(f"{path} replaces, patches or overrides a source") - sys.exit(1) - PY - # Fails when Cargo.lock is out of date with the manifests. - cargo metadata --locked --format-version 1 > /dev/null - python3 - <<'PY' - import sys, tomllib - lock = tomllib.load(open("Cargo.lock", "rb")) - bad = sorted({p["source"] for p in lock.get("package", []) - if p.get("source", "").startswith("git+") - and not p["source"].startswith("git+https://github.com/storytold/")}) - if bad: - print("Git dependencies outside github.com/storytold:", *bad, sep="\n") - sys.exit(1) - PY - release: - needs: git-sources # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/release.yml@main with: From 3729c3f664329ea199371c00ba24eb3702410298 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 14:41:44 -0400 Subject: [PATCH 40/53] security(raster,doc): count tile grids without walking them; share rebuilt tiles; index slice merges Rect::tile_count counts a rect's tiles in u64; try_take_tiles uses it (no usize overflow on 32-bit). Surface::translated reuses one rebuilt tile for equal source-tile windows. auto_slices merges vertically adjacent runs through a hash index instead of searching the output. --- crates/doc/src/slices.rs | 18 ++++++++++---- crates/geom/src/lib.rs | 8 +++++++ crates/raster/src/lib.rs | 52 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 72 insertions(+), 6 deletions(-) diff --git a/crates/doc/src/slices.rs b/crates/doc/src/slices.rs index 7fb6ba3ad5..9e66752833 100644 --- a/crates/doc/src/slices.rs +++ b/crates/doc/src/slices.rs @@ -241,12 +241,22 @@ pub fn auto_slices(canvas: Rect, taken: &[Rect]) -> Vec { } } // Merge vertically adjacent runs with the same columns. + // `open` finds the slice a run continues by its columns and bottom edge: searching the output + // for each run is quadratic in the runs, and bars crossing in a grid leave a million of them. let mut out: Vec = Vec::new(); + let mut open: std::collections::HashMap<(i32, i32, i32), usize> = std::collections::HashMap::new(); for r in runs { - if let Some(prev) = out.iter_mut().find(|p| p.x0 == r.x0 && p.x1 == r.x1 && p.y1 == r.y0) { - prev.y1 = r.y1; - } else { - out.push(r); + match open.remove(&(r.x0, r.x1, r.y0)) { + Some(i) => { + if let Some(prev) = out.get_mut(i) { + prev.y1 = r.y1; + } + open.insert((r.x0, r.x1, r.y1), i); + } + None => { + open.insert((r.x0, r.x1, r.y1), out.len()); + out.push(r); + } } } out diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index d95e4b847f..acd422b32f 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -96,6 +96,14 @@ impl Rect { pub fn inflate(&self, d: i32) -> Rect { Rect::new(self.x0.saturating_sub(d), self.y0.saturating_sub(d), self.x1.saturating_add(d), self.y1.saturating_add(d)) } + /// How many tiles overlap this rect, without visiting them (a rect can span billions). + pub fn tile_count(&self) -> u64 { + if self.is_empty() { + return 0; + } + let (a, b) = (TileCoord::containing(self.x0, self.y0), TileCoord::containing(self.x1 - 1, self.y1 - 1)); + ((i64::from(b.tx) - i64::from(a.tx) + 1) as u64) * ((i64::from(b.ty) - i64::from(a.ty) + 1) as u64) + } /// All tiles overlapping this rect. pub fn tiles(&self) -> impl Iterator + use<> { let (a, b) = if self.is_empty() { diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 49806e521c..6ce0ea688d 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -732,7 +732,9 @@ impl Surface { /// Fallible counterpart to [`Surface::take_tiles`]. Reserves the complete result and any /// default tile before removing a tile, so a reservation failure leaves the surface intact. pub fn try_take_tiles(&mut self, r: Rect) -> Result)>, AllocationError> { - let count = r.tiles().count(); + // Counted in u64 and checked before narrowing: on 32-bit targets the grid of a sparse + // surface's bounds can pass `usize` (and visiting it to count it takes just as long). + let count = usize::try_from(r.tile_count()).map_err(|_| AllocationError::SizeOverflow)?; let bytes = count.checked_mul(std::mem::size_of::<(TileCoord, Arc)>()).ok_or(AllocationError::SizeOverflow)?; let mut out = Vec::new(); out.try_reserve_exact(count).map_err(|_| AllocationError::NotEnoughMemory { bytes })?; @@ -890,6 +892,10 @@ impl Surface { // One tile row of the default pixel, to compare a finished tile with a row at a time. let default_row = self.default_pixel.repeat(TILE_SIZE.unsigned_abs() as usize); + // Target tiles rebuilt from the same source tiles at the same offset are the same tile: + // a bundle can point thousands of tile references at one payload, and a move would + // otherwise copy it into a separate buffer for each. + let built: std::sync::Mutex, Option>>> = Default::default(); let build_target = |dc: TileCoord| -> Option<(TileCoord, Arc)> { let dr = dc.rect(); let sr = dr.translate(dx.saturating_neg(), dy.saturating_neg()); @@ -904,6 +910,14 @@ impl Surface { // A tile of only the default pixel is left out, as `prune` would. return (shared.data.get(..bpp) != Some(&*self.default_pixel)).then_some((dc, shared)); } + // The identities of the source tiles under a covered window (the offset is the same + // for every target of this call). + let memo_key: Option> = covered.then(|| sr.tiles().map(|sc| self.tiles.get(&sc).map_or(0, |t| Arc::as_ptr(t) as usize)).collect()); + if let Some(k) = &memo_key + && let Some(hit) = built.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(k) + { + return hit.clone().map(|t| (dc, t)); + } let mut tile = Tile { data: vec![0u8; (TILE_SIZE * TILE_SIZE) as usize * bpp].into_boxed_slice() }; let (tx0, ty0) = (i64::from(dc.tx) * i64::from(TILE_SIZE), i64::from(dc.ty) * i64::from(TILE_SIZE)); @@ -984,7 +998,11 @@ impl Surface { } // A target that got only default pixels isn't stored, as `prune` would drop it. Checked // after the copy, a row at a time: a tile with content stops at its first such row. - tile.data.chunks(default_row.len()).any(|row| row != default_row).then(|| (dc, Arc::new(tile))) + let finished = tile.data.chunks(default_row.len()).any(|row| row != default_row).then(|| Arc::new(tile)); + if let Some(k) = memo_key { + built.lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert(k, finished.clone()); + } + finished.map(|t| (dc, t)) }; #[cfg(not(target_arch = "wasm32"))] { @@ -1835,6 +1853,36 @@ mod tests { let _ = s.translated(i32::MIN + 1, 3, all); } + /// Many references to one payload, moved off the tile grid: the rebuilt tiles that read the + /// same source tiles at the same offset are one tile, not a copy each. + #[test] + fn translated_shares_tiles_rebuilt_from_shared_sources() { + let fmt = PixelFormat::RGBA8; + let mut one = Surface::new(fmt); + let r = Rect::new(0, 0, TILE_SIZE, TILE_SIZE); + let px: Vec = (0..r.width() * r.height()).flat_map(|i| [(i % 251) as f32 / 255.0, (i % 7) as f32 / 7.0, 0.5, 1.0]).collect(); + one.write_region(r, &px); + let payload = Arc::clone(one.tile(TileCoord::new(0, 0)).unwrap()); + let mut s = Surface::new(fmt); + s.put_tiles((0..16).flat_map(|ty| (0..16).map(move |tx| TileCoord::new(tx, ty))).map(|c| (c, Arc::clone(&payload)))); + let moved = s.translated(7, 5, s.content_bounds()); + let distinct: std::collections::HashSet<_> = moved.tiles().map(|(_, t)| Arc::as_ptr(t)).collect(); + // The 15 × 15 interior tiles read the same four source tiles at the same offset, so they are + // one tile; only the 64 border tiles (cut by the content's edge) are built one by one. + assert!(moved.tile_count() == 289 && distinct.len() <= 64 + 5, "{} tiles, {} distinct", moved.tile_count(), distinct.len()); + let all = Rect::new(-50, -50, 16 * TILE_SIZE + 50, 16 * TILE_SIZE + 50); + assert_eq!(moved.read_region(all), s.read_region(all.translate(-7, -5))); + } + + #[test] + fn a_surface_bounds_tile_count_is_counted_not_walked() { + let mut s = Surface::new(PixelFormat::RGBA8); + let far = Rect::new(0, 0, i32::MAX, i32::MAX); + assert_eq!(far.tile_count(), u64::from(i32::MAX as u32 / TILE_SIZE as u32 + 1).pow(2)); + // Not allocated: 2^46 entries is far past any reservation. + assert!(s.try_take_tiles(far).is_err()); + } + #[test] fn translated_stores_no_tiles_of_only_the_default_pixel() { let only_default = |t: &Surface| { From 93e605a2e937e6200c3eaf8cc5c49dc45bdc5f65 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 14:41:49 -0400 Subject: [PATCH 41/53] security(ipp): total reply deadline; resumable chunk walk --- crates/ipp/src/lib.rs | 15 +++++++++++++-- crates/ipp/src/tests.rs | 29 ++++++++++++++++++++++++----- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/crates/ipp/src/lib.rs b/crates/ipp/src/lib.rs index d0535a86ce..bbf6efffec 100644 --- a/crates/ipp/src/lib.rs +++ b/crates/ipp/src/lib.rs @@ -67,6 +67,8 @@ pub struct Printer { /// Timeouts: connecting, and each request (a Print-Job carries the whole PDF). const CONNECT: Duration = Duration::from_secs(3); const REQUEST: Duration = Duration::from_secs(60); +/// The whole reply, however it trickles in: the timeouts above only cover each read. +const REPLY_DEADLINE: Duration = Duration::from_secs(120); /// Largest reply read. const MAX_REPLY: usize = 16 << 20; /// Largest number of attributes taken from a reply. @@ -434,7 +436,13 @@ fn read_response(stream: &mut Stream) -> Result> { let mut body_at = None; let mut expected: Option = None; let mut chunked = false; + // Where the chunk walk resumes, so each read costs its own bytes, not the whole body again. + let mut chunk_at = 0usize; + let started = std::time::Instant::now(); loop { + if started.elapsed() > REPLY_DEADLINE { + return Err(Error::Protocol("the print server took too long to reply".into())); + } let n = stream.read(&mut buf).map_err(|e| Error::Protocol(format!("reading: {e}")))?; if n == 0 { return Ok(raw); @@ -455,7 +463,7 @@ fn read_response(stream: &mut Stream) -> Result> { let body = raw.get(at..).unwrap_or_default(); let complete = match expected { Some(len) if !chunked => body.len() >= len, - _ if chunked => chunked_complete(body), + _ if chunked => chunked_complete(body, &mut chunk_at), _ => false, }; if complete { @@ -483,8 +491,11 @@ fn http_response(raw: &[u8]) -> Result<(u16, Headers, &[u8])> { /// `0\r\n\r\n` inside a chunk's payload (an attribute value can hold it) does not count. A /// malformed or impossible size counts as complete, so [`dechunk`] reports it instead of /// waiting for more. -fn chunked_complete(mut b: &[u8]) -> bool { +fn chunked_complete(body: &[u8], resume: &mut usize) -> bool { + let mut b = body.get(*resume..).unwrap_or_default(); loop { + // Chunks already walked are not walked again: `resume` marks the start of this one. + *resume = body.len() - b.len(); let Some(line_end) = b.windows(2).position(|w| w == b"\r\n") else { return false }; let size_str = String::from_utf8_lossy(b.get(..line_end).unwrap_or(&[])); let Ok(size) = usize::from_str_radix(size_str.split(';').next().unwrap_or_default().trim(), 16) else { return true }; diff --git a/crates/ipp/src/tests.rs b/crates/ipp/src/tests.rs index 394ff758d1..518c9dfa39 100644 --- a/crates/ipp/src/tests.rs +++ b/crates/ipp/src/tests.rs @@ -429,10 +429,29 @@ fn a_last_chunk_pattern_inside_a_value_does_not_end_the_reply() { assert_eq!(list.len(), 1); assert_eq!(list[0].name, "Of\r\n0\r\n\r\nfice"); // The boundary walk itself: complete only at a zero-size chunk, never on payload bytes. - assert!(!chunked_complete(b"5\r\nab\r\n0")); - assert!(!chunked_complete(b"7\r\n\r\n0\r\n\r\nx\r\n")); - assert!(chunked_complete(b"7\r\n\r\n0\r\n\r\nx\r\n0\r\n\r\n")); - assert!(chunked_complete(b"zz\r\n") && dechunk(b"zz\r\n").is_err()); + assert!(!complete(b"5\r\nab\r\n0")); + assert!(!complete(b"7\r\n\r\n0\r\n\r\nx\r\n")); + assert!(complete(b"7\r\n\r\n0\r\n\r\nx\r\n0\r\n\r\n")); + assert!(complete(b"zz\r\n") && dechunk(b"zz\r\n").is_err()); +} + +/// [`chunked_complete`] on a whole body at once. +fn complete(body: &[u8]) -> bool { + chunked_complete(body, &mut 0) +} + +/// Fed a byte at a time, as a server dripping the reply would, the walk resumes where it stopped +/// and agrees with the one-shot answer at every length. +#[test] +fn the_chunk_walk_resumes_instead_of_starting_over() { + let body = b"3\r\nabc\r\n10\r\n0123456789abcdef\r\n0\r\n\r\n"; + let mut at = 0; + for len in 0..=body.len() { + let prefix = &body[..len]; + assert_eq!(chunked_complete(prefix, &mut at), complete(prefix), "{len}"); + assert!(at <= len); + } + assert!(at > 0, "it moved past the chunks already walked"); } /// A chunk size that cannot be satisfied (`usize::MAX`, `usize::MAX - 1`, anything past the @@ -440,7 +459,7 @@ fn a_last_chunk_pattern_inside_a_value_does_not_end_the_reply() { #[test] fn an_impossible_chunk_size_is_a_protocol_error_not_a_panic() { for size_line in ["ffffffffffffffff", "fffffffffffffffe", "1000001"] { - assert!(chunked_complete(format!("{size_line}\r\n").as_bytes())); + assert!(complete(format!("{size_line}\r\n").as_bytes())); assert!(dechunk(format!("{size_line}\r\nab\r\n").as_bytes()).is_err()); let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let port = listener.local_addr().unwrap().port(); From 530e0b6b701a704508e1dcd4640ede4734e53a70 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 14:41:52 -0400 Subject: [PATCH 42/53] security(plugins): reject host routing keys, bound bands and start fuel, open without blocking on FIFOs --- Cargo.lock | 1 + crates/plugins/Cargo.toml | 4 ++++ crates/plugins/src/apply.rs | 10 +++++++++ crates/plugins/src/manifest.rs | 9 +++++++- crates/plugins/src/registry.rs | 20 +++++++++++++++-- crates/plugins/src/runtime.rs | 5 +++-- crates/plugins/tests/abi.rs | 41 ++++++++++++++++++++++++++++++++++ 7 files changed, 85 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d35b8a14c3..1df0af69ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4963,6 +4963,7 @@ dependencies = [ "photocraft-geom", "photocraft-raster", "rayon", + "rustix 1.1.5", "serde", "serde_json", "thiserror 2.0.21", diff --git a/crates/plugins/Cargo.toml b/crates/plugins/Cargo.toml index 09a69ca838..a88b7711f3 100644 --- a/crates/plugins/Cargo.toml +++ b/crates/plugins/Cargo.toml @@ -24,6 +24,10 @@ wasmi = { version = "2.0.0", default-features = false, features = ["std", "valid [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rayon = { workspace = true } +# Opening a plug-in file without blocking on a FIFO (the flag constant only). +[target.'cfg(unix)'.dependencies] +rustix = { version = "1.1", default-features = false, features = ["std", "fs"] } + [dev-dependencies] # WebAssembly text -> binary for the hostile-module fixtures (Apache-2.0 WITH LLVM-exception). wat = "1.228" diff --git a/crates/plugins/src/apply.rs b/crates/plugins/src/apply.rs index 7a144a28b9..ba3f29f89d 100644 --- a/crates/plugins/src/apply.rs +++ b/crates/plugins/src/apply.rs @@ -70,6 +70,9 @@ fn read_clamped(s: &Surface, rect: Rect, extent: Rect) -> Vec { out } +/// Most bands one run is split into. +const MAX_BANDS: u64 = 1 << 16; + impl Plugin { /// The rectangle a run writes: the layer's content (grown by the plug-in's overlap) or the /// canvas, limited to the selection's bounds. @@ -113,6 +116,13 @@ impl Plugin { } let mut bands = Vec::new(); let (t0, t1) = (TileCoord::containing(area.x0, area.y0), TileCoord::containing(area.x1 - 1, area.y1 - 1)); + // Counted before any is made: a few distant tiles make `area` a grid of millions of + // bands, each a call into the plug-in, planned before the deadline starts. + let columns = (i64::from(t1.tx) - i64::from(t0.tx) + 1).max(0) as u64; + let rows = (i64::from(t1.ty) - i64::from(t0.ty) + 1).max(0) as u64; + if columns.div_ceil(across as u64).saturating_mul(rows) > MAX_BANDS { + return Err(Error::Limit(format!("more than {MAX_BANDS} bands (the layer's content is spread too widely)"))); + } for ty in t0.ty..=t1.ty { let mut tx = t0.tx; while tx <= t1.tx { diff --git a/crates/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs index 081c7acf6d..2606b34c2b 100644 --- a/crates/plugins/src/manifest.rs +++ b/crates/plugins/src/manifest.rs @@ -398,8 +398,12 @@ fn visible(c: char) -> bool { !c.is_whitespace() && !c.is_control() && !in_ranges(c, FORMAT) && !in_ranges(c, BLANK) } +/// Names the host's `plugin.run` command takes for itself: a parameter of the same name would be +/// read as the command's routing (which layer or channel is filtered), not as the plug-in's. +const HOST_KEYS: [&str; 4] = ["id", "layer", "target", "channel"]; + fn valid_key(k: &str) -> bool { - (1..=64).contains(&k.len()) && k.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') && !k.starts_with('_') && k != "id" && k != "layer" + (1..=64).contains(&k.len()) && k.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') && !k.starts_with('_') && !HOST_KEYS.contains(&k) } #[cfg(test)] @@ -471,6 +475,9 @@ mod tests { r#"{"id":"a","name":"x","kind":"filter","params":{"r":{"type":"number","min":5,"max":1,"default":1}}}"#, r#"{"id":"a","name":"x","kind":"filter","params":{"c":{"type":"choice","options":[]}}}"#, r#"{"id":"a","name":"x","kind":"filter","params":{"__x":{"type":"bool"}}}"#, + // Names the host command routes by: a plug-in default must not steer the filter. + r#"{"id":"a","name":"x","kind":"filter","params":{"target":{"type":"bool","default":false}}}"#, + r#"{"id":"a","name":"x","kind":"filter","params":{"channel":{"type":"int","min":0,"max":3,"default":1}}}"#, r#"{"id":"a","name":"x","kind":"filter","params":[1]}"#, ] { assert!(Manifest::parse(bad.as_bytes()).is_err(), "{bad}"); diff --git a/crates/plugins/src/registry.rs b/crates/plugins/src/registry.rs index 92eb5f3f1b..c819fb3de6 100644 --- a/crates/plugins/src/registry.rs +++ b/crates/plugins/src/registry.rs @@ -54,19 +54,35 @@ pub fn revision() -> u64 { REVISION.load(Ordering::Relaxed) } +/// Opens `path` for reading without waiting on a FIFO or device that has no writer. +#[cfg(unix)] +fn open_regular(path: &std::path::Path) -> std::io::Result { + use std::os::unix::fs::OpenOptionsExt; + // O_NONBLOCK: opening a writer-less FIFO read-only returns at once; the caller then sees it + // is not a regular file. Regular files ignore the flag. + std::fs::OpenOptions::new().read(true).custom_flags(rustix::fs::OFlags::NONBLOCK.bits() as i32).open(path) +} + +#[cfg(not(unix))] +fn open_regular(path: &std::path::Path) -> std::io::Result { + std::fs::File::open(path) +} + /// Reads a `.wasm` file (refusing anything that isn't a regular file or exceeds the module limit) /// and loads it. #[cfg(not(target_arch = "wasm32"))] pub fn load_file(path: &std::path::Path, limits: Limits) -> Result { use std::io::Read; - let meta = std::fs::metadata(path).map_err(|e| Error::Io(format!("{}: {e}", path.display())))?; + // Open first, without blocking on a FIFO, and judge the file that was opened: a name checked + // and then opened again can be swapped for a special file in between. + let f = open_regular(path).map_err(|e| Error::Io(format!("{}: {e}", path.display())))?; + let meta = f.metadata().map_err(|e| Error::Io(format!("{}: {e}", path.display())))?; if !meta.is_file() { return Err(Error::Io(format!("{} is not a file", path.display()))); } if meta.len() > limits.max_module_bytes as u64 { return Err(Error::Module(format!("{} is {} bytes (limit {})", path.display(), meta.len(), limits.max_module_bytes))); } - let f = std::fs::File::open(path).map_err(|e| Error::Io(format!("{}: {e}", path.display())))?; let mut bytes = Vec::new(); f.take(limits.max_module_bytes as u64 + 1).read_to_end(&mut bytes).map_err(|e| Error::Io(format!("{}: {e}", path.display())))?; Ok(Plugin::load(&bytes, limits)?.with_source(path.display().to_string())) diff --git a/crates/plugins/src/runtime.rs b/crates/plugins/src/runtime.rs index e436133b92..c82171a5ac 100644 --- a/crates/plugins/src/runtime.rs +++ b/crates/plugins/src/runtime.rs @@ -184,8 +184,9 @@ impl Plugin { let limits = StoreLimitsBuilder::new().memory_size(self.limits.max_memory_bytes).memories(1).tables(4).table_elements(100_000).instances(1).build(); let mut store = Store::new(&self.engine, limits); store.limiter(|l| l); - // The start function (if any) runs on this fuel. - store.set_fuel(self.limits.fuel_base).map_err(wasm_err)?; + // The start function (if any) runs on this fuel. It cannot be interrupted to check the + // deadline the way a call can, so it gets no more than one slice, however much a call may use. + store.set_fuel(self.limits.fuel_base.min(FUEL_SLICE)).map_err(wasm_err)?; let linker = Linker::::new(&self.engine); let instance = linker.instantiate_and_start(&mut store, &self.module).map_err(wasm_err)?; let memory = instance.get_memory(&store, "memory").ok_or_else(|| Error::Abi("the module must export its linear memory as `memory`".into()))?; diff --git a/crates/plugins/tests/abi.rs b/crates/plugins/tests/abi.rs index a75eedd88b..e0d6d8aa35 100644 --- a/crates/plugins/tests/abi.rs +++ b/crates/plugins/tests/abi.rs @@ -317,3 +317,44 @@ fn folder_loading_skips_bad_files() { assert!(photocraft_plugins::registry::load_file(&dir, Limits::default()).is_err(), "a directory is not a module"); std::fs::remove_dir_all(&dir).ok(); } + +#[test] +fn a_start_function_that_spins_is_cut_off_at_one_slice() { + // `fuel_base` is what a call may use; the start function cannot be interrupted to check the + // clock, so it gets one slice of it at most. + let spin = "(func $spin (loop $l (br $l))) (start $spin)"; + let limits = Limits { fuel_base: u64::MAX / 4, ..Limits::default() }; + let (error, took) = run_err(&module(MANIFEST, 1, "(i32.const 0)", spin), limits); + assert!(matches!(error, Error::Trap(_) | Error::Limit(_)), "{error:?}"); + assert!(took < Duration::from_secs(30), "{took:?}"); +} + +#[test] +fn content_spread_over_millions_of_bands_is_refused_before_planning_them() { + let p = Plugin::load(INVERT, Limits::default()).unwrap(); + let mut s = Surface::new(PixelFormat::RGBA8); + s.write_pixel(0, 0, &[0.1, 0.2, 0.3, 1.0]); + s.write_pixel(4_000_000, 4_000_000, &[0.1, 0.2, 0.3, 1.0]); + let t = Instant::now(); + let e = p.apply(&s, Rect::new(0, 0, 8, 8), None, &json!({})).expect_err("a 15600 × 15600 tile grid is not one run"); + assert!(matches!(e, Error::Limit(_)), "{e:?}"); + assert!(t.elapsed() < Duration::from_secs(5), "{:?}", t.elapsed()); +} + +#[cfg(unix)] +#[test] +fn a_fifo_in_place_of_a_module_file_does_not_block_loading() { + let dir = std::env::temp_dir().join(format!("photocraft-plugin-fifo-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("evil.wasm"); + let _ = std::fs::remove_file(&path); + rustix::fs::mknodat(rustix::fs::CWD, &path, rustix::fs::FileType::Fifo, rustix::fs::Mode::RUSR | rustix::fs::Mode::WUSR, 0).unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + let p = path.clone(); + std::thread::spawn(move || { + let _ = tx.send(photocraft_plugins::registry::load_file(&p, Limits::default()).map(|_| ())); + }); + let r = rx.recv_timeout(Duration::from_secs(10)).expect("loading a FIFO must not block"); + assert!(matches!(r, Err(Error::Io(_))), "{r:?}"); + let _ = std::fs::remove_dir_all(&dir); +} From 78ae50f7b2c470a062506cce8ddb429db802c606 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 14:41:53 -0400 Subject: [PATCH 43/53] security: bisect flat-table and gradient-stop lookups; charge ICC curve samples; bound PSD planes, RAW ljpeg retention and TIFF dump preview --- crates/cms/src/curve.rs | 26 ++++++++++++++++++++-- crates/cms/src/profile.rs | 45 ++++++++++++++++++++++++++++++++++++++ crates/psd/src/pixels.rs | 8 ++++--- crates/raw/src/sensor.rs | 5 ++++- crates/raw/src/tiff.rs | 13 ++++++++++- crates/vector/src/lib.rs | 27 ++++++++++------------- crates/vector/src/tests.rs | 31 ++++++++++++++++++++++++++ 7 files changed, 132 insertions(+), 23 deletions(-) diff --git a/crates/cms/src/curve.rs b/crates/cms/src/curve.rs index 8b619842d1..963ae3cdb5 100644 --- a/crates/cms/src/curve.rs +++ b/crates/cms/src/curve.rs @@ -293,15 +293,22 @@ fn table_inverse_with(t: &[f32], y: f32, increasing: bool, edge_start: f32, edge /// For a flat run at the table start (`start = true`) the last index of the run, or for a flat /// run at the end the first index of it, as a normalised input. +/// +/// A table that is inverted is monotonic, so the run is found by bisection, not by a scan: a long +/// constant table is inverted once per pixel, and scanning it each time made the cost the curve's +/// length times the image's. fn edge_index(t: &[f32], start: bool) -> f32 { let n = t.len(); if start { let v = t[0]; - let i = t.iter().position(|x| (*x - v).abs() > 1e-7).map_or(n - 1, |i| i - 1); + // The first value that differs, given they do so from there on. + let first_other = t.partition_point(|x| (*x - v).abs() <= 1e-7); + let i = if first_other >= n { n - 1 } else { first_other.saturating_sub(1) }; i as f32 / (n - 1) as f32 } else { let v = t[n - 1]; - let i = t.iter().rposition(|x| (*x - v).abs() > 1e-7).map_or(0, |i| i + 1); + // The start of the trailing run equal to the last value. + let i = t.partition_point(|x| (*x - v).abs() > 1e-7); i as f32 / (n - 1) as f32 } } @@ -362,6 +369,21 @@ mod tests { assert!((g.eval64(-0.5) + 0.5f64.powf(2.2)).abs() < 1e-12); } + #[test] + fn inverting_a_long_flat_table_does_not_rescan_it_for_every_pixel() { + // Four million zeros and a one: a scan per call would be 8·10¹¹ steps for these calls. + let mut t = vec![0.0f32; 4_000_000]; + t.push(1.0); + let c = Curve::Table(t); + let started = std::time::Instant::now(); + let mut last = 0.0; + for _ in 0..200_000 { + last = c.eval_inverse(0.0); + } + assert!(last > 0.99, "the flat start ends at the last zero: {last}"); + assert!(started.elapsed().as_secs() < 10, "{:?}", started.elapsed()); + } + #[test] fn inverse_table_matches_per_call_including_flat_runs() { // Batch inverse must equal calling eval_inverse64 per point. Include a table with flat diff --git a/crates/cms/src/profile.rs b/crates/cms/src/profile.rs index 99284772f3..01b2327df8 100644 --- a/crates/cms/src/profile.rs +++ b/crates/cms/src/profile.rs @@ -184,6 +184,7 @@ pub struct Profile { impl Profile { /// Parses ICC profile bytes. pub fn parse(bytes: &[u8]) -> Result { + CURVE_SAMPLES_LEFT.with(|left| left.set(MAX_CURVE_SAMPLES)); let mut p = parse_profile(bytes)?; p.bytes = Some(Arc::new(bytes.to_vec())); Ok(p) @@ -585,6 +586,24 @@ fn parse_profile(bytes: &[u8]) -> Result { Ok(p) } +/// Sampled-curve entries one profile may decode in all (64 MB of `f32`). Tags and curve groups can +/// point at the same encoded bytes, and each reference decodes its own table. +const MAX_CURVE_SAMPLES: usize = 16 << 20; + +thread_local! { + /// What is left of [`MAX_CURVE_SAMPLES`] for the profile being parsed on this thread. + static CURVE_SAMPLES_LEFT: std::cell::Cell = const { std::cell::Cell::new(MAX_CURVE_SAMPLES) }; +} + +/// Charges `n` decoded curve samples to the profile being parsed. +fn charge_curve_samples(n: usize) -> Result<(), CmsError> { + CURVE_SAMPLES_LEFT.with(|left| { + let rest = left.get().checked_sub(n).ok_or_else(|| CmsError::Invalid("the profile's curves hold too many samples".into()))?; + left.set(rest); + Ok(()) + }) +} + /// Parses a `curv` or `para` element; returns the curve and the element's byte length /// (unpadded). fn parse_curve(d: &[u8]) -> Result<(Curve, usize), CmsError> { @@ -602,6 +621,7 @@ fn parse_curve(d: &[u8]) -> Result<(Curve, usize), CmsError> { 1 => Ok((Curve::Gamma(r.u16(12)? as f64 / 256.0), len)), _ => { let s = r.slice(12, n * 2)?; + charge_curve_samples(n)?; let t = s.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect(); Ok((Curve::Table(t), len)) } @@ -959,3 +979,28 @@ mod tests { assert_eq!(eval(&cached(srgb, lab, opts).unwrap()), sr); } } + +#[cfg(test)] +mod curve_budget_tests { + use super::*; + + #[test] + fn aliased_curves_are_charged_for_each_decoded_table() { + // One 1M-sample `curv` element referenced seventeen times decodes 17M samples. + let mut element = b"curv\0\0\0\0".to_vec(); + element.extend((1u32 << 20).to_be_bytes()); + element.extend(std::iter::repeat_n(0u8, 2 << 20)); + CURVE_SAMPLES_LEFT.with(|left| left.set(MAX_CURVE_SAMPLES)); + let mut failed_at = None; + for i in 0..17 { + if parse_curve(&element).is_err() { + failed_at = Some(i); + break; + } + } + assert_eq!(failed_at, Some(16), "sixteen tables fit the budget, the seventeenth does not"); + // Each profile parse starts with a fresh budget. + CURVE_SAMPLES_LEFT.with(|left| left.set(MAX_CURVE_SAMPLES)); + assert!(parse_curve(&element).is_ok()); + } +} diff --git a/crates/psd/src/pixels.rs b/crates/psd/src/pixels.rs index 5af4903c55..47828c82d5 100644 --- a/crates/psd/src/pixels.rs +++ b/crates/psd/src/pixels.rs @@ -245,12 +245,14 @@ impl<'a> Layer<'a> { let rect = self.record.rect; let (w, h) = rect.size()?; let n = w.checked_mul(h).ok_or(PsdError::LimitExceeded("layer size overflow"))?; - if n.checked_mul(4).is_none_or(|len| len as u64 > crate::compression::MAX_DECODED_BYTES) { - return Err(PsdError::LimitExceeded("RGBA output exceeds MAX_DECODED_BYTES")); - } let depth = self.header.depth; let mode = self.header.color_mode; let cc = color_channel_count(mode)?; + // The planes (one per colour channel, filled in for channels the layer lacks, and alpha) + // coexist with the RGBA output, so the cap covers all of them, not the output alone. + if n.checked_mul(cc + 2 + 4).is_none_or(|len| len as u64 > crate::compression::MAX_DECODED_BYTES) { + return Err(PsdError::LimitExceeded("RGBA output exceeds MAX_DECODED_BYTES")); + } let mut color = Vec::with_capacity(cc); for id in 0..cc as i16 { let plane = if self.record.channel(id).is_some() { diff --git a/crates/raw/src/sensor.rs b/crates/raw/src/sensor.rs index cab5ce2a05..6ea536aaa8 100644 --- a/crates/raw/src/sensor.rs +++ b/crates/raw/src/sensor.rs @@ -314,7 +314,7 @@ pub(crate) fn read_plane(t: &Tiff, ifd: &Ifd, limits: &Limits, layout: JpegLayou let scatter = if layout == JpegLayout::Quads && samples == 1 { s.w.saturating_mul(s.h).saturating_mul(2) as u64 } else { 0 }; let budget = usize::try_from(limits.max_alloc.saturating_sub(scatter) / 2).unwrap_or(usize::MAX); let geom = s.w.saturating_mul(s.h).saturating_mul(samples).saturating_mul(4); - let (f, v) = ljpeg::decode(src, geom.min(budget))?; + let (f, mut v) = ljpeg::decode(src, geom.min(budget))?; if v.len() < need { return Err(RawError::malformed("lossless JPEG tile is smaller than its tile")); } @@ -329,6 +329,9 @@ pub(crate) fn read_plane(t: &Tiff, ifd: &Ifd, limits: &Limits, layout: JpegLayou } return Ok(tile); } + // The frame may be declared larger than the tile; keep (and so charge) the tile. + v.truncate(s.w.saturating_mul(s.h).saturating_mul(samples)); + v.shrink_to_fit(); Ok(v) } } diff --git a/crates/raw/src/tiff.rs b/crates/raw/src/tiff.rs index ca190a92f0..df695e3346 100644 --- a/crates/raw/src/tiff.rs +++ b/crates/raw/src/tiff.rs @@ -305,6 +305,17 @@ impl<'a> Tiff<'a> { (!s.is_empty()).then_some(s) } + /// [`Tiff::ascii`] for diagnostics: reads at most `limit` bytes (and a few more, so a + /// character cut at the limit still decodes) of a value that may be gigabytes long and + /// shared by many entries. + pub fn ascii_prefix(&self, e: &Entry, limit: usize) -> Option { + let b = self.raw(e)?; + let b = b.get(..b.len().min(limit.saturating_add(4)))?; + let end = b.iter().position(|&c| c == 0).unwrap_or(b.len()); + let s = String::from_utf8_lossy(b.get(..end)?).trim().to_string(); + (!s.is_empty()).then_some(s) + } + pub fn tag_uint(&self, ifd: &Ifd, t: u16) -> Option { ifd.get(t).and_then(|e| self.uint(e, 0)) } @@ -345,7 +356,7 @@ pub(crate) fn dump(data: &[u8]) -> String { break 'ifds; } let preview: String = if e.typ == 2 { - let mut a = t.ascii(e).unwrap_or_default(); + let mut a = t.ascii_prefix(e, MAX_ASCII_PREVIEW).unwrap_or_default(); if a.len() > MAX_ASCII_PREVIEW { // Truncate at a char boundary at or below the cap (lossy UTF-8 can hold // multi-byte replacement chars). diff --git a/crates/vector/src/lib.rs b/crates/vector/src/lib.rs index 2492414d2a..d6c21e3ac3 100644 --- a/crates/vector/src/lib.rs +++ b/crates/vector/src/lib.rs @@ -424,15 +424,13 @@ fn sample_color(stops: &[(f32, [f32; 4])], mids: &[f32], t: f32) -> [f32; 4] { if t <= first.0 { return first.1; } - for (i, w) in stops.windows(2).enumerate() { - let (a, b) = (&w[0], &w[1]); - if t <= b.0 { - let u = if b.0 > a.0 { (t - a.0) / (b.0 - a.0) } else { 0.0 }; - let k = midpoint_remap(u, mids.get(i).copied().unwrap_or(0.5)); - return std::array::from_fn(|ch| a.1[ch] + (b.1[ch] - a.1[ch]) * k); - } - } - last.1 + // The segment ending at the first stop at or past `t` (bisection: this runs per pixel, and a + // gradient can carry any number of stops). Past the last stop, or for a NaN, there is none. + let at = stops.partition_point(|s| s.0 < t); + let (Some(a), Some(b)) = (at.checked_sub(1).and_then(|k| stops.get(k)), stops.get(at)) else { return last.1 }; + let u = if b.0 > a.0 { (t - a.0) / (b.0 - a.0) } else { 0.0 }; + let k = midpoint_remap(u, mids.get(at - 1).copied().unwrap_or(0.5)); + std::array::from_fn(|ch| a.1[ch] + (b.1[ch] - a.1[ch]) * k) } /// Opacity stops at `t` (stops sorted by location; 1.0 when empty). @@ -441,13 +439,10 @@ fn sample_opacity(stops: &[(f32, f32)], t: f32) -> f32 { if t <= first.0 { return first.1; } - for w in stops.windows(2) { - if t <= w[1].0 { - let u = if w[1].0 > w[0].0 { (t - w[0].0) / (w[1].0 - w[0].0) } else { 0.0 }; - return w[0].1 + (w[1].1 - w[0].1) * u; - } - } - last.1 + let at = stops.partition_point(|s| s.0 < t); + let (Some(a), Some(b)) = (at.checked_sub(1).and_then(|k| stops.get(k)), stops.get(at)) else { return last.1 }; + let u = if b.0 > a.0 { (t - a.0) / (b.0 - a.0) } else { 0.0 }; + a.1 + (b.1 - a.1) * u } /// Colour and alpha at `t`: colour stops with midpoints, alpha times opacity stops. Mirrors diff --git a/crates/vector/src/tests.rs b/crates/vector/src/tests.rs index 8ef86f18ef..815c7be5e6 100644 --- a/crates/vector/src/tests.rs +++ b/crates/vector/src/tests.rs @@ -583,3 +583,34 @@ fn tile_bands_and_band_tiles_near_i32_max_do_not_overflow() { assert_eq!(tiles.len(), 1); assert_eq!(tiles[0].0, band); } + +#[test] +fn gradient_stops_are_found_by_bisection_and_agree_with_a_scan() { + let stops: Vec<(f32, [f32; 4])> = (0..=8).map(|i| (i as f32 / 8.0, [i as f32 / 8.0, 0.0, 1.0 - i as f32 / 8.0, 1.0])).collect(); + let scan = |t: f32| { + let (first, last) = (stops[0], stops[stops.len() - 1]); + if t <= first.0 { + return first.1; + } + for w in stops.windows(2) { + if t <= w[1].0 { + let u = if w[1].0 > w[0].0 { (t - w[0].0) / (w[1].0 - w[0].0) } else { 0.0 }; + return std::array::from_fn(|ch| w[0].1[ch] + (w[1].1[ch] - w[0].1[ch]) * u); + } + } + last.1 + }; + for i in -10..=110 { + let t = i as f32 / 100.0; + assert_eq!(sample_color(&stops, &[], t), scan(t), "t = {t}"); + } + assert_eq!(sample_color(&stops, &[], f32::NAN), stops[8].1, "a NaN position takes the last stop, as before"); + assert_eq!(sample_opacity(&[(0.0, 0.0), (1.0, 1.0)], 0.25), 0.25); + // A million stops are bisected: this is a per-pixel call. + let many: Vec<(f32, [f32; 4])> = (0..1_000_000).map(|i| (i as f32 / 1_000_000.0, [0.0; 4])).collect(); + let started = std::time::Instant::now(); + for i in 0..100_000 { + sample_color(&many, &[], 0.9 + i as f32 * 1e-7); + } + assert!(started.elapsed().as_secs() < 5, "{:?}", started.elapsed()); +} From b20ccefffbd593ffbaea6c0682023cee5f777859 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 14:41:54 -0400 Subject: [PATCH 44/53] security(gpu,ui-egui): bound clipped pass-through planning; sample pattern swatch pixels directly --- crates/gpu/src/plan.rs | 30 ++++++++++++++++++++++++++++- crates/ui-egui/src/preset_panels.rs | 8 ++++---- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/crates/gpu/src/plan.rs b/crates/gpu/src/plan.rs index 815b55517d..333ba2280a 100644 --- a/crates/gpu/src/plan.rs +++ b/crates/gpu/src/plan.rs @@ -361,8 +361,15 @@ struct Planner<'a> { cx: DocCtx<'a>, fx: Vec>, checkpoints: Vec, + /// Layers planned so far, counting a layer each time it is planned (a clipped pass-through + /// group is planned twice). + visits: usize, } +/// Most layer plannings one document may take: a pass-through group with clipped layers is +/// planned twice, so groups nested in one another double the work at every level. +const MAX_PLAN_VISITS: usize = 100_000; + /// Coverage source of an effect paint (`kind` in `fs_fxpaint`); flags refine it. #[derive(Clone, Copy)] enum Cov { @@ -455,7 +462,7 @@ pub const SHAPE_MAP: usize = usize::MAX; impl<'a> Planner<'a> { fn new(cx: DocCtx<'a>) -> Self { - Planner { passes: Vec::new(), free: Vec::new(), refs: Vec::new(), cx, fx: Vec::new(), checkpoints: Vec::new() } + Planner { passes: Vec::new(), free: Vec::new(), refs: Vec::new(), cx, fx: Vec::new(), checkpoints: Vec::new(), visits: 0 } } fn finish(self, root: Slot) -> Plan<'a> { @@ -607,6 +614,10 @@ impl<'a> Planner<'a> { /// composite_layer, honouring the layer's channel restrictions. fn layer(&mut self, layer: &'a Layer, clipped: &'a [Layer], backdrop: Slot) -> Result { + self.visits += 1; + if self.visits > MAX_PLAN_VISITS { + return Err(Unsupported("too many nested layers to plan for the GPU (composited on the CPU)".into())); + } self.check_advanced(layer, clipped)?; self.restricted(layer, backdrop, |s, b| s.layer_any(layer, clipped, b)) } @@ -1876,6 +1887,23 @@ mod tests { use photocraft_color::{Color, ColorMode, SampleType}; use photocraft_geom::Size; + #[test] + fn nested_pass_through_groups_with_clipped_layers_are_not_planned_exponentially() { + let mut layers = vec![Layer::raster("leaf", photocraft_color::PixelFormat::RGBA8)]; + for _ in 0..30 { + let group = Layer::group("g", layers); + let mut clipped = Layer::raster("c", photocraft_color::PixelFormat::RGBA8); + clipped.clipped = true; + layers = vec![group, clipped]; + } + let mut doc = Document::new("nest", Size::new(8, 8), ColorMode::Rgb, SampleType::U8); + doc.layers = layers; + let started = std::time::Instant::now(); + let error = plan(&doc).err().expect("2^30 plannings must be refused"); + assert!(error.0.contains("too many nested layers"), "{}", error.0); + assert!(started.elapsed().as_secs() < 10, "{:?}", started.elapsed()); + } + #[test] fn stops_closer_than_one_lut_step_stay_on_the_cpu() { assert!(check_gradient_stops(&[(0.4999_f32, ()), (0.5, ())]).is_err()); diff --git a/crates/ui-egui/src/preset_panels.rs b/crates/ui-egui/src/preset_panels.rs index e2ca35a2f9..8b11f1b09c 100644 --- a/crates/ui-egui/src/preset_panels.rs +++ b/crates/ui-egui/src/preset_panels.rs @@ -223,8 +223,8 @@ fn pattern_texture(ctx: &egui::Context, pat: &photocraft_doc::Pattern) -> Textur cached_texture(ctx, ("pattern", pat.id.clone()), || { let (w, h) = (pat.width.max(1), pat.height.max(1)); let fmt = pat.surface.format(); - let px = pat.surface.read_region(photocraft_geom::Rect::new(0, 0, w as i32, h as i32)); - let n = fmt.channels(); + // Only the pixels the swatch shows are read: a pattern file can declare a padded source + // of any size, and reading it whole to pick 4096 of its pixels is gigabytes of floats. // Tiles smaller than the swatch repeat; larger ones are shown shrunk. const S: u32 = 64; let step = (w.max(h) as f32 / S as f32).max(1.0); @@ -233,8 +233,8 @@ fn pattern_texture(ctx: &egui::Context, pat: &photocraft_doc::Pattern) -> Textur for x in 0..S { let sx = ((x as f32 * step) as u32) % w; let sy = ((y as f32 * step) as u32) % h; - let i = (sy * w + sx) as usize * n; - img.pixels[(y * S + x) as usize] = c32(photocraft_raster::to_rgba(&fmt, &px[i..i + n])); + let px = pat.surface.read_region(photocraft_geom::Rect::from_xywh(sx as i32, sy as i32, 1, 1)); + img.pixels[(y * S + x) as usize] = c32(photocraft_raster::to_rgba(&fmt, &px)); } } img From a8983a4060b19d625fbb833dab8ed71cbbcd61e9 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 14:44:28 -0400 Subject: [PATCH 45/53] security(codecs): budget truncated JPEG scans by scan data, document the animated WebP ICC charge CodeRabbit follow-up: - data_end counts the scan-data bytes it crosses, and decode's truncated-input bound is measured on those, not on bytes.len(): APP padding padded before the scan used to charge the budget without describing one more pixel. - a regression test builds a truncated JPEG with ~256 KiB of complete COMMENT padding whose patched 20000x20000 frame was admissible under the file-length bound, and verifies padding alone cannot admit it (while the same file at its real size still recovers). - Limits::max_alloc docs state the animated WebP charging, next to the PNG text/XMP sentence. --- crates/codecs/src/codecs/jpeg.rs | 68 +++++++++++++++++++++++++------- crates/codecs/src/options.rs | 4 +- 2 files changed, 57 insertions(+), 15 deletions(-) diff --git a/crates/codecs/src/codecs/jpeg.rs b/crates/codecs/src/codecs/jpeg.rs index 26a47c13d6..393f8e9512 100644 --- a/crates/codecs/src/codecs/jpeg.rs +++ b/crates/codecs/src/codecs/jpeg.rs @@ -137,32 +137,39 @@ enum DataEnd { /// Walks the marker segments (skipped by their length) and the entropy-coded scan data /// (skipped up to the next marker that isn't a stuffed `FF 00`, a restart or a fill byte), /// so an embedded thumbnail's end marker or data after the end of the image don't count. -fn data_end(b: &[u8]) -> DataEnd { +/// Also returns how many scan-data bytes the walk crossed: the truncated-input budget is +/// measured on these, not on the file length, which APP padding can inflate without +/// adding image data. +fn data_end(b: &[u8]) -> (DataEnd, u64) { // Where the bytes run out: inside the image data once a scan has held some. - let cut = |scan_data: bool| if scan_data { DataEnd::Truncated } else { DataEnd::Empty }; + let cut = |scan_data: bool, scanned: u64| (if scan_data { DataEnd::Truncated } else { DataEnd::Empty }, scanned); let mut i = 2; // past SOI let mut scan_data = false; + let mut scanned = 0u64; loop { - let Some(&byte) = b.get(i) else { return cut(scan_data) }; + let Some(&byte) = b.get(i) else { return cut(scan_data, scanned) }; if byte != 0xFF { // Not where a marker should be: too odd to judge. - return DataEnd::Complete; + return (DataEnd::Complete, scanned); } - let Some(&code) = b.get(i + 1) else { return cut(scan_data) }; + let Some(&code) = b.get(i + 1) else { return cut(scan_data, scanned) }; match code { - 0xD9 => return DataEnd::Complete, + 0xD9 => return (DataEnd::Complete, scanned), 0xFF => i += 1, 0x01 | 0xD0..=0xD8 => i += 2, _ => { let Some(len) = b.get(i + 2..i + 4).and_then(|s| <[u8; 2]>::try_from(s).ok()).map(u16::from_be_bytes) else { - return cut(scan_data); + return cut(scan_data, scanned); }; i = i.saturating_add(2 + usize::from(len)); if code == 0xDA { let start = i; loop { let Some(p) = b.get(i..).and_then(|r| r.iter().position(|&v| v == 0xFF)) else { - return cut(scan_data || b.len() > start); + // EOF inside this scan: everything from the scan start to the end + // of the file is scan data (inside a declared segment it may not + // be, so only count what remains). + return cut(scan_data || b.len() > start, scanned + u64::try_from(b.len().saturating_sub(start)).unwrap_or(0)); }; i += p; match b.get(i + 1) { @@ -172,6 +179,7 @@ fn data_end(b: &[u8]) -> DataEnd { } } scan_data |= i > start; + scanned += u64::try_from(i - start).unwrap_or(0); } } } @@ -182,12 +190,12 @@ fn err(e: impl std::fmt::Display) -> CodecError { CodecError::malformed(F, e) } -/// Most pixels one byte of a truncated file may account for. Even a flat image costs a few bits -/// per 8×8 block, and truncated photographs are far above this. +/// Most pixels one byte of truncated scan data may account for. Even a flat image costs a few +/// bits per 8×8 block, and truncated photographs are far above this. const MAX_TRUNCATED_PIXELS_PER_BYTE: u64 = 2048; pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result { - let end = data_end(bytes); + let (end, scan_bytes) = data_end(bytes); if end == DataEnd::Empty { return Err(err("the file ends before any image data")); } @@ -209,8 +217,10 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result }; limits.check(w, h, layout, SampleType::U8)?; // The backend fills whatever the scan no longer describes, so a short scan under a large - // frame is a gigabyte of synthesized output from a few kilobytes of file. - if end == DataEnd::Truncated && u64::from(w) * u64::from(h) / MAX_TRUNCATED_PIXELS_PER_BYTE > bytes.len() as u64 { + // frame is a gigabyte of synthesized output from a few kilobytes of file. The budget is + // measured on the scan data itself: APP padding can inflate `bytes.len()` arbitrarily + // without describing one more pixel. + if end == DataEnd::Truncated && u64::from(w) * u64::from(h) / MAX_TRUNCATED_PIXELS_PER_BYTE > scan_bytes { return Err(err("the truncated scan is too short for the frame it declares")); } dec.set_options(dec.options().jpeg_set_out_colorspace(out_cs)); @@ -451,7 +461,7 @@ mod tests { let img = Image::from_raw(16, 16, ChannelLayout::Rgb, SampleType::U8, vec![128; 16 * 16 * 3]).unwrap(); let full = crate::encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap(); let cut = &full[..full.len() - 4]; - assert_eq!(data_end(cut), DataEnd::Truncated); + assert_eq!(data_end(cut).0, DataEnd::Truncated); assert!(crate::decode_as(Format::Jpeg, cut).is_ok()); let sof = cut.windows(2).position(|w| w == [0xFF, 0xC0] || w == [0xFF, 0xC2]).unwrap(); let mut huge = cut.to_vec(); @@ -460,6 +470,36 @@ mod tests { assert!(e.to_string().contains("too short"), "{e}"); } + /// The truncated-scan budget counts scan data, not the file length: APP padding before the + /// window cannot admit an oversized output. The same padded file with its real size still + /// decodes (with the truncation warning). + #[test] + fn app_padding_cannot_admit_an_oversized_truncated_frame() { + let img = Image::from_raw(16, 16, ChannelLayout::Rgb, SampleType::U8, vec![128; 16 * 16 * 3]).unwrap(); + let full = crate::encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap(); + let cut = &full[..full.len() - 4]; + // Four full-length COMMENT segments (~256 KiB) stand where APP padding would; they are + // complete, so the file otherwise parses normally. + let fill = [b'p'; 65533]; + let mut padded = cut[..2].to_vec(); + for _ in 0..4 { + padded.extend_from_slice(&[0xFF, 0xFE]); + padded.extend_from_slice(&0xFFFFu16.to_be_bytes()); + padded.extend_from_slice(&fill); + } + padded.extend_from_slice(&cut[2..]); + assert_eq!(data_end(&padded).0, DataEnd::Truncated); + // Well above the frame's pixels / budget, yet far above the scan data: the old + // file-length bound admitted this frame. + assert!(padded.len() > (u64::from(20_000) * u64::from(20_000) / MAX_TRUNCATED_PIXELS_PER_BYTE) as usize, "padding too small"); + assert!(crate::decode_as(Format::Jpeg, &padded).is_ok()); + let sof = padded.windows(2).position(|w| w == [0xFF, 0xC0] || w == [0xFF, 0xC2]).unwrap(); + let mut huge = padded.clone(); + huge[sof + 5..sof + 9].copy_from_slice(&[0x4E, 0x20, 0x4E, 0x20]); + let e = crate::decode_as(Format::Jpeg, &huge).unwrap_err(); + assert!(e.to_string().contains("too short"), "{e}"); + } + /// CMYK JPEG output must omit JFIF while retaining Adobe's CMYK marker. #[test] fn cmyk_jpeg_omits_jfif_but_keeps_adobe_cmyk_marker() { diff --git a/crates/codecs/src/options.rs b/crates/codecs/src/options.rs index 1f7b249722..2279504413 100644 --- a/crates/codecs/src/options.rs +++ b/crates/codecs/src/options.rs @@ -14,7 +14,9 @@ pub struct Limits { /// Maximum bytes for the decoded pixel buffer (and passed on as the /// allocation budget to underlying decoders). /// PNG also applies this to the aggregate decoded text/XMP UTF-8 bytes, - /// including keywords, separately from pixels. This is not a total RSS cap. + /// including keywords, separately from pixels. Animated WebP charges + /// every retained frame's full pixel copy plus its own copy of the + /// file's ICC profile against this budget. This is not a total RSS cap. pub max_alloc: u64, } From 75f5a9c126e52650b141483a0443907dbaee9207 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 15:03:12 -0400 Subject: [PATCH 46/53] packaging: use [[ for bash conditional tests (SonarCloud S7688) (#8914) * packaging: use [[ for bash conditional tests (SonarCloud S7688) SonarCloud flags every bare [ conditional in bash scripts under packaging/ as a reliability smell (rule S7688). Convert the flagged [ ... ] tests to [[ ... ]]: safer word-splitting and no glob surprises, same semantics for these string and file tests. * packaging/web: parse size overrides as base 10 under [[ --- packaging/env.sh | 12 +++--- packaging/freebsd/package.sh | 14 +++---- packaging/linux/apprun-test.sh | 50 +++++++++++------------ packaging/linux/flatpak-bundle.sh | 8 ++-- packaging/linux/package.sh | 14 +++---- packaging/linux/verified-download-test.sh | 14 +++---- packaging/linux/verified-download.sh | 12 +++--- packaging/macos/import-cert.sh | 6 +-- packaging/macos/package.sh | 24 +++++------ packaging/macos/verify.sh | 24 +++++------ packaging/web/package.sh | 26 ++++++------ 11 files changed, 102 insertions(+), 102 deletions(-) diff --git a/packaging/env.sh b/packaging/env.sh index 0007227058..5b4bfe0b4c 100644 --- a/packaging/env.sh +++ b/packaging/env.sh @@ -22,7 +22,7 @@ workspace_version() { } VERSION="${PHOTOCRAFT_VERSION:-$(workspace_version)}" -if [ -z "$VERSION" ]; then +if [[ -z "$VERSION" ]]; then echo "error: could not read [workspace.package] version from $ROOT/Cargo.toml" >&2 exit 1 fi @@ -32,7 +32,7 @@ DIST="${DIST:-$ROOT/dist/release}" mkdir -p "$DIST" export DIST -if [ -z "${PHOTOCRAFT_BUILD_SHA:-}" ]; then +if [[ -z "${PHOTOCRAFT_BUILD_SHA:-}" ]]; then PHOTOCRAFT_BUILD_SHA="$(git -C "$ROOT" rev-parse HEAD 2>/dev/null || true)" fi export PHOTOCRAFT_BUILD_SHA @@ -41,14 +41,14 @@ export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/target}" # Emit a GitHub Actions warning (plain stderr outside Actions). warn() { - if [ -n "${GITHUB_ACTIONS:-}" ]; then echo "::warning::$*"; else echo "warning: $*" >&2; fi + if [[ -n "${GITHUB_ACTIONS:-}" ]]; then echo "::warning::$*"; else echo "warning: $*" >&2; fi } # Copy licence and readme files that exist into a package directory. copy_docs() { local dest="$1" f for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE COPYRIGHT; do - if [ -f "$ROOT/$f" ]; then cp "$ROOT/$f" "$dest/"; fi + if [[ -f "$ROOT/$f" ]]; then cp "$ROOT/$f" "$dest/"; fi done copy_font_licences "$dest" } @@ -57,9 +57,9 @@ copy_docs() { # are OFL-1.1: ship each font's licence as OFL-.txt. No-op without CRAFT_FONTS_DIR. copy_font_licences() { local dest="$1" lic fam - [ -n "${CRAFT_FONTS_DIR:-}" ] || return 0 + [[ -n "${CRAFT_FONTS_DIR:-}" ]] || return 0 for lic in "$CRAFT_FONTS_DIR"/fonts/*/OFL.txt; do - [ -f "$lic" ] || continue + [[ -f "$lic" ]] || continue fam="$(basename "$(dirname "$lic")")" cp "$lic" "$dest/OFL-$fam.txt" done diff --git a/packaging/freebsd/package.sh b/packaging/freebsd/package.sh index b03af6be55..71296da600 100755 --- a/packaging/freebsd/package.sh +++ b/packaging/freebsd/package.sh @@ -20,7 +20,7 @@ APP_ID=ai.storyteller.photocraft SKIP_BUILD=0 DRY_RUN=0 -while [ $# -gt 0 ]; do +while [[ $# -gt 0 ]]; do case "$1" in --skip-build) SKIP_BUILD=1; shift ;; --dry-run) DRY_RUN=1; SKIP_BUILD=1; shift ;; @@ -36,7 +36,7 @@ case "$MACHINE" in arm64 | aarch64) ARCH=aarch64 ;; *) echo "unsupported architecture $MACHINE" >&2; exit 2 ;; esac -if [ "$DRY_RUN" = 0 ] && [ "$(uname -s)" != FreeBSD ]; then +if [[ "$DRY_RUN" = 0 ]] && [[ "$(uname -s)" != FreeBSD ]]; then echo "error: build on FreeBSD (or pass --dry-run to check the tree layout)" >&2 exit 2 fi @@ -46,7 +46,7 @@ echo "==> PhotoCraft $VERSION for FreeBSD $ARCH" # The release VM has 12 GB; full parallelism on the biggest crates runs it out of memory. export CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-4}" -if [ "$SKIP_BUILD" = 0 ]; then +if [[ "$SKIP_BUILD" = 0 ]]; then (cd "$ROOT" && cargo build --release --locked -p photocraft -p photocraft-cli --features heif) fi WORK="$CARGO_TARGET_DIR/freebsd-package" @@ -56,7 +56,7 @@ mkdir -p "$WORK" BIN="$CARGO_TARGET_DIR/release" OUT_DIR="$DIST" -if [ "$DRY_RUN" = 1 ]; then +if [[ "$DRY_RUN" = 1 ]]; then # Stub binaries, and the tarball stays in the work dir so dist/ only ever holds real builds. BIN="$WORK/stub-bin" OUT_DIR="$WORK" @@ -81,13 +81,13 @@ sed -e "s/@VERSION@/$VERSION/g" -e "s/@DATE@/$PHOTOCRAFT_BUILD_DATE/g" \ cp -R "$ROOT/assets/app-icon/hicolor" "$STAGE/share/icons/" copy_docs "$STAGE/share/doc/photocraft" for f in NOTICE ATTRIBUTION.md; do - if [ -f "$ROOT/$f" ]; then install -m 644 "$ROOT/$f" "$STAGE/share/doc/photocraft/"; fi + if [[ -f "$ROOT/$f" ]]; then install -m 644 "$ROOT/$f" "$STAGE/share/doc/photocraft/"; fi done for f in bin/photocraft bin/photocraft-cli "share/applications/$APP_ID.desktop" \ "share/icons/hicolor/256x256/apps/$APP_ID.png" "share/icons/hicolor/scalable/apps/$APP_ID.svg" \ share/doc/photocraft/LICENSE-MIT share/doc/photocraft/LICENSE-APACHE; do - if [ ! -e "$STAGE/$f" ]; then echo "error: $f is missing from the package" >&2; exit 1; fi + if [[ ! -e "$STAGE/$f" ]]; then echo "error: $f is missing from the package" >&2; exit 1; fi done # ---- .tar.gz ------------------------------------------------------------------------------------ @@ -96,7 +96,7 @@ tar -C "$WORK" -czf "$OUT_DIR/$BASENAME.tar.gz" "$BASENAME" echo "wrote $OUT_DIR/$BASENAME.tar.gz" "$STAGE/bin/photocraft-cli" --version -if [ "$DRY_RUN" = 1 ]; then +if [[ "$DRY_RUN" = 1 ]]; then echo "==> dry run: tarball contents" tar -tzvf "$OUT_DIR/$BASENAME.tar.gz" fi diff --git a/packaging/linux/apprun-test.sh b/packaging/linux/apprun-test.sh index 18f920560b..17e4923cf3 100755 --- a/packaging/linux/apprun-test.sh +++ b/packaging/linux/apprun-test.sh @@ -37,10 +37,10 @@ fail() { # 1. Launch works and passes arguments through. out="$("$APPDIR/AppRun" open file.png)" || fail "AppRun exited non-zero" -[ "$out" = "stub: open file.png" ] || fail "arguments not forwarded: $out" +[[ "$out" = "stub: open file.png" ]] || fail "arguments not forwarded: $out" DESKTOP="$XDG_DATA_HOME/applications/$APP_ID.desktop" -[ -f "$DESKTOP" ] || fail "desktop entry not installed" +[[ -f "$DESKTOP" ]] || fail "desktop entry not installed" # 2. Exec rewritten to the AppImage (quoted, spaces intact), TryExec dropped, # everything else preserved from the packaged entry. @@ -56,15 +56,15 @@ fi # 3. Icons landed under the name the .desktop entry references. for f in "16x16/apps/$APP_ID.png" "256x256/apps/$APP_ID.png" "scalable/apps/$APP_ID.svg"; do - [ -f "$XDG_DATA_HOME/icons/hicolor/$f" ] || fail "icon missing: $f" + [[ -f "$XDG_DATA_HOME/icons/hicolor/$f" ]] || fail "icon missing: $f" done # 4. Idempotent: a second launch must not rewrite anything (inode unchanged). before="$(stat -c %i "$DESKTOP")" out="$("$APPDIR/AppRun")" || fail "second launch exited non-zero" -[ "$out" = "stub: " ] || fail "second launch output: $out" +[[ "$out" = "stub: " ]] || fail "second launch output: $out" after="$(stat -c %i "$DESKTOP")" -[ "$before" = "$after" ] || fail "desktop entry rewritten although unchanged" +[[ "$before" = "$after" ]] || fail "desktop entry rewritten although unchanged" # 5. Moving the AppImage refreshes Exec. mv "$APPIMAGE" "$TMP/moved.AppImage" @@ -78,9 +78,9 @@ grep -Fxq "Exec=\"$(readlink -f "$APPIMAGE")\" %F" "$DESKTOP" || fail "Exec not ext_desktop="$XDG_DATA_HOME/applications/appimagekit-photocraft.desktop" printf '[Desktop Entry]\nType=Application\nName=PhotoCraft\nExec="%s" %%F\nIcon=%s\n' "$(readlink -f "$APPIMAGE")" "$APP_ID" >"$ext_desktop" "$APPDIR/AppRun" >/dev/null || fail "launch with external integrator desktop entry exited non-zero" -[ ! -e "$DESKTOP" ] || fail "duplicate desktop entry kept alongside external integrator entry" -[ -f "$ext_desktop" ] || fail "external integrator desktop entry removed" -[ -f "$XDG_DATA_HOME/icons/hicolor/256x256/apps/$APP_ID.png" ] || fail "icons missing with external integrator" +[[ ! -e "$DESKTOP" ]] || fail "duplicate desktop entry kept alongside external integrator entry" +[[ -f "$ext_desktop" ]] || fail "external integrator desktop entry removed" +[[ -f "$XDG_DATA_HOME/icons/hicolor/256x256/apps/$APP_ID.png" ]] || fail "icons missing with external integrator" rm -f "$ext_desktop" # 7. AppImageLauncher exports DESKTOPINTEGRATION even after X-AppImage-Integrate=false @@ -88,53 +88,53 @@ rm -f "$ext_desktop" # writes its own entry and a later launch must not delete it (#5534). rm -rf "$XDG_DATA_HOME" DESKTOPINTEGRATION=AppImageLauncher "$APPDIR/AppRun" >/dev/null || fail "DESKTOPINTEGRATION launch exited non-zero" -[ -f "$DESKTOP" ] || fail "DESKTOPINTEGRATION suppressed the desktop entry (#5534)" +[[ -f "$DESKTOP" ]] || fail "DESKTOPINTEGRATION suppressed the desktop entry (#5534)" grep -Fxq "Exec=\"$(readlink -f "$APPIMAGE")\" %F" "$DESKTOP" || fail "Exec missing under DESKTOPINTEGRATION" grep -Fxq "X-AppImage-Integrate=false" "$DESKTOP" || fail "X-AppImage-Integrate=false missing under DESKTOPINTEGRATION" -[ -f "$XDG_DATA_HOME/icons/hicolor/256x256/apps/$APP_ID.png" ] || fail "icons not installed under DESKTOPINTEGRATION" +[[ -f "$XDG_DATA_HOME/icons/hicolor/256x256/apps/$APP_ID.png" ]] || fail "icons not installed under DESKTOPINTEGRATION" before="$(stat -c %i "$DESKTOP")" DESKTOPINTEGRATION=AppImageLauncher "$APPDIR/AppRun" >/dev/null || fail "second DESKTOPINTEGRATION launch exited non-zero" -[ -f "$DESKTOP" ] || fail "DESKTOPINTEGRATION removed the desktop entry on a later launch" -[ "$before" = "$(stat -c %i "$DESKTOP")" ] || fail "desktop entry rewritten under DESKTOPINTEGRATION although unchanged" +[[ -f "$DESKTOP" ]] || fail "DESKTOPINTEGRATION removed the desktop entry on a later launch" +[[ "$before" = "$(stat -c %i "$DESKTOP")" ]] || fail "desktop entry rewritten under DESKTOPINTEGRATION although unchanged" # A real integrator entry still wins, including when DESKTOPINTEGRATION is also set (#3623). ext_desktop="$XDG_DATA_HOME/applications/appimagekit-photocraft.desktop" printf '[Desktop Entry]\nType=Application\nName=PhotoCraft\nExec="%s" %%F\nIcon=%s\n' "$(readlink -f "$APPIMAGE")" "$APP_ID" >"$ext_desktop" DESKTOPINTEGRATION=AppImageLauncher "$APPDIR/AppRun" >/dev/null || fail "launch with integrator entry and DESKTOPINTEGRATION exited non-zero" -[ ! -e "$DESKTOP" ] || fail "duplicate desktop entry kept when an integrator entry exists" -[ -f "$ext_desktop" ] || fail "external integrator desktop entry removed" -[ -f "$XDG_DATA_HOME/icons/hicolor/256x256/apps/$APP_ID.png" ] || fail "icons missing when DESKTOPINTEGRATION and an integrator entry coexist" +[[ ! -e "$DESKTOP" ]] || fail "duplicate desktop entry kept when an integrator entry exists" +[[ -f "$ext_desktop" ]] || fail "external integrator desktop entry removed" +[[ -f "$XDG_DATA_HOME/icons/hicolor/256x256/apps/$APP_ID.png" ]] || fail "icons missing when DESKTOPINTEGRATION and an integrator entry coexist" rm -f "$ext_desktop" # 8. Opt-out installs nothing. rm -rf "$XDG_DATA_HOME" PHOTOCRAFT_NO_DESKTOP_INTEGRATION=1 "$APPDIR/AppRun" >/dev/null || fail "opt-out launch exited non-zero" -[ ! -e "$XDG_DATA_HOME" ] || fail "opt-out still integrated" +[[ ! -e "$XDG_DATA_HOME" ]] || fail "opt-out still integrated" # 9. An unwritable data dir never blocks the launch. mkdir -p "$TMP/ro" && chmod a-w "$TMP/ro" out="$(XDG_DATA_HOME="$TMP/ro/data" "$APPDIR/AppRun" out.txt)" || fail "launch blocked by unwritable data dir" -[ "$out" = "stub: out.txt" ] || fail "stub not run against unwritable data dir: $out" +[[ "$out" = "stub: out.txt" ]] || fail "stub not run against unwritable data dir: $out" chmod u+w "$TMP/ro" # 10. No $APPIMAGE (extracted AppDir): launch only, nothing installed. rm -rf "$XDG_DATA_HOME" env -u APPIMAGE "$APPDIR/AppRun" >/dev/null || fail "extracted-AppDir launch exited non-zero" -[ ! -e "$XDG_DATA_HOME" ] || fail "integrated without $APPIMAGE" +[[ ! -e "$XDG_DATA_HOME" ]] || fail "integrated without $APPIMAGE" # 11. A path that would need Exec escaping (here a `$`): launch only, nothing installed. rm -rf "$XDG_DATA_HOME" odd="$TMP/odd\$(id).AppImage" : >"$odd" out="$(APPIMAGE="$odd" "$APPDIR/AppRun" x)" || fail "launch with an odd path exited non-zero" -[ "$out" = "stub: x" ] || fail "odd path: $out" -[ ! -e "$XDG_DATA_HOME" ] || fail "integrated an AppImage path that needs escaping" +[[ "$out" = "stub: x" ]] || fail "odd path: $out" +[[ ! -e "$XDG_DATA_HOME" ]] || fail "integrated an AppImage path that needs escaping" # 12. AM / AppMan (#6392): a photocraft-AM.desktop launcher in the user's applications # dir owns the menu entry. AppRun removes its own entry left from an earlier run, # writes no second one and still installs the icons. rm -rf "$XDG_DATA_HOME" "$APPDIR/AppRun" >/dev/null || fail "launch before AM install exited non-zero" -[ -f "$DESKTOP" ] || fail "no desktop entry before AM install" +[[ -f "$DESKTOP" ]] || fail "no desktop entry before AM install" am_desktop="$XDG_DATA_HOME/applications/photocraft-AM.desktop" printf '[Desktop Entry] Type=Application @@ -143,13 +143,13 @@ Exec=photocraft %%F Icon=%s ' "$APP_ID" >"$am_desktop" "$APPDIR/AppRun" >/dev/null || fail "launch with AM launcher exited non-zero" -[ ! -e "$DESKTOP" ] || fail "duplicate desktop entry kept alongside AM launcher (#6392)" -[ -f "$am_desktop" ] || fail "AM launcher was removed" -[ -f "$XDG_DATA_HOME/icons/hicolor/256x256/apps/$APP_ID.png" ] || fail "icons missing with AM launcher" +[[ ! -e "$DESKTOP" ]] || fail "duplicate desktop entry kept alongside AM launcher (#6392)" +[[ -f "$am_desktop" ]] || fail "AM launcher was removed" +[[ -f "$XDG_DATA_HOME/icons/hicolor/256x256/apps/$APP_ID.png" ]] || fail "icons missing with AM launcher" # 13. Once AM removes its launcher, AppRun writes its own entry again. rm -f "$am_desktop" "$APPDIR/AppRun" >/dev/null || fail "launch after AM removal exited non-zero" -[ -f "$DESKTOP" ] || fail "no desktop entry after the AM launcher was removed" +[[ -f "$DESKTOP" ]] || fail "no desktop entry after the AM launcher was removed" echo "AppRun desktop integration ok" diff --git a/packaging/linux/flatpak-bundle.sh b/packaging/linux/flatpak-bundle.sh index 597866523a..bbbedacae1 100755 --- a/packaging/linux/flatpak-bundle.sh +++ b/packaging/linux/flatpak-bundle.sh @@ -21,7 +21,7 @@ FLATHUB=https://dl.flathub.org/repo/flathub.flatpakrepo TEST=1 TARBALL="" -while [ $# -gt 0 ]; do +while [[ $# -gt 0 ]]; do case "$1" in --no-test) TEST=0; shift ;; -h | --help) sed -n '2,14p' "$0"; exit 0 ;; @@ -38,7 +38,7 @@ case "$ARCH" in esac BASENAME="photocraft-$VERSION-linux-$ARCH" TARBALL="${TARBALL:-$DIST/$BASENAME.tar.gz}" -[ -f "$TARBALL" ] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; } +[[ -f "$TARBALL" ]] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; } for tool in flatpak flatpak-builder; do command -v "$tool" >/dev/null || { echo "error: $tool not found (apt install flatpak flatpak-builder librsvg2-common)" >&2; exit 1; } done @@ -49,7 +49,7 @@ WORK="$CARGO_TARGET_DIR/flatpak-bundle" rm -rf "$WORK" mkdir -p "$WORK/stage" tar -xzf "$TARBALL" -C "$WORK/stage" --strip-components=1 -[ -x "$WORK/stage/bin/photocraft" ] || { echo "error: $TARBALL has no bin/photocraft" >&2; exit 1; } +[[ -x "$WORK/stage/bin/photocraft" ]] || { echo "error: $TARBALL has no bin/photocraft" >&2; exit 1; } cp "$HERE/flatpak/$APP_ID.bundle.yml" "$WORK/$APP_ID.yml" flatpak remote-add --user --if-not-exists flathub "$FLATHUB" @@ -62,7 +62,7 @@ OUT="$DIST/$BASENAME.flatpak" flatpak build-bundle --runtime-repo="$FLATHUB" "$WORK/repo" "$OUT" "$APP_ID" stable echo "wrote $OUT" -if [ "$TEST" = 1 ]; then +if [[ "$TEST" = 1 ]]; then flatpak install --user -y --noninteractive --reinstall "$OUT" flatpak info --user "$APP_ID" flatpak run --command=photocraft-cli "$APP_ID" --version diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 615bef25bf..e245d254ca 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -27,7 +27,7 @@ APP_ID=ai.storyteller.photocraft SKIP_BUILD=0 FORMATS="appimage deb rpm tar" -while [ $# -gt 0 ]; do +while [[ $# -gt 0 ]]; do case "$1" in --skip-build) SKIP_BUILD=1; shift ;; --formats) FORMATS="$2"; shift 2 ;; @@ -48,8 +48,8 @@ BASENAME="photocraft-$VERSION-linux-$ARCH" echo "==> PhotoCraft $VERSION for Linux $ARCH ($FORMATS)" -if [ "$SKIP_BUILD" = 0 ]; then - if [ -n "${CROSS_TARGET:-}" ]; then +if [[ "$SKIP_BUILD" = 0 ]]; then + if [[ -n "${CROSS_TARGET:-}" ]]; then (cd "$ROOT" && cargo build --release --locked -p photocraft -p photocraft-cli --features heif --target "$CROSS_TARGET") else (cd "$ROOT" && cargo build --release --locked -p photocraft -p photocraft-cli --features heif) @@ -136,7 +136,7 @@ if has appimage; then esac mkdir -p "$CARGO_TARGET_DIR" TOOL="${APPIMAGETOOL:-$CARGO_TARGET_DIR/appimagetool-$TOOL_VERSION-$ARCH.AppImage}" - if [ -n "${APPIMAGETOOL:-}" ]; then + if [[ -n "${APPIMAGETOOL:-}" ]]; then verify_appimagetool_override "$TOOL" "$TOOL_SHA256" "$TOOL_VERSION" "$ARCH" else download_verified \ @@ -167,16 +167,16 @@ if has appimage; then (cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream \ --runtime-file "$RUNTIME" -u "$UPDATE_INFO" "$APPDIR" "$OUT") echo "wrote $OUT" - if [ -s "$OUT.zsync" ]; then + if [[ -s "$OUT.zsync" ]]; then echo "wrote $OUT.zsync" else warn "zsyncmake not found, so $OUT.zsync was not written; AppImage delta updates need it" fi fi -if [ -z "${CROSS_TARGET:-}" ]; then +if [[ -z "${CROSS_TARGET:-}" ]]; then "$STAGE/usr/bin/photocraft-cli" --version -elif [ -n "${EMULATOR:-}" ]; then +elif [[ -n "${EMULATOR:-}" ]]; then "$EMULATOR" "$STAGE/usr/bin/photocraft-cli" --version fi echo "==> done" diff --git a/packaging/linux/verified-download-test.sh b/packaging/linux/verified-download-test.sh index 540cbd54bc..cfc9382f32 100644 --- a/packaging/linux/verified-download-test.sh +++ b/packaging/linux/verified-download-test.sh @@ -22,7 +22,7 @@ expected="$(sha256 "$source_asset")" cp "$source_asset" "$cache" /bin/chmod 644 "$cache" download_verified "file://$TMP/unavailable" "$cache" "$expected" true || fail "offline cache was not reused" -[ -x "$cache" ] || fail "valid preseed did not become executable" +[[ -x "$cache" ]] || fail "valid preseed did not become executable" # If a valid but non-executable cache cannot be chmod'd, fail here rather than at invocation. /bin/chmod 644 "$cache" @@ -44,27 +44,27 @@ cp "$source_asset" "$runtime" chmod() { fail "chmod called for a readable runtime cache"; } download_verified "file://$TMP/unavailable" "$runtime" "$expected" || fail "readable runtime was not reused" unset -f chmod -[ ! -x "$runtime" ] || fail "runtime was needlessly made executable" +[[ ! -x "$runtime" ]] || fail "runtime was needlessly made executable" # A fresh runtime download is verified, readable data rather than an executable program. fresh_runtime="$TMP/fresh-runtime" download_verified "file://$source_asset" "$fresh_runtime" "$expected" || fail "fresh runtime download failed" cmp -s "$source_asset" "$fresh_runtime" || fail "fresh runtime differs from verified asset" -[ -r "$fresh_runtime" ] || fail "fresh runtime is not readable" -[ ! -x "$fresh_runtime" ] || fail "fresh runtime was made executable" +[[ -r "$fresh_runtime" ]] || fail "fresh runtime is not readable" +[[ ! -x "$fresh_runtime" ]] || fail "fresh runtime was made executable" mode="$(stat -c %a "$fresh_runtime" 2>/dev/null || stat -f %Lp "$fresh_runtime")" -[ "$mode" = 644 ] || fail "fresh runtime mode is $mode, expected 644" +[[ "$mode" = 644 ]] || fail "fresh runtime mode is $mode, expected 644" # A corrupt cache is replaced only by bytes with the pinned digest. printf 'corrupt' >"$cache" download_verified "file://$source_asset" "$cache" "$expected" true || fail "corrupt cache was not replaced" cmp -s "$source_asset" "$cache" || fail "replacement differs from verified asset" -[ -x "$cache" ] || fail "replacement is not executable" +[[ -x "$cache" ]] || fail "replacement is not executable" wrong_digest="$(printf '%064d' 0)" bad="$TMP/bad.AppImage" if download_verified "file://$source_asset" "$bad" "$wrong_digest" >/dev/null 2>&1; then fail "wrong digest accepted"; fi -[ ! -e "$bad" ] || fail "wrong-digest download left a cache entry" +[[ ! -e "$bad" ]] || fail "wrong-digest download left a cache entry" verify_appimagetool_override "$cache" "$expected" 1.9.1 x86_64 || fail "valid override rejected" /bin/chmod 644 "$cache" diff --git a/packaging/linux/verified-download.sh b/packaging/linux/verified-download.sh index 75c816a61a..ce6eb1cdfc 100644 --- a/packaging/linux/verified-download.sh +++ b/packaging/linux/verified-download.sh @@ -4,8 +4,8 @@ download_verified() { local url="$1" dest="$2" expected="$3" executable="${4:-false}" tmp - if [ -f "$dest" ] && [ "$(sha256 "$dest")" = "$expected" ]; then - if [ "$executable" = true ] && [ ! -x "$dest" ] && ! chmod 755 "$dest"; then + if [[ -f "$dest" ]] && [[ "$(sha256 "$dest")" = "$expected" ]]; then + if [[ "$executable" = true ]] && [[ ! -x "$dest" ]] && ! chmod 755 "$dest"; then echo "error: verified asset is not executable and cannot be chmod'd: $dest" >&2 return 1 fi @@ -13,12 +13,12 @@ download_verified() { fi tmp="$(mktemp "$dest.XXXXXX")" if ! curl -fsSL -o "$tmp" "$url"; then rm -f "$tmp"; return 1; fi - if [ "$(sha256 "$tmp")" != "$expected" ]; then + if [[ "$(sha256 "$tmp")" != "$expected" ]]; then echo "error: SHA-256 mismatch for $url" >&2 rm -f "$tmp" return 1 fi - if [ "$executable" = true ]; then + if [[ "$executable" = true ]]; then if ! chmod 755 "$tmp"; then rm -f "$tmp"; return 1; fi else if ! chmod 644 "$tmp"; then rm -f "$tmp"; return 1; fi @@ -28,11 +28,11 @@ download_verified() { verify_appimagetool_override() { local tool="$1" expected="$2" version="$3" arch="$4" - if [ ! -f "$tool" ] || [ "$(sha256 "$tool")" != "$expected" ]; then + if [[ ! -f "$tool" ]] || [[ "$(sha256 "$tool")" != "$expected" ]]; then echo "error: APPIMAGETOOL must be the verified $version $arch release asset" >&2 return 1 fi - if [ ! -x "$tool" ]; then + if [[ ! -x "$tool" ]]; then echo "error: APPIMAGETOOL must be executable (chmod +x $tool)" >&2 return 1 fi diff --git a/packaging/macos/import-cert.sh b/packaging/macos/import-cert.sh index 236f443224..d67e7a6893 100755 --- a/packaging/macos/import-cert.sh +++ b/packaging/macos/import-cert.sh @@ -12,7 +12,7 @@ umask 077 # shellcheck source=../env.sh . "$(dirname "${BASH_SOURCE[0]}")/../env.sh" -if [ -z "${APPLE_CERTIFICATE:-}" ]; then +if [[ -z "${APPLE_CERTIFICATE:-}" ]]; then warn "APPLE_CERTIFICATE is not set: macOS builds will be ad-hoc signed and not notarized" exit 0 fi @@ -36,13 +36,13 @@ security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KC_PASS" security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"') IDENTITY="$(security find-identity -v -p codesigning "$KEYCHAIN" | awk '/Developer ID Application/ { print $2; exit }')" -if [ -z "$IDENTITY" ]; then +if [[ -z "$IDENTITY" ]]; then security find-identity -v -p codesigning "$KEYCHAIN" >&2 echo "error: no 'Developer ID Application' identity in APPLE_CERTIFICATE" >&2 exit 1 fi echo "Imported signing identity $IDENTITY" -if [ -n "${GITHUB_ENV:-}" ]; then +if [[ -n "${GITHUB_ENV:-}" ]]; then { echo "MACOS_SIGN_IDENTITY=$IDENTITY"; echo "MACOS_KEYCHAIN=$KEYCHAIN"; } >>"$GITHUB_ENV" else echo "MACOS_SIGN_IDENTITY=$IDENTITY" diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index 194b379293..1b0974a340 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -19,7 +19,7 @@ HERE="$ROOT/packaging/macos" ARCH=universal SKIP_BUILD=0 -while [ $# -gt 0 ]; do +while [[ $# -gt 0 ]]; do case "$1" in --arch) ARCH="$2"; shift 2 ;; --skip-build) SKIP_BUILD=1; shift ;; @@ -44,9 +44,9 @@ DMG="$DIST/photocraft-$VERSION-macos-$ARCH.dmg" CLI_ZIP="$DIST/photocraft-cli-$VERSION-macos-$ARCH.zip" NOTARIZE=0 -if [ "$IDENTITY" = "-" ]; then +if [[ "$IDENTITY" = "-" ]]; then warn "macOS: ad-hoc signing (no MACOS_SIGN_IDENTITY); artifacts are not notarized" -elif [ -n "${APPLE_ID:-}" ] && [ -n "${APPLE_PASSWORD:-}" ] && [ -n "${APPLE_TEAM_ID:-}" ]; then +elif [[ -n "${APPLE_ID:-}" ]] && [[ -n "${APPLE_PASSWORD:-}" ]] && [[ -n "${APPLE_TEAM_ID:-}" ]]; then NOTARIZE=1 else warn "macOS: APPLE_ID / APPLE_PASSWORD / APPLE_TEAM_ID incomplete; signed but not notarized" @@ -55,7 +55,7 @@ fi echo "==> PhotoCraft $VERSION for macOS ($ARCH), identity: $IDENTITY, notarize: $NOTARIZE" # ---- build ------------------------------------------------------------------------------------- -if [ "$SKIP_BUILD" = 0 ]; then +if [[ "$SKIP_BUILD" = 0 ]]; then args=() for t in "${TARGETS[@]}"; do args+=(--target "$t"); done (cd "$ROOT" && cargo build --release --locked -p photocraft -p photocraft-cli --features heif "${args[@]}") @@ -74,9 +74,9 @@ done sign() { # Hardened runtime + secure timestamp for a real identity; ad-hoc can't be timestamped. local ts=(--timestamp) - [ "$IDENTITY" = "-" ] && ts=(--timestamp=none) + [[ "$IDENTITY" = "-" ]] && ts=(--timestamp=none) local kc=() - [ -n "${MACOS_KEYCHAIN:-}" ] && kc=(--keychain "$MACOS_KEYCHAIN") + [[ -n "${MACOS_KEYCHAIN:-}" ]] && kc=(--keychain "$MACOS_KEYCHAIN") codesign --force --sign "$IDENTITY" ${kc[@]+"${kc[@]}"} "${ts[@]}" "$@" } @@ -88,8 +88,8 @@ notarize() { echo "$out" id="$(printf '%s' "$out" | plutil -extract id raw -o - - 2>/dev/null || true)" status="$(printf '%s' "$out" | plutil -extract status raw -o - - 2>/dev/null || true)" - if [ "$status" != "Accepted" ]; then - if [ -n "$id" ]; then + if [[ "$status" != "Accepted" ]]; then + if [[ -n "$id" ]]; then xcrun notarytool log "$id" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" --team-id "$APPLE_TEAM_ID" || true fi echo "error: notarization of $(basename "$file") failed (status: ${status:-unknown})" >&2 @@ -104,7 +104,7 @@ mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" cp "$WORK/bin/photocraft" "$APP/Contents/MacOS/PhotoCraft" cp "$ROOT/assets/app-icon/photocraft.icns" "$APP/Contents/Resources/PhotoCraft.icns" # Licences of the embedded craft-fonts fonts (only when built with CRAFT_FONTS_DIR). -if [ -n "${CRAFT_FONTS_DIR:-}" ]; then +if [[ -n "${CRAFT_FONTS_DIR:-}" ]]; then mkdir -p "$APP/Contents/Resources/Licenses" copy_font_licences "$APP/Contents/Resources/Licenses" fi @@ -120,7 +120,7 @@ sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP/Contents/ sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP" codesign --verify --strict --deep --verbose=2 "$APP" -if [ "$NOTARIZE" = 1 ]; then +if [[ "$NOTARIZE" = 1 ]]; then ditto -c -k --keepParent "$APP" "$WORK/PhotoCraft-notarize.zip" notarize "$WORK/PhotoCraft-notarize.zip" xcrun stapler staple "$APP" @@ -147,7 +147,7 @@ hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG" rm -f "$WORK/raw.dmg" sign "$DMG" codesign --verify --strict --verbose=2 "$DMG" -if [ "$NOTARIZE" = 1 ]; then +if [[ "$NOTARIZE" = 1 ]]; then notarize "$DMG" xcrun stapler staple "$DMG" xcrun stapler validate "$DMG" @@ -168,7 +168,7 @@ rm -f "$CLI_ZIP" ditto -c -k --keepParent "$CLI_DIR" "$CLI_ZIP" # notarytool accepts a zip of the bare binary. A bare Mach-O can't carry a stapled ticket, so # Gatekeeper looks the notarization up online when a quarantined copy first runs. -if [ "$NOTARIZE" = 1 ]; then notarize "$CLI_ZIP"; fi +if [[ "$NOTARIZE" = 1 ]]; then notarize "$CLI_ZIP"; fi "$WORK/bin/photocraft-cli" --version echo "==> done (check the artifacts as shipped with packaging/macos/verify.sh --arch $ARCH)" diff --git a/packaging/macos/verify.sh b/packaging/macos/verify.sh index 496aad1174..7521d44e03 100755 --- a/packaging/macos/verify.sh +++ b/packaging/macos/verify.sh @@ -23,7 +23,7 @@ set -euo pipefail . "$(dirname "${BASH_SOURCE[0]}")/../env.sh" ARCH=universal -while [ $# -gt 0 ]; do +while [[ $# -gt 0 ]]; do case "$1" in --arch) ARCH="$2"; shift 2 ;; -h | --help) sed -n '2,20p' "$0"; exit 0 ;; @@ -41,13 +41,13 @@ DMG="$DIST/photocraft-$VERSION-macos-$ARCH.dmg" CLI_ZIP="$DIST/photocraft-cli-$VERSION-macos-$ARCH.zip" IDENTITY="${MACOS_SIGN_IDENTITY:--}" STRICT=0 -if [ "$IDENTITY" != "-" ] && [ -n "${APPLE_ID:-}" ] && [ -n "${APPLE_PASSWORD:-}" ] && [ -n "${APPLE_TEAM_ID:-}" ]; then +if [[ "$IDENTITY" != "-" ]] && [[ -n "${APPLE_ID:-}" ]] && [[ -n "${APPLE_PASSWORD:-}" ]] && [[ -n "${APPLE_TEAM_ID:-}" ]]; then STRICT=1 fi FAILED=0 fail() { - if [ "$STRICT" = 1 ]; then + if [[ "$STRICT" = 1 ]]; then echo "error: $*" >&2 FAILED=1 else @@ -58,20 +58,20 @@ fail() { # spctl's online ticket lookup can lag a few seconds behind notarytool's "Accepted". assess() { local out attempt tries=1 - [ "$STRICT" = 1 ] && tries=5 + [[ "$STRICT" = 1 ]] && tries=5 for attempt in $(seq "$tries"); do if out="$(spctl "$@" 2>&1)"; then printf '%s\n' "$out" return 0 fi - [ "$attempt" = "$tries" ] || sleep 10 + [[ "$attempt" = "$tries" ]] || sleep 10 done printf '%s\n' "$out" return 1 } for f in "$DMG" "$CLI_ZIP"; do - if [ ! -f "$f" ]; then + if [[ ! -f "$f" ]]; then echo "error: missing $f" >&2 exit 1 fi @@ -84,7 +84,7 @@ trap 'rm -rf "$WORK"' EXIT echo "==> $(basename "$CLI_ZIP")" ditto -x -k "$CLI_ZIP" "$WORK/cli" CLI="$WORK/cli/photocraft-cli-$VERSION-macos-$ARCH/photocraft-cli" -if [ ! -x "$CLI" ]; then +if [[ ! -x "$CLI" ]]; then echo "error: $(basename "$CLI_ZIP") has no executable photocraft-cli-$VERSION-macos-$ARCH/photocraft-cli" >&2 exit 1 fi @@ -108,7 +108,7 @@ printf '%s\n' "$details" | grep -q '^Authority=Developer ID Application:' \ || fail "photocraft-cli is not signed with a Developer ID Application identity" printf '%s\n' "$details" | grep -q '^Timestamp=' \ || fail "photocraft-cli signature has no secure timestamp" -if [ -n "${APPLE_TEAM_ID:-}" ]; then +if [[ -n "${APPLE_TEAM_ID:-}" ]]; then printf '%s\n' "$details" | grep -qx "TeamIdentifier=$APPLE_TEAM_ID" \ || fail "photocraft-cli is signed by a different team" fi @@ -125,24 +125,24 @@ fi # ---- DMG --------------------------------------------------------------------------------------- echo "==> $(basename "$DMG")" codesign --verify --strict --verbose=2 "$DMG" -if [ "$STRICT" = 1 ]; then +if [[ "$STRICT" = 1 ]]; then xcrun stapler validate "$DMG" || fail "no notarization ticket stapled to the DMG" assess --assess --type open --context context:primary-signature -vv "$DMG" \ || fail "Gatekeeper rejects the DMG" fi -if [ "$FAILED" != 0 ]; then +if [[ "$FAILED" != 0 ]]; then echo "error: macOS artifacts failed verification" >&2 exit 1 fi -if [ "$STRICT" = 1 ]; then +if [[ "$STRICT" = 1 ]]; then result="signed (Developer ID, hardened runtime), notarized; DMG stapled" else result="signature intact; Developer ID and notarization not required (no signing secrets)" fi echo "==> verified: $result" -if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then +if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then { echo "- macOS \`$(basename "$DMG")\`, \`$(basename "$CLI_ZIP")\`: $result" } >>"$GITHUB_STEP_SUMMARY" diff --git a/packaging/web/package.sh b/packaging/web/package.sh index 58b9bccd07..ccb06aded3 100755 --- a/packaging/web/package.sh +++ b/packaging/web/package.sh @@ -15,7 +15,7 @@ set -euo pipefail . "$(dirname "${BASH_SOURCE[0]}")/../env.sh" HERE="$ROOT/packaging/web" -if [ "${1:-}" != "--skip-build" ]; then +if [[ "${1:-}" != "--skip-build" ]]; then command -v trunk >/dev/null || { echo "error: trunk not found (cargo install trunk --locked)" >&2; exit 1; } (cd "$ROOT/apps/photocraft-web" && trunk build --release) (cd "$ROOT/apps/photocraft-web-boot" && trunk build --release) @@ -24,7 +24,7 @@ fi APP="$ROOT/dist/web" # the app: index.html, wasm-bindgen glue, one .wasm BOOT="$ROOT/dist/web-boot" # the page that ships, and the module that starts the app from parts for site in "$APP" "$BOOT"; do - [ -f "$site/index.html" ] || { echo "error: $site/index.html missing; run without --skip-build" >&2; exit 1; } + [[ -f "$site/index.html" ]] || { echo "error: $site/index.html missing; run without --skip-build" >&2; exit 1; } # Paths must be relative so the site works under any prefix (public_url = "./" in Trunk.toml). if grep -Eq '(src|href)="/[^/]' "$site/index.html"; then echo "error: $site/index.html has root-absolute URLs; it would break when served from a sub-path" >&2 @@ -35,23 +35,23 @@ done # Size limit (issues #198, #5302): Cloudflare Pages/Workers reject any single file over 25 MiB, # and other hosts and CDNs have similar caps. No file in the site may come near that; the app's # wasm, the one file that does, ships as parts of PART_BYTES. Override only to investigate. -MAX_FILE_BYTES="${PHOTOCRAFT_WEB_MAX_FILE_BYTES:-25165824}" # 24 MiB -PART_BYTES="${PHOTOCRAFT_WEB_PART_BYTES:-20971520}" # 20 MiB -if [ "$PART_BYTES" -le 0 ] || [ "$PART_BYTES" -gt "$MAX_FILE_BYTES" ]; then +MAX_FILE_BYTES=$((10#${PHOTOCRAFT_WEB_MAX_FILE_BYTES:-25165824})) # 24 MiB +PART_BYTES=$((10#${PHOTOCRAFT_WEB_PART_BYTES:-20971520})) # 20 MiB +if [[ "$PART_BYTES" -le 0 ]] || [[ "$PART_BYTES" -gt "$MAX_FILE_BYTES" ]]; then echo "error: a part of $PART_BYTES bytes doesn't fit the $MAX_FILE_BYTES-byte file limit" >&2 exit 1 fi WASM="" for wasm in "$APP"/*_bg.wasm; do - [ -f "$wasm" ] || continue - [ -z "$WASM" ] || { echo "error: more than one .wasm in $APP; remove it and run without --skip-build" >&2; exit 1; } + [[ -f "$wasm" ]] || continue + [[ -z "$WASM" ]] || { echo "error: more than one .wasm in $APP; remove it and run without --skip-build" >&2; exit 1; } WASM="$wasm" done -[ -n "$WASM" ] || { echo "error: no .wasm in $APP" >&2; exit 1; } +[[ -n "$WASM" ]] || { echo "error: no .wasm in $APP" >&2; exit 1; } STEM="$(basename "$WASM" _bg.wasm)" # photocraft-web- GLUE="$APP/$STEM.js" -[ -f "$GLUE" ] || { echo "error: $GLUE missing" >&2; exit 1; } +[[ -f "$GLUE" ]] || { echo "error: $GLUE missing" >&2; exit 1; } NAME="photocraft-web-$VERSION" WORK="$CARGO_TARGET_DIR/web-package" @@ -80,9 +80,9 @@ copy_docs "$WORK/$NAME" # Optional served fonts (README.md › Fonts): PHOTOCRAFT_WEB_FONTS_DIR= copies the manifest, every font and licence file # it lists into the site, at the same paths. The wasm is unchanged; the app fetches fonts on demand. -if [ -n "${PHOTOCRAFT_WEB_FONTS_DIR:-}" ]; then +if [[ -n "${PHOTOCRAFT_WEB_FONTS_DIR:-}" ]]; then FONTS_MANIFEST="$PHOTOCRAFT_WEB_FONTS_DIR/fonts/manifest.txt" - [ -f "$FONTS_MANIFEST" ] || { echo "error: $FONTS_MANIFEST missing (PHOTOCRAFT_WEB_FONTS_DIR)" >&2; exit 1; } + [[ -f "$FONTS_MANIFEST" ]] || { echo "error: $FONTS_MANIFEST missing (PHOTOCRAFT_WEB_FONTS_DIR)" >&2; exit 1; } mkdir -p "$WORK/$NAME/fonts" cp "$FONTS_MANIFEST" "$WORK/$NAME/fonts/manifest.txt" # Field 3 is the font file, field 6 (if any) its licence file; both relative to the folder. @@ -98,8 +98,8 @@ fi largest=0 while IFS= read -r file; do bytes=$(size "$file") - [ "$bytes" -le "$largest" ] || largest=$bytes - if [ "$bytes" -gt "$MAX_FILE_BYTES" ]; then + [[ "$bytes" -le "$largest" ]] || largest=$bytes + if [[ "$bytes" -gt "$MAX_FILE_BYTES" ]]; then echo "error: ${file#"$WORK/$NAME/"} is $bytes bytes, over the $MAX_FILE_BYTES-byte limit (Cloudflare's per-file cap is 25 MiB)" >&2 exit 1 fi From cb297eeac195e24259d696cf7c79ad1b2ebf6d18 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 19:07:23 +0000 Subject: [PATCH 47/53] xtask: i18n-coverage lists missing strings; ci prints them last The report prints, per language, the source keys its catalog lacks (one line for a language with none). `cargo xtask ci` runs it as its last step and never fails on it: a missing translation shows in English. A report that can't run prints a warning. --- xtask/src/i18n_coverage.rs | 75 +++++++++++++++++++++----------------- xtask/src/main.rs | 16 +++++++- 2 files changed, 56 insertions(+), 35 deletions(-) diff --git a/xtask/src/i18n_coverage.rs b/xtask/src/i18n_coverage.rs index ee337c0b9b..cd8fbbce63 100644 --- a/xtask/src/i18n_coverage.rs +++ b/xtask/src/i18n_coverage.rs @@ -72,9 +72,33 @@ fn format_report(languages: &[Language], english_keys: &BTreeSet, translati let coverage = 100.0 * translated as f64 / english_keys.len() as f64; report.push_str(&format!("{:<12} {:>5}/{:<6} {:>8.2}%\n", language.code, translated, english_keys.len(), coverage)); } + report.push_str("\nMissing translations (shown in English until translated)\n"); + for language in languages.iter().filter(|language| language.code != "en") { + let empty = BTreeSet::new(); + let translated = translations.get(&language.code).unwrap_or(&empty); + let missing: Vec<&Key> = english_keys.difference(translated).collect(); + if missing.is_empty() { + report.push_str(&format!("{}: none missing\n", language.code)); + continue; + } + report.push_str(&format!("{}: {} missing\n", language.code, missing.len())); + for key in missing { + let source = escape_line(&key.source); + if key.context.is_empty() { + report.push_str(&format!(" {source}\n")); + } else { + report.push_str(&format!(" [{}] {source}\n", escape_line(&key.context))); + } + } + } report } +/// Keeps one key per log line: a source string with a newline or tab prints them escaped. +fn escape_line(text: &str) -> String { + text.replace('\\', "\\\\").replace('\n', "\\n").replace('\t', "\\t") +} + fn registered_languages(source: &str) -> Result, String> { let registry = source .split("pub static LANGUAGES") @@ -494,15 +518,28 @@ mod tests { let languages = registered_languages(registry); assert!(languages.is_ok()); let languages = languages.unwrap_or_default(); - let english_keys = BTreeSet::from([Key { context: String::new(), source: "Open".into() }, Key { context: String::new(), source: "Save".into() }]); - let translations = BTreeMap::from([("fr".to_string(), BTreeSet::from([Key { context: String::new(), source: "Open".into() }]))]); + let english_keys = BTreeSet::from([ + Key { context: String::new(), source: "Open".into() }, + Key { context: String::new(), source: "Save".into() }, + Key { context: "@plural".into(), source: "{n} layer|{n} layers".into() }, + ]); + let translations = BTreeMap::from([ + ("fr".to_string(), BTreeSet::from([Key { context: String::new(), source: "Open".into() }])), + ("zh".to_string(), english_keys.clone()), + ]); assert_eq!( format_report(&languages, &english_keys, &translations), "UI translation coverage (translated source keys / source keys)\n\ language translated coverage\n\ - en 2/2 100.00%\n\ - fr 1/2 50.00%\n\ - zh 0/2 0.00%\n" + en 3/3 100.00%\n\ + fr 1/3 33.33%\n\ + zh 3/3 100.00%\n\ + \n\ + Missing translations (shown in English until translated)\n\ + fr: 2 missing\n\ + \x20 Save\n\ + \x20 [@plural] {n} layer|{n} layers\n\ + zh: none missing\n" ); } @@ -563,34 +600,6 @@ mod tests { } } - #[test] - fn source_key_set_is_covered_by_complete_catalogs() { - let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap_or(Path::new(".")); - let keys = source_keys(root).unwrap_or_default(); - for (code, path) in [ - ("ja", "ja.tsv"), - ("zh-hant", "zh-hant.tsv"), - ("es", "es.tsv"), - ("ru", "ru.tsv"), - ("cs", "cs.tsv"), - ("de", "de.tsv"), - ("pt-br", "pt-br.tsv"), - ("it", "it.tsv"), - ("el", "el.tsv"), - ("uk", "uk.tsv"), - ("nl", "nl.tsv"), - ("hu", "hu.tsv"), - ("tr", "tr.tsv"), - ("vi", "vi.tsv"), - ("sr", "sr.tsv"), - ] { - let catalog_path = root.join("crates/ui-egui/src/i18n").join(path); - let text = fs::read_to_string(&catalog_path).unwrap_or_default(); - let translations = parse_catalog(&catalog_path, &text).unwrap_or_default(); - let missing: Vec<_> = keys.difference(&translations).collect(); - assert!(missing.is_empty(), "{code}: missing source keys: {missing:#?}"); - } - } #[test] fn plural_forms_follow_count_expressions_and_dynamic_forms_are_not_literal_keys() { let sources = vec![( diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 0f40543829..d6965b48cb 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -26,7 +26,8 @@ commands: wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates (one call, plus one per optional web feature) test workspace tests (cargo nextest when installed, else cargo test), then doctests - ci fmt --check, clippy -D warnings, test, doctest, layers, wasm (stops at first failure) + ci fmt --check, clippy -D warnings, test, doctest, layers, wasm (stops at first failure), + then the i18n-coverage report corpus [--all | --pngsuite | --psd | --psd-tools | --pixls | --atn | --photoshop | --gimp] [--local] [--update-manifest] show where test corpora live and their pins (xtask/src/corpus_pins.rs), or fetch them into corpus/ (pinned commits, sha256-verified; --all = every corpus but pixls, @@ -38,7 +39,8 @@ commands: --local takes corpus/photoshop and corpus/gimp from the photocraft-corpus authoring clone stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`) parity Photoshop menu parity; rewrites docs/parity-checklist.md - i18n-coverage report stable UI translation coverage for registered languages + i18n-coverage report UI translation coverage and the missing strings per registered language + (the last `ci` step; it never fails) perf [--quick] [--update-baseline] [--threshold PCT] [--bench NAME]... [--skip-build] [--reuse] run the release benches, merge them by scenario id into target/perf/results.json, check perf/budgets.toml and perf/baseline.json (non-zero on a broken budget or regression) @@ -249,6 +251,16 @@ fn cmd_ci() -> Result<(), String> { ("doctest", Box::new(cmd_doctest)), ("layers", Box::new(cmd_layers)), ("wasm", Box::new(cmd_wasm)), + ( + "i18n", + Box::new(|| { + // Lists missing translations for the log; a missing string shows in English, so it never fails CI. + if let Err(e) = i18n_coverage::run(&root()) { + eprintln!("warning: i18n coverage report failed: {e}"); + } + Ok(()) + }), + ), ]; let mut done = Vec::new(); for (name, f) in &steps { From c3aa3f9e5164e6deb7fb4c2da65eaf3c48f9244e Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 19:07:23 +0000 Subject: [PATCH 48/53] Tests: missing translations don't fail; xtask ci prints them Delete the coverage tests, the per-language parity checks and the tests of one language's exact wording. Catalog integrity, placeholder and plural-form checks stay, as do the engine tests (locale resolution, plural rules, fallback, language scoping, drawing in a language); those compare against tr() or the plural rule rather than catalog wording. Docs describe the report instead of a failing test. --- crates/ui-egui/src/adjust_dialog.rs | 6 +- crates/ui-egui/src/brush_panel_tests.rs | 1 - crates/ui-egui/src/delete_layer_prompt.rs | 20 - crates/ui-egui/src/discard_ui.rs | 7 +- crates/ui-egui/src/doc_props_ui.rs | 7 +- crates/ui-egui/src/filter_dialog.rs | 101 +-- crates/ui-egui/src/gpu_status.rs | 14 +- crates/ui-egui/src/history_menu_ui.rs | 11 - crates/ui-egui/src/i18n/live_tests.rs | 116 +-- crates/ui-egui/src/i18n/mod.rs | 816 ++---------------- crates/ui-egui/src/menus.rs | 13 +- crates/ui-egui/src/new_doc_ui.rs | 17 - crates/ui-egui/src/notices.rs | 22 +- crates/ui-egui/src/plugin_ui.rs | 3 - crates/ui-egui/src/prefs_ui.rs | 28 - crates/ui-egui/src/profile_open_tests.rs | 44 +- crates/ui-egui/src/shortcut_dispatch/tests.rs | 6 +- crates/ui-egui/src/stroke_ui_tests.rs | 11 - crates/ui-egui/src/symmetry_ui/tests.rs | 3 - crates/ui-egui/src/type_tool_tests.rs | 2 +- .../ui-egui/tests/it/camera_raw_histogram.rs | 1 - docs/development.md | 6 +- docs/localization-es.md | 4 +- docs/localization-it.md | 11 +- docs/localization-ko.md | 3 - docs/localization-parity.md | 4 +- docs/localization-ru.md | 8 +- docs/localization-zh-hans.md | 16 +- docs/localization.md | 6 +- docs/ui-design.md | 11 +- 30 files changed, 178 insertions(+), 1140 deletions(-) diff --git a/crates/ui-egui/src/adjust_dialog.rs b/crates/ui-egui/src/adjust_dialog.rs index 82432879db..3bbf36b3c2 100644 --- a/crates/ui-egui/src/adjust_dialog.rs +++ b/crates/ui-egui/src/adjust_dialog.rs @@ -1075,7 +1075,7 @@ mod tests { #[test] fn curves_channel_popup_keeps_custom_names_verbatim_in_italian() { let it = crate::i18n::Lang::from_code("it").expect("it registered"); - assert_eq!(crate::i18n::tr(it, "Green"), "Verde", "the test relies on the built-in label"); + let green = crate::i18n::tr(it, "Green"); let _lang = crate::i18n::language_scope(it); let mut app = app_with_image(); app.run("channel.new", json!({"name": "Green", "fill": "white"})).unwrap(); @@ -1083,13 +1083,13 @@ mod tests { let mut h = curves_harness(app); let names = popup_channels(&mut h); assert!(names.iter().any(|n| n == "Green"), "custom names stay as entered: {names:?}"); - assert!(!names.iter().any(|n| n == "Verde"), "not translated: {names:?}"); + assert!(green == "Green" || !names.iter().any(|n| n == green), "not translated: {names:?}"); // A targeted colour component keeps its translated built-in label. let mut colour = app_with_image(); colour.run("channel.target", json!({"channel": {"color": 1}})).unwrap(); let mut h = curves_harness(colour); let names = popup_channels(&mut h); - assert!(names.iter().any(|n| n == "Verde"), "built-in labels follow the language: {names:?}"); + assert!(names.iter().any(|n| n == green), "built-in labels follow the language: {names:?}"); } /// Curves open in a harness over `app`. diff --git a/crates/ui-egui/src/brush_panel_tests.rs b/crates/ui-egui/src/brush_panel_tests.rs index f0b6cc906b..1ea52bdb72 100644 --- a/crates/ui-egui/src/brush_panel_tests.rs +++ b/crates/ui-egui/src/brush_panel_tests.rs @@ -1378,7 +1378,6 @@ fn brush_sections_paint_simplified_chinese_labels() { for (name, _) in SECTIONS { let translated = crate::i18n::tr(zh, name); assert!(painted.iter().any(|s| s == translated), "{name} must paint as {translated}"); - assert!(!painted.iter().any(|s| s == name), "{name} must not paint in English"); } } diff --git a/crates/ui-egui/src/delete_layer_prompt.rs b/crates/ui-egui/src/delete_layer_prompt.rs index 7c1913db78..d67d299345 100644 --- a/crates/ui-egui/src/delete_layer_prompt.rs +++ b/crates/ui-egui/src/delete_layer_prompt.rs @@ -154,24 +154,4 @@ mod tests { assert_eq!(clear.shortcut, Some("Delete")); assert_eq!(delete.shortcut, None); } - - #[test] - fn confirmation_strings_are_translated_in_every_supported_catalog() { - for language in crate::i18n::LANGUAGES.iter().filter(|l| l.code != "en") { - let lang = crate::i18n::Lang::from_code(language.code).unwrap(); - for key in [ - "Delete layer ‘{name}’?", - "Delete {count} layers? {names}", - "Delete layer?", - "Don't show again", - "The document is closed", - "The document or selected layers changed. Please request deletion again.", - "Missing delete command", - "Invalid delete command", - "Layer deleted, but confirmation preference could not be saved: {error}", - ] { - assert_ne!(crate::i18n::tr(lang, key), key, "{}: {key}", language.code); - } - } - } } diff --git a/crates/ui-egui/src/discard_ui.rs b/crates/ui-egui/src/discard_ui.rs index f6f654afa0..263f3652bf 100644 --- a/crates/ui-egui/src/discard_ui.rs +++ b/crates/ui-egui/src/discard_ui.rs @@ -787,8 +787,11 @@ mod tests { assert_eq!(button_labels(false, &buttons), ["(Y)es", "(N)o", "(C)ancel"]); let german = crate::i18n::Lang::from_code("de").unwrap(); crate::i18n::with_language(german, || { - assert_eq!(button_labels(false, &buttons), ["Ja (Y)", "Nein (N)", "Abbrechen (C)"]); - assert_eq!(button_labels(true, &buttons), ["Ja", "Nein", "Abbrechen"]); + let shown: Vec<&str> = buttons.iter().map(|(label, _)| crate::i18n::tr(german, label)).collect(); + assert_eq!(button_labels(true, &buttons), shown); + let labels = button_labels(false, &buttons); + let appended: Vec = shown.iter().zip(&buttons).map(|(text, (_, key))| format!("{text} ({})", key.map_or("", |k| k.name()))).collect(); + assert!(labels.iter().all(|label| label.starts_with('(')) || labels == appended, "{labels:?}"); }); } diff --git a/crates/ui-egui/src/doc_props_ui.rs b/crates/ui-egui/src/doc_props_ui.rs index 6a959c38d7..90f70dd77e 100644 --- a/crates/ui-egui/src/doc_props_ui.rs +++ b/crates/ui-egui/src/doc_props_ui.rs @@ -385,15 +385,10 @@ mod tests { } #[test] - fn image_mode_depth_labels_match_sample_types_and_are_translated() { + fn image_mode_depth_labels_match_sample_types() { assert_eq!( DEPTHS.iter().map(|(sample, label, _)| (*sample, *label)).collect::>(), [(SampleType::U8, "8 Bits/Channel"), (SampleType::U16, "16 Bits/Channel"), (SampleType::F32, "32 Bits/Channel"),] ); - for lang in crate::i18n::Lang::all().filter(|lang| lang.code() != "en") { - for (_, label, id) in DEPTHS { - assert_ne!(crate::i18n::tr_id(lang, id, label), *label, "{}: {label}", lang.code()); - } - } } } diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index e8c838ebc8..b611b9ac64 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -1396,14 +1396,10 @@ mod tests { assert_eq!(source_label("centerX"), "Center X"); } - /// Convert to Profile's and Proof Setup's `bpc` read "Bpc": spelled out, and translated. + /// Convert to Profile's and Proof Setup's `bpc` read "Bpc": spelled out. #[test] fn black_point_compensation_is_spelled_out() { assert_eq!(source_label("bpc"), "Black Point Compensation"); - // English is the source: it has no catalog. - for lang in crate::i18n::Lang::all().filter(|l| l.code() != "en") { - assert!(crate::i18n::has(lang, "Black Point Compensation"), "{lang:?}"); - } } fn key() -> FilterPreviewKey { @@ -1855,105 +1851,14 @@ mod tests { assert!(!has_dialog("filter.stylize.findEdges")); } - /// Generated filter and adjustment labels (param names, symbolic choices) and gallery - /// names that `lang` has no catalog row for. - fn missing_generated_filter_labels(lang: crate::i18n::Lang) -> std::collections::BTreeSet { - let mut missing = std::collections::BTreeSet::new(); - let mut check = |s: String| { - if !matches!(s.as_str(), "X" | "Y" | "A" | "B") && !crate::i18n::has(lang, &s) { - missing.insert(s); - } - }; - for c in photocraft_engine::command_specs().iter().filter(|c| c.id.starts_with("filter.") || c.id.starts_with("image.adjustments.")) { - for p in parse_spec(c.params) { - if !p.key.chars().all(|c| c.is_ascii_alphanumeric()) || matches!(p.kind, Kind::Json) { - continue; - } - check(source_label(&p.key)); - if let Kind::Choice { options, .. } = &p.kind { - for choice in options { - // Only symbolic options are UI choices; registry docs also contain - // colour syntax and array notation, which are not translatable names. - if choice.chars().all(|c| c.is_ascii_alphanumeric()) { - check(source_label(choice)); - } - } - } - } - } - for f in photocraft_algo::GalleryFilter::ALL { - check(f.name().to_string()); - } - for cat in photocraft_algo::GALLERY_CATEGORIES { - check(cat.to_string()); - } - missing - } - + /// Select and Mask's Output To lists all six output targets. #[test] - fn korean_covers_generated_filter_options_and_gallery_names() { - let missing = missing_generated_filter_labels(crate::i18n::lang_from_tag("ko-KR").unwrap()); - assert!(missing.is_empty(), "missing Korean dynamic labels: {missing:#?}"); - } - - #[test] - fn italian_covers_generated_filter_options_and_gallery_names() { - let missing = missing_generated_filter_labels(crate::i18n::lang_from_tag("it-IT").unwrap()); - assert!(missing.is_empty(), "missing Italian dynamic labels: {missing:#?}"); - } - - #[test] - fn spanish_covers_generated_filter_options_and_gallery_names() { - let missing = missing_generated_filter_labels(crate::i18n::lang_from_tag("es-ES").unwrap()); - assert!(missing.is_empty(), "missing Spanish dynamic labels: {missing:#?}"); - } - - #[test] - fn russian_covers_generated_filter_options_and_gallery_names() { - let missing = missing_generated_filter_labels(crate::i18n::lang_from_tag("ru-RU").unwrap()); - assert!(missing.is_empty(), "missing Russian dynamic labels: {missing:#?}"); - } - - /// The Select menu's generated dialogs (Select and Mask, Modify ▸ …) show their - /// params, choices and title in every UI language, not just Korean. - #[test] - fn select_dialogs_are_translated_in_every_language() { - let mut missing = std::collections::BTreeMap::<&str, std::collections::BTreeSet>::new(); - for lang in crate::i18n::Lang::all().filter(|l| l.code() != "en") { - let mut check = |s: String| { - if !crate::i18n::has(lang, &s) { - missing.entry(lang.code()).or_default().insert(s); - } - }; - for c in photocraft_engine::command_specs().iter().filter(|c| c.id.starts_with("select.") && has_dialog(c.id)) { - let title = c.label.trim_end_matches('…'); - if !crate::i18n::has(lang, title) { - check(format!("{title}…")); - } - for p in parse_spec(c.params) { - check(source_label(&p.key)); - if let Kind::Choice { options: choices, .. } = p.kind { - choices.iter().for_each(|choice| check(source_label(choice))); - } - } - } - } - assert!(missing.is_empty(), "untranslated Select dialog labels: {missing:#?}"); - } - - /// Select and Mask's Output To lists all six of Photoshop's targets, each translated. - #[test] - fn select_and_mask_output_choices_are_translated() { + fn select_and_mask_output_lists_all_six_targets() { let spec = photocraft_engine::commands::find("select.refineEdge").unwrap().params; let p = parse_spec(spec).into_iter().find(|p| p.key == "output").unwrap(); let Kind::Choice { options: choices, .. } = p.kind else { panic!("output is not a choice") }; let labels: Vec = choices.iter().map(|c| source_label(c)).collect(); assert_eq!(labels, ["Selection", "Layer Mask", "New Layer", "New Layer With Mask", "New Document", "New Document With Mask"]); - for lang in crate::i18n::Lang::all().filter(|l| l.code() != "en") { - for label in &labels { - assert!(crate::i18n::has(lang, label), "{}: untranslated {label}", lang.code()); - } - } } #[test] diff --git a/crates/ui-egui/src/gpu_status.rs b/crates/ui-egui/src/gpu_status.rs index d9608a539b..cd2bb7bc4d 100644 --- a/crates/ui-egui/src/gpu_status.rs +++ b/crates/ui-egui/src/gpu_status.rs @@ -297,8 +297,11 @@ mod tests { assert_eq!(result["info"]["lines"], json!(english)); assert_eq!(crate::i18n::current().code(), "cs", "diagnostics restore the drawing language"); let screen = system_info_ui(&app); - assert!(screen.iter().any(|line| line == "Grafický adaptér: Test GPU"), "{screen:?}"); - assert!(screen.iter().any(|line| line.starts_with("Profil monitoru:")), "{screen:?}"); + let cs = crate::i18n::current(); + let adapter = crate::i18n::fmt(crate::i18n::tr(cs, "Graphics adapter: {adapter}"), &[("adapter", "Test GPU")]); + assert!(screen.contains(&adapter), "{screen:?}"); + let profile = crate::i18n::tr(cs, "Monitor profile: {profile}").split('{').next().unwrap_or_default(); + assert!(screen.iter().any(|line| line.starts_with(profile)), "{screen:?}"); assert!(english.iter().any(|line| line == "GPU backend preference: auto")); }); } @@ -313,8 +316,11 @@ mod tests { app.perf.gpu_info.preference = "auto".into(); app.perf.gpu_info.selected = "auto".into(); assert_eq!(system_info_ui(&app), empty); - assert!(empty.iter().any(|line| line == "Grafický adaptér: auto")); - assert!(empty.iter().any(|line| line == "Preferovaný backend GPU: automaticky"), "{empty:?}"); + let cs = crate::i18n::current(); + let adapter = crate::i18n::fmt(crate::i18n::tr(cs, "Graphics adapter: {adapter}"), &[("adapter", "auto")]); + assert!(empty.contains(&adapter), "{empty:?}"); + let preference = crate::i18n::fmt(crate::i18n::tr(cs, "GPU backend preference: {backend}"), &[("backend", crate::i18n::tr(cs, "auto"))]); + assert!(empty.contains(&preference), "{empty:?}"); assert_eq!(app.perf.gpu_info.preference, "auto"); assert_eq!(app.perf.gpu_info.selected, "auto"); }); diff --git a/crates/ui-egui/src/history_menu_ui.rs b/crates/ui-egui/src/history_menu_ui.rs index cf95f18a01..da77f9d2fb 100644 --- a/crates/ui-egui/src/history_menu_ui.rs +++ b/crates/ui-egui/src/history_menu_ui.rs @@ -187,17 +187,6 @@ mod tests { assert_eq!(states(&h).0, steps.0 - 1, "the current state is deleted"); } - /// The labels aren't `tl!` literals, so the catalog scan can't see them: every language that - /// claims complete menus must still translate them. - #[test] - fn menu_labels_are_translated() { - for lang in crate::i18n::Lang::all().filter(|l| l.complete_menus()) { - for &(label, _) in ITEMS.iter().filter(|(l, _)| *l != "---") { - assert!(crate::i18n::has(lang, label), "{}: {label}", lang.code()); - } - } - } - #[test] fn without_a_document_only_close_is_enabled() { let h = harness(PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default())); diff --git a/crates/ui-egui/src/i18n/live_tests.rs b/crates/ui-egui/src/i18n/live_tests.rs index e2150247b9..81639fa8d2 100644 --- a/crates/ui-egui/src/i18n/live_tests.rs +++ b/crates/ui-egui/src/i18n/live_tests.rs @@ -41,9 +41,10 @@ fn ukrainian_native_app_menu_keeps_product_and_language_names() { with_language(Lang::EN, || { let h = harness(); let layout = crate::native_menu::photocraft_layout(&crate::menus::menu_items(h.state()), lang("uk"), "uk"); - for (id, label) in [("help.about", "Про PhotoCraft"), (crate::native_menu::HIDE, "Приховати PhotoCraft"), ("file.exit", "Вийти з PhotoCraft")] - { - assert_eq!(layout.bar.find(id).map(|item| item.label.as_str()), Some(label), "{id}"); + for (id, template) in [("help.about", "About {app}"), (crate::native_menu::HIDE, "Hide {app}"), ("file.exit", "Quit {app}")] { + let label = fmt(tr(lang("uk"), template), &[("app", "PhotoCraft")]); + assert!(label.contains("PhotoCraft"), "{id}: {label}"); + assert_eq!(layout.bar.find(id).map(|item| item.label.as_str()), Some(label.as_str()), "{id}"); } assert_eq!(layout.bar.find("app.language.uk").map(|item| (item.label.as_str(), item.checked)), Some(("Українська", Some(true)))); }); @@ -52,29 +53,21 @@ fn ukrainian_native_app_menu_keeps_product_and_language_names() { #[test] fn ukrainian_color_picker_titles_draw_in_the_selected_language() { with_language(Lang::EN, || { - for (target, expected) in [("foreground", "Вибір кольору (основний колір)"), ("background", "Вибір кольору (колір тла)")] - { + for (target, title) in [("foreground", "Color Picker (Foreground Color)"), ("background", "Color Picker (Background Color)")] { let mut h = harness(); h.state_mut().run("prefs.set", json!({"path": "interface.language", "value": "uk"})).unwrap(); crate::color_picker_ui::open(h.state_mut(), target); h.run_steps(4); - let text = drawn_text(&h); - assert!(text.iter().any(|text| text == expected), "missing title {expected}"); - assert!(!text.iter().any(|text| text.starts_with("Color Picker")), "no English picker title"); + let expected = tr(lang("uk"), title); + assert!(drawn_text(&h).iter().any(|text| text == expected), "missing title {expected}"); } - assert_eq!(tr(lang("uk"), "Color Picker (Custom Canvas Color)"), "Вибір кольору (власний колір полотна)"); }); } #[test] -fn ukrainian_rasterize_prompts_explain_the_loss_of_editable_content() { +fn ukrainian_rasterize_prompt_draws_in_the_selected_language() { use crate::rasterize_prompt::Kind; - for kind in Kind::ALL { - let translated = tr(lang("uk"), kind.message()); - assert_ne!(translated, kind.message(), "{kind:?}: the warning must be translated"); - assert!(translated.contains("більше не можна буде редагувати"), "{kind:?}: explain the loss of editability"); - } with_language(Lang::EN, || { let mut h = harness(); h.state_mut().run("prefs.set", json!({"path": "interface.language", "value": "uk"})).unwrap(); @@ -85,29 +78,17 @@ fn ukrainian_rasterize_prompts_explain_the_loss_of_editable_content() { let expected = tr(lang("uk"), Kind::Type.message()); let text = drawn_text(&h); assert!(text.iter().any(|text| text == expected), "the actual dialog draws the Ukrainian warning"); - assert!(!text.iter().any(|text| text == Kind::Type.message())); }); } -#[test] -fn french_and_korean_plural_messages_keep_placeholders() { - for (n, expected) in [(0, "0 élément"), (1, "1 élément"), (2, "2 éléments"), (21, "21 éléments")] { - assert_eq!(trn(lang("fr"), n, "{n} item", "{n} items"), expected); - } - for n in [0, 1, 2, 21, u64::MAX] { - assert_eq!(trn(lang("ko"), n, "{n} item", "{n} items"), format!("항목 {n}개")); - } - assert_eq!(fmt(tr(lang("ko"), "Version {version}"), &[("version", "0.2.0")]), "버전 0.2.0"); - assert_eq!(tr(lang("fr"), "unknown translation"), "unknown translation"); -} - #[test] fn scoped_preview_restores_language_even_after_a_panic() { with_language(Lang::EN, || { with_language(lang("fr"), || { - assert_eq!(t("Layer"), "Calque"); - with_language(lang("ko"), || assert_eq!(t("Layer"), "레이어")); - assert_eq!(t("Layer"), "Calque"); + assert_eq!(current(), lang("fr")); + assert_eq!(t("Layer"), tr(lang("fr"), "Layer")); + with_language(lang("ko"), || assert_eq!(current(), lang("ko"))); + assert_eq!(current(), lang("fr")); }); let result = std::panic::catch_unwind(|| with_language(lang("ja"), || panic!("test unwind"))); assert!(result.is_err()); @@ -120,20 +101,15 @@ fn drawing_languages_are_isolated_between_threads() { with_language(lang("fr"), || { let thread = std::thread::spawn(|| { assert_eq!(current(), Lang::EN); - with_language(lang("ko"), || assert_eq!(t("Layer"), "레이어")); + with_language(lang("ko"), || assert_eq!(t("Layer"), tr(lang("ko"), "Layer"))); }); thread.join().expect("language thread"); - assert_eq!(t("Layer"), "Calque"); + assert_eq!(current(), lang("fr")); }); } #[test] -fn dynamic_brush_sections_have_translations_and_draw_in_the_selected_language() { - for language in Lang::all().filter(|language| language.complete_menus()) { - for (name, _) in crate::brush_panel::SECTIONS { - assert!(has(language, name), "{}: untranslated brush section {name}", language.code()); - } - } +fn dynamic_brush_sections_draw_in_the_selected_language() { with_language(Lang::EN, || { let mut h = harness(); h.state_mut().run("prefs.set", json!({"path": "interface.language", "value": "zh-hans"})).expect("language"); @@ -141,21 +117,13 @@ fn dynamic_brush_sections_have_translations_and_draw_in_the_selected_language() h.run_steps(4); let text = drawn_text(&h); // The section row is the only place the name is drawn: the options pane has no heading (#3477). - assert_eq!(text.iter().filter(|text| text.as_str() == "画笔笔尖形状").count(), 1, "the section row must be translated"); - assert!(!text.iter().any(|text| text == "Brush Tip Shape")); + let expected = tr(lang("zh-hans"), "Brush Tip Shape"); + assert_eq!(text.iter().filter(|text| *text == expected).count(), 1, "the section row must be translated"); }); } #[test] -fn fill_dialog_and_contour_choices_have_translations_and_draw_in_the_selected_language() { - let labels = ["Contents:", "Color:", "Custom Pattern:", "Mode:", "Opacity:", "{size} (was {old})"]; - for language in Lang::all().filter(|language| language.complete_menus()) { - let contents = crate::fill_ui::CONTENTS.iter().map(|(_, label)| *label); - let contours = photocraft_engine::layer_style::CONTOURS.iter().map(|(name, _)| *name); - for text in labels.into_iter().chain(contents).chain(contours) { - assert!(has(language, text), "{}: untranslated {text}", language.code()); - } - } +fn fill_dialog_draws_in_the_selected_language() { with_language(Lang::EN, || { let mut h = harness(); h.state_mut().run("prefs.set", json!({"path": "interface.language", "value": "pt-br"})).expect("language"); @@ -163,21 +131,15 @@ fn fill_dialog_and_contour_choices_have_translations_and_draw_in_the_selected_la crate::menus::invoke(h.state_mut(), &ctx, crate::fill_ui::COMMAND, json!({})).expect("fill dialog"); h.run_steps(4); let text = drawn_text(&h); - for want in ["Conteúdo:", "Cor de primeiro plano", "Modo:", "Opacidade:"] { + for source in ["Contents:", "Foreground Color", "Mode:", "Opacity:"] { + let want = tr(lang("pt-br"), source); assert!(text.iter().any(|text| text == want), "missing {want}"); } - assert!(!text.iter().any(|text| text == "Contents:" || text == "Foreground Color")); }); } #[test] -fn file_info_camera_data_has_translations_and_draws_read_only() { - for language in Lang::all().filter(|language| language.complete_menus()) { - assert!(has(language, "Camera Data"), "{}: untranslated Camera Data", language.code()); - for (_, label) in photocraft_engine::file_cmds::CAMERA_FIELDS { - assert!(language.catalog().contextual("cameraData", label).is_some(), "{}: untranslated cameraData {label}", language.code()); - } - } +fn file_info_camera_data_draws_in_the_selected_language_read_only() { with_language(Lang::EN, || { let mut h = harness(); let exif = photocraft_algo::exif::build(&photocraft_algo::exif::CameraInfo { @@ -199,7 +161,9 @@ fn file_info_camera_data_has_translations_and_draws_read_only() { assert_eq!(h.state_mut().ui.dialog_mut(dialog).expect("open").fields["__camera"]["exposure"], "1/250 s"); h.run_steps(4); let text = drawn_text(&h); - for want in ["Kameradaten", "Hersteller", "SONY", "Belichtungszeit", "1/250 s", "Blende", "f/8"] { + let de = lang("de"); + let labels = [tr(de, "Camera Data"), tr_ctx(de, "cameraData", "Make"), tr_ctx(de, "cameraData", "Exposure"), tr_ctx(de, "cameraData", "F-Stop")]; + for want in labels.into_iter().chain(["SONY", "1/250 s", "f/8"]) { assert!(text.iter().any(|text| text == want), "missing {want}: {text:?}"); } // Read-only: confirming submits only the editable fields. @@ -360,8 +324,9 @@ fn preferences_preview_does_not_commit_until_applied_and_survives_reload() { dialog.fields.get_mut("values").expect("values")["interface"]["language"] = json!("fr"); h.run_steps(3); let text = drawn_text(&h); - assert!(text.iter().any(|text| text == "Apparence"), "the draft language must translate the visible dialog"); - assert!(text.iter().any(|text| text == "Annuler"), "the dialog buttons must preview the language too"); + let fr = lang("fr"); + assert!(text.iter().any(|text| text == tr(fr, "Appearance")), "the draft language must translate the visible dialog"); + assert!(text.iter().any(|text| text == tr(fr, "Cancel")), "the dialog buttons must preview the language too"); assert!(text.iter().any(|text| text == "File"), "the main window keeps its committed language"); assert_eq!(h.state().session.prefs().interface.language, "auto"); assert_eq!(current(), Lang::EN, "preview must not leak to the main window"); @@ -388,14 +353,15 @@ fn keyboard_shortcuts_list_commands_in_the_selected_language() { h.step(); let ctx = h.ctx.clone(); let id = crate::menus::invoke(h.state_mut(), &ctx, "edit.keyboardShortcuts", json!({})).expect("dialog")["dialog"].as_u64().expect("id"); + let pt = lang("pt-br"); + let label = tr_id(pt, "file.saveAs", "Save As…"); // Search matches the translated label and the English one. - for filter in ["salvar como", "save as"] { + for filter in [label.trim_end_matches('…').to_lowercase(), "save as".to_string()] { h.state_mut().ui.dialog_mut(id).expect("shortcuts").fields.insert("filter".into(), json!(filter)); h.run_steps(3); let text = drawn_text(&h); - assert!(text.iter().any(|text| text == "Arquivo"), "{filter}: the menu heading must be translated"); - assert!(text.iter().any(|text| text.trim() == "Salvar como…"), "{filter}: the command must be translated: {text:?}"); - assert!(!text.iter().any(|text| text.trim() == "Save As…"), "{filter}: no English command labels"); + assert!(text.iter().any(|text| text == tr(pt, "File")), "{filter}: the menu heading must be translated"); + assert!(text.iter().any(|text| text.trim() == label), "{filter}: the command must be translated: {text:?}"); } }); } @@ -432,13 +398,18 @@ fn czech_status_notices_and_navigator_draw_translated_text() { None, ); let info = crate::gpu_canvas::GpuInfo { driver: "Mesa 25.0".into(), ..Default::default() }; - assert!(info.lines().iter().any(|line| line == "Ovladač: Mesa 25.0")); + assert!(info.lines().iter().any(|line| *line == fmt(tr(lang("cs"), "Driver: {driver}"), &[("driver", "Mesa 25.0")]))); }); h.run_steps(4); let text = drawn_text(&h); + let cs = lang("cs"); // The Navigator's own text is its tab title now: its zoom row has no label (#4840). - for want in ["Navigátor", "Soubor English file.psd se nepodařilo otevřít: disk full", "Předvolby nelze uložit"] { - assert!(text.iter().any(|text| text == want), "missing {want}: {text:?}"); + for want in [ + tr(cs, "Navigator").to_string(), + fmt(tr(cs, "Couldn't open {name}: {error}"), &[("name", "English file.psd"), ("error", "disk full")]), + tr(cs, "Preferences can't be saved").to_string(), + ] { + assert!(text.contains(&want), "missing {want}: {text:?}"); } // Czech text must be drawable by the bundled fonts, including the new notices. let chars: std::collections::BTreeSet<_> = text.iter().flat_map(|s| s.chars()).filter(|c| !c.is_whitespace()).collect(); @@ -469,7 +440,12 @@ fn czech_shortcut_conflicts_keep_warning_state_and_shortcut_codes() { h.run_steps(4); let fields = &h.state().ui.dialogs.iter().find(|d| d.id == id).expect("dialog").fields; assert_eq!(fields["messageWarning"], json!(true)); - assert!(fields["message"].as_str().expect("message").contains("již používá")); + let message = fields["message"].as_str().expect("message"); + let template = tr(lang("cs"), "{shortcut} is already in use by {commands} and will be removed from it when you click OK."); + // The message is the conflict template, filled in: its fixed text appears around the shortcut and commands. + for piece in template.split('{').map(|piece| piece.split_once('}').map_or(piece, |(_, rest)| rest)).filter(|piece| !piece.trim().is_empty()) { + assert!(message.contains(piece), "{message:?} lacks {piece:?}"); + } assert_eq!(fields["overrides"]["file.new"], json!("Cmd+O")); }); } diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 0ff8a30432..22b9ff60c4 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -43,7 +43,7 @@ pub struct LangInfo { /// Czech: 0 = one, 1 = few (2–4), 2 = other; French: 0 = one (0 and 1), 1 = other). A catalog's `@plural` entries list one form per /// index. pub plural: fn(u64) -> usize, - /// Must the catalog cover every menu string? (checked by the tests) + /// Does the catalog cover every menu string? (`cargo xtask i18n-coverage` lists what it lacks) pub complete_menus: bool, catalog: OnceLock, } @@ -479,10 +479,6 @@ mod tests { assert_eq!(HI().name(), "हिन्दी"); assert_eq!(Lang::from_pref("hi"), HI()); assert_eq!(Lang::from_pref("HI"), HI()); - assert_eq!(tr(HI(), "Layer"), "लेयर"); - assert_eq!(tr(HI(), "File"), "फ़ाइल"); - assert_eq!(trn(HI(), 1, "{n} layer", "{n} layers"), "1 लेयर"); - assert_eq!(trn(HI(), 3, "{n} layer", "{n} layers"), "3 लेयर्स"); } #[test] @@ -499,7 +495,6 @@ mod tests { assert_eq!(lang_from_tag(tag), Some(VI()), "{tag}"); } assert_eq!(VI().name(), "Tiếng Việt"); - assert!(VI().complete_menus(), "Vietnamese translates every source key"); assert_eq!(Lang::from_pref("vi"), VI()); assert_eq!(Lang::from_pref("VI"), VI()); } @@ -578,87 +573,21 @@ mod tests { assert_eq!(Lang::from_pref("xx-unknown"), Lang::EN); } - /// The brush size/hardness and preset-stepping shortcut rows (`tools.*` commands without a menu item) must - /// be translated in every catalog, or they show English in the Keyboard Shortcuts dialog and cannot be found - /// by searching. #[test] - fn brush_size_and_hardness_shortcuts_are_translated_in_every_language() { - let keys = [ - "Decrease Brush Size", - "Increase Brush Size", - "Decrease Brush Hardness", - "Increase Brush Hardness", - "Previous Brush", - "Next Brush", - "First Brush", - "Last Brush", - ]; - for lang in Lang::all().filter(|lang| *lang != Lang::EN) { - for key in keys { - assert!(has(lang, key), "{} has no translation for {key:?}", lang.code()); - } - } + fn simplified_chinese_resolves_from_preferences() { let zh = Lang::from_code("zh-hans").expect("zh-hans registered"); - assert_eq!(tr(zh, "Decrease Brush Size"), "减小画笔大小"); - assert_eq!(tr(zh, "Increase Brush Size"), "增大画笔大小"); - } - - #[test] - fn simplified_chinese_covers_dynamic_shortcuts_and_layer_counts() { - let zh = Lang::from_code("zh-hans").expect("zh-hans registered"); - assert!(zh.complete_menus(), "Simplified Chinese must participate in the coverage gates"); assert_eq!(Lang::from_pref("ZH-Hans"), zh); - assert_eq!(tr(zh, "Pixel Layer"), "像素图层"); - assert_eq!(tr(zh, "System Info"), "系统信息"); - for key in ["⌥", "Alt"] { - assert_eq!(fmt(tr(zh, "Add a mask (from the selection; {key} inverts)"), &[("key", key)]), format!("添加蒙版 (基于选区;{key} 反相)")); - } - for n in [0, 1, 3] { - assert_eq!(trn(zh, n, "{n} layer", "{n} layers"), format!("{n} 个图层")); - } assert_eq!(tr(zh, "no such label"), "no such label"); } - /// Keep the Korean tool/menu vocabulary aligned with the Photoshop equivalents. - /// Sources and the product-specific vocabulary policy are recorded in `ko.tsv`. - #[test] - fn korean_uses_photoshop_terminology() { - let ko = Lang::from_code("ko").expect("ko registered"); - for (source, expected) in [ - ("Shape", "모양"), - ("Stroke", "획"), - ("Smudge Tool", "손가락 도구"), - ("Eyedropper Tool", "스포이드 도구"), - ("Rectangular Marquee Tool", "사각형 선택 윤곽 도구"), - ("Elliptical Marquee Tool", "원형 선택 윤곽 도구"), - ("Zoom Tool", "돋보기 도구"), - ("Horizontal Type Tool", "수평 문자 도구"), - ("Puppet Warp", "퍼펫 뒤틀기"), - ("Liquify…", "픽셀 유동화…"), - ("Gaussian Blur…", "가우시안 흐림 효과…"), - ("Gaussian Blur", "가우시안 흐림 효과"), - ("Adaptive Wide Angle…", "응용 광각…"), - ("Render", "렌더"), - ("Sharpen", "선명 효과"), - ("Vibrance", "활기"), - ("Layer Comps", "레이어 구성 요소"), - ("Vivid Light", "선명한 라이트"), - ("Hard Mix", "하드 혼합"), - ] { - assert_eq!(tr(ko, source), expected, "{source}"); - } - assert_eq!(tr_id(ko, "filter.sharpen.sharpen", "Sharpen"), "선명하게"); - } - #[test] fn spanish_resolves_and_pluralises() { let es = Lang::from_code("es").expect("es registered"); for tag in ["es", "es_ES.UTF-8", "es-MX", "es-419"] { assert_eq!(lang_from_tag(tag), Some(es), "{tag}"); } - assert_eq!(tr(es, "Layer"), "Capa"); - assert_eq!(trn(es, 1, "{n} item", "{n} items"), "1 elemento"); - assert_eq!(trn(es, 3, "{n} item", "{n} items"), "3 elementos"); + let forms: Vec = [0, 1, 3].into_iter().map(es.0.plural).collect(); + assert_eq!(forms, [1, 0, 1]); } #[test] @@ -667,14 +596,9 @@ mod tests { for tag in ["hu", "hu_HU.UTF-8", "hu-HU"] { assert_eq!(lang_from_tag(tag), Some(hu), "{tag}"); } - assert!(hu.complete_menus()); assert_eq!(hu.name(), "Magyar"); - assert_eq!(tr(hu, "Layer"), "Réteg"); - for (n, expected) in [(0, "0 réteg"), (1, "1 réteg"), (3, "3 réteg"), (21, "21 réteg")] { - assert_eq!(trn(hu, n, "{n} layer", "{n} layers"), expected); - } - assert_eq!(tr_ctx(hu, "layerLabel", "Red"), "Piros"); - assert_eq!(tr(hu, "Red"), "Vörös"); + let forms: Vec = [0, 1, 3, 21].into_iter().map(hu.0.plural).collect(); + assert_eq!(forms, [0, 0, 0, 0]); } #[test] @@ -684,241 +608,63 @@ mod tests { assert_eq!(lang_from_tag(tag), Some(pt), "{tag}"); } assert_eq!(candidates("pt_PT"), ["pt-pt", "pt-br", "pt"]); - assert_eq!(tr(pt, "Layer"), "Camada"); - assert_eq!(trn(pt, 0, "{n} item", "{n} items"), "0 item"); - assert_eq!(trn(pt, 1, "{n} item", "{n} items"), "1 item"); - assert_eq!(trn(pt, 2, "{n} item", "{n} items"), "2 itens"); + let forms: Vec = [0, 1, 2].into_iter().map(pt.0.plural).collect(); + assert_eq!(forms, [0, 0, 1]); } #[test] fn lookups_fall_back_to_english() { assert_eq!(tr(JA(), "no such label"), "no such label"); assert_eq!(tr(Lang::EN, "Layer"), "Layer"); - assert_eq!(tr(JA(), "Layer"), "レイヤー"); - assert_eq!(tr(ZH(), "Layer"), "圖層"); assert_eq!(tr(ZH(), "no such label"), "no such label"); - assert_eq!(tr_id(ZH(), "no.such.id", "Layer"), "圖層"); - assert_eq!(tr_id(JA(), "no.such.id", "Layer"), "レイヤー"); - assert_eq!(tr_ctx(JA(), "no such context", "Layer"), "レイヤー"); + assert_eq!(tr_id(ZH(), "no.such.id", "Layer"), tr(ZH(), "Layer")); + assert_eq!(tr_id(JA(), "no.such.id", "Layer"), tr(JA(), "Layer")); + assert_eq!(tr_ctx(JA(), "no such context", "Layer"), tr(JA(), "Layer")); + assert_eq!(trn(JA(), 3, "{n} no such message", "{n} no such messages"), "3 no such messages"); } #[test] - fn russian_plural_rules() { - let ru = || Lang::from_code("ru").expect("ru registered"); - assert_eq!(trn(ru(), 1, "{n} item", "{n} items"), "1 элемент"); - assert_eq!(trn(ru(), 2, "{n} item", "{n} items"), "2 элемента"); - assert_eq!(trn(ru(), 5, "{n} item", "{n} items"), "5 элементов"); - assert_eq!(trn(ru(), 11, "{n} item", "{n} items"), "11 элементов"); - assert_eq!(trn(ru(), 21, "{n} item", "{n} items"), "21 элемент"); - assert_eq!(trn(ru(), 22, "{n} item", "{n} items"), "22 элемента"); - assert_eq!(trn(ru(), 101, "{n} item", "{n} items"), "101 элемент"); - assert_eq!(trn(ru(), 111, "{n} item", "{n} items"), "111 элементов"); - } - - #[test] - fn russian_status_messages_agree_with_counts() { + fn russian_plural_rule() { let ru = Lang::from_code("ru").expect("ru registered"); - let cases = [ - ( - "Exported {n} swatch to {file}", - "Exported {n} swatches to {file}", - ["Экспортирован {n} образец в {file}", "Экспортированы {n} образца в {file}", "Экспортировано {n} образцов в {file}"], - ), - ( - "Imported {n} brush from {group}", - "Imported {n} brushes from {group}", - ["Импортирована {n} кисть из {group}", "Импортированы {n} кисти из {group}", "Импортировано {n} кистей из {group}"], - ), - ( - "Imported {n} swatch from {file}", - "Imported {n} swatches from {file}", - ["Импортирован {n} образец из {file}", "Импортированы {n} образца из {file}", "Импортировано {n} образцов из {file}"], - ), - ("Played {n} step", "Played {n} steps", ["Воспроизведён {n} шаг", "Воспроизведены {n} шага", "Воспроизведено {n} шагов"]), - ( - "Updated {n} linked smart object changed on disk", - "Updated {n} linked smart objects changed on disk", - [ - "Обновлён {n} связанный смарт-объект, изменённый на диске", - "Обновлены {n} связанных смарт-объекта, изменённых на диске", - "Обновлено {n} связанных смарт-объектов, изменённых на диске", - ], - ), - ( - "{n} layer recorded by this comp was deleted. Update the comp, or clear the warning.", - "{n} layers recorded by this comp were deleted. Update the comp, or clear the warning.", - [ - "Удалён {n} слой, записанный в этом варианте. Обновите вариант или сбросьте предупреждение.", - "Удалены {n} слоя, записанных в этом варианте. Обновите вариант или сбросьте предупреждение.", - "Удалено {n} слоёв, записанных в этом варианте. Обновите вариант или сбросьте предупреждение.", - ], - ), - ("{n} sample point removed", "{n} sample points removed", ["Удалена {n} точка замера", "Удалены {n} точки замера", "Удалено {n} точек замера"]), - ( - "Loaded {n} action from {file}", - "Loaded {n} actions from {file}", - ["Загружена {n} операция из {file}", "Загружены {n} операции из {file}", "Загружено {n} операций из {file}"], - ), - ( - "Saved {n} action to {file}", - "Saved {n} actions to {file}", - ["Сохранена {n} операция в {file}", "Сохранены {n} операции в {file}", "Сохранено {n} операций в {file}"], - ), - ( - "{n} step with no Photoshop equivalent left out", - "{n} steps with no Photoshop equivalent left out", - [ - "Пропущен {n} шаг без эквивалента в Photoshop", - "Пропущены {n} шага без эквивалента в Photoshop", - "Пропущено {n} шагов без эквивалента в Photoshop", - ], - ), - ]; - // Include zero, teens, and compound counts as well as each basic form. - for (n, form) in [(0, 2), (1, 0), (2, 1), (3, 1), (4, 1), (5, 2), (11, 2), (12, 2), (14, 2), (21, 0), (22, 1), (25, 2), (101, 0), (112, 2)] { - for (one, other, forms) in cases { - let expected = forms[form].replace("{n}", &n.to_string()); - assert_eq!(trn(ru, n, one, other), expected, "{one}, n={n}"); - } - } - assert_eq!( - fmt(&trn(ru, 2, "Imported {n} brush from {group}", "Imported {n} brushes from {group}"), &[("group", "Кисти")]), - "Импортированы 2 кисти из Кисти" - ); - assert_eq!( - fmt(&trn(ru, 22, "Loaded {n} action from {file}", "Loaded {n} actions from {file}"), &[("file", "actions.atn")]), - "Загружены 22 операции из actions.atn" - ); - } - - #[test] - fn russian_font_weights_and_reviewed_labels() { - let ru = Lang::from_code("ru").expect("ru registered"); - assert_eq!(tr_ctx(ru, "fontWeight", "ExtraLight"), "Сверхтонкий"); - assert_eq!(tr_ctx(ru, "fontWeight", "Light"), "Лёгкий"); - assert_eq!(tr_ctx(ru, "fontWeight", "ExtraBold"), "Сверхжирный"); - assert_eq!(tr_ctx(ru, "fontWeight", "Black"), "Сверхжирный (Black)"); - assert_eq!(tr(ru, "Delete and Fill Selection"), "Удалить и залить выделение"); - assert_eq!(tr(ru, "Discard the embedded profile (don't color manage)"), "Не использовать встроенный профиль (без управления цветом)"); - assert_eq!(tr(ru, "No object found there — drag a rectangle around it"), "Объект не найден — обведите его прямоугольником"); + let forms: Vec = [0, 1, 2, 5, 11, 12, 14, 21, 22, 25, 101, 111, 112].into_iter().map(ru.0.plural).collect(); + assert_eq!(forms, [2, 0, 1, 2, 2, 2, 2, 0, 1, 2, 0, 2, 2]); } #[test] fn ukrainian_resolves_locales_and_preferences() { let uk = Lang::from_code("uk").expect("uk registered"); assert_eq!(uk.name(), "Українська"); - assert!(uk.complete_menus()); for tag in ["uk", "UK", "uk-UA", "uk_UA", "uk_UA.UTF-8", "uk-UA@euro", "uk-Cyrl-UA"] { assert_eq!(lang_from_tag(tag), Some(uk), "{tag}"); assert_eq!(Lang::from_pref(tag), uk, "{tag}"); } - assert_eq!(tr(uk, "File"), "Файл"); - assert_eq!(tr(uk, "Layer"), "Шар"); - assert_eq!(tr(uk, "New document…"), "Новий документ…"); - assert_eq!(tr(uk, "No properties"), "Немає властивостей"); - assert_eq!(tr_id(uk, "select.all", "All"), "Виділити все"); - assert_eq!(tr_ctx(uk, "cameraRaw", "Light"), "Світло"); - assert_eq!(tr_ctx(uk, "fontWeight", "Light"), "Легкий"); - assert_eq!(fmt(tr(uk, "Camera Raw Filter ({layer})"), &[("layer", "Background")]), "Фільтр Camera Raw (Background)"); - for key in ["Alt", "⌥"] { - assert_eq!( - fmt(tr(uk, "Add a mask (from the selection; {key} inverts)"), &[("key", key)]), - format!("Додати маску (із виділення; {key} інвертує)") - ); - } assert_eq!(tr(uk, "unknown translation"), "unknown translation"); } #[test] - fn ukrainian_plural_messages_handle_teens_and_compound_counts() { + fn ukrainian_plural_rule_handles_teens_and_compound_counts() { let uk = Lang::from_code("uk").expect("uk registered"); - for (n, item, layer) in [ - (0, "елементів", "шарів"), - (1, "елемент", "шар"), - (2, "елементи", "шари"), - (4, "елементи", "шари"), - (5, "елементів", "шарів"), - (11, "елементів", "шарів"), - (12, "елементів", "шарів"), - (14, "елементів", "шарів"), - (19, "елементів", "шарів"), - (21, "елемент", "шар"), - (22, "елементи", "шари"), - (24, "елементи", "шари"), - (25, "елементів", "шарів"), - (100, "елементів", "шарів"), - (101, "елемент", "шар"), - (111, "елементів", "шарів"), - (112, "елементів", "шарів"), - (114, "елементів", "шарів"), - (121, "елемент", "шар"), - (122, "елементи", "шари"), - (u64::MAX, "елементів", "шарів"), - ] { - assert_eq!(trn(uk, n, "{n} item", "{n} items"), format!("{n} {item}")); - assert_eq!(trn(uk, n, "{n} layer", "{n} layers"), format!("{n} {layer}")); - assert_eq!(trn(uk, n, "Group · {n} layer", "Group · {n} layers"), format!("Група · {n} {layer}")); - } + let counts = [0, 1, 2, 4, 5, 11, 12, 14, 19, 21, 22, 24, 25, 100, 101, 111, 112, 114, 121, 122, u64::MAX]; + let forms: Vec = counts.into_iter().map(uk.0.plural).collect(); + assert_eq!(forms, [2, 0, 1, 1, 2, 2, 2, 2, 2, 0, 1, 1, 2, 2, 0, 2, 2, 2, 0, 1, 2]); } #[test] fn serbian_resolves_locales_and_preferences() { let sr = Lang::from_code("sr").expect("sr registered"); assert_eq!(sr.name(), "Srpski"); - assert!(sr.complete_menus()); for tag in ["sr", "SR", "sr-RS", "sr_RS", "sr_RS.UTF-8", "sr_RS@latin", "sr-Latn", "sr-Latn-RS", "sr-Cyrl-RS", "sr-ME"] { assert_eq!(lang_from_tag(tag), Some(sr), "{tag}"); assert_eq!(Lang::from_pref(tag), sr, "{tag}"); } - assert_eq!(tr(sr, "File"), "Datoteka"); - assert_eq!(tr(sr, "Layer"), "Sloj"); - assert_eq!(tr(sr, "New document…"), "Novi dokument…"); - assert_eq!(tr_id(sr, "select.all", "All"), "Izaberi sve", "an id override wins over the plain label"); - assert_eq!(tr(sr, "All"), "Sve"); - assert_eq!(tr_ctx(sr, "cameraRaw", "Light"), "Svetlo"); - assert_eq!(tr_ctx(sr, "fontWeight", "Light"), "Lagano"); - assert_eq!(fmt(tr(sr, "Camera Raw Filter ({layer})"), &[("layer", "Background")]), "Camera Raw filter (Background)"); - for key in ["Alt", "⌥"] { - assert_eq!( - fmt(tr(sr, "Add a mask (from the selection; {key} inverts)"), &[("key", key)]), - format!("Dodaj masku (iz selekcije; {key} invertuje)") - ); - } } #[test] - fn serbian_plural_messages_handle_teens_and_compound_counts() { + fn serbian_plural_rule_handles_teens_and_compound_counts() { let sr = Lang::from_code("sr").expect("sr registered"); - for (n, item, layer) in [ - (0, "stavki", "slojeva"), - (1, "stavka", "sloj"), - (2, "stavke", "sloja"), - (4, "stavke", "sloja"), - (5, "stavki", "slojeva"), - (11, "stavki", "slojeva"), - (12, "stavki", "slojeva"), - (14, "stavki", "slojeva"), - (21, "stavka", "sloj"), - (22, "stavke", "sloja"), - (25, "stavki", "slojeva"), - (101, "stavka", "sloj"), - (111, "stavki", "slojeva"), - (112, "stavki", "slojeva"), - (122, "stavke", "sloja"), - ] { - assert_eq!(trn(sr, n, "{n} item", "{n} items"), format!("{n} {item}")); - assert_eq!(trn(sr, n, "{n} layer", "{n} layers"), format!("{n} {layer}")); - assert_eq!(trn(sr, n, "Group · {n} layer", "Group · {n} layers"), format!("Grupa · {n} {layer}")); - } - for (n, expected) in [ - (1, "Uklonjena je 1 tačka uzorkovanja"), - (3, "Uklonjene su 3 tačke uzorkovanja"), - (5, "Uklonjeno je 5 tačaka uzorkovanja"), - (13, "Uklonjeno je 13 tačaka uzorkovanja"), - (21, "Uklonjena je 21 tačka uzorkovanja"), - ] { - assert_eq!(trn(sr, n, "{n} sample point removed", "{n} sample points removed"), expected); - } + let counts = [0, 1, 2, 4, 5, 11, 12, 14, 21, 22, 25, 101, 111, 112, 122]; + let forms: Vec = counts.into_iter().map(sr.0.plural).collect(); + assert_eq!(forms, [2, 0, 1, 1, 2, 2, 2, 2, 0, 1, 2, 0, 2, 2, 1]); } #[test] @@ -945,14 +691,14 @@ mod tests { assert_eq!(trn(Lang::EN, 1, "{n} item", "{n} items"), "1 item"); assert_eq!(trn(Lang::EN, 0, "{n} item", "{n} items"), "0 items"); assert_eq!(trn(Lang::EN, 7, "{n} item", "{n} items"), "7 items"); - assert_eq!(trn(JA(), 1, "{n} item", "{n} items"), "1 件"); - assert_eq!(trn(JA(), 7, "{n} item", "{n} items"), "7 件"); - assert_eq!(trn(ZH(), 1, "{n} item", "{n} items"), "1 個項目"); - assert_eq!(trn(ZH(), 7, "{n} item", "{n} items"), "7 個項目"); - assert_eq!(trn(CS(), 1, "{n} item", "{n} items"), "1 položka"); - assert_eq!(trn(CS(), 3, "{n} item", "{n} items"), "3 položky"); - assert_eq!(trn(CS(), 5, "{n} item", "{n} items"), "5 položek"); - assert_eq!(trn(CS(), 0, "{n} item", "{n} items"), "0 položek"); + let cs = fixture_lang(plural_cs, "@plural\t{n} item|{n} items\t{n} one|{n} few|{n} other\n"); + for (n, expected) in [(0, "0 other"), (1, "1 one"), (3, "3 few"), (5, "5 other")] { + assert_eq!(trn(cs, n, "{n} item", "{n} items"), expected); + } + let ja = fixture_lang(plural_none, "@plural\t{n} item|{n} items\t{n} only\n"); + assert_eq!(trn(ja, 1, "{n} item", "{n} items"), "1 only"); + assert_eq!(trn(ja, 7, "{n} item", "{n} items"), "7 only"); + assert_eq!(trn(ja, 7, "{n} file", "{n} files"), "7 files", "a missing row falls back to English"); assert_eq!(fmt("{b} before {a}", &[("a", "x"), ("b", "y"), ("c", "z")]), "y before x"); assert_eq!(fmt("{missing}", &[]), "{missing}"); assert_eq!(placeholders("a {x} b {y} {"), ["x", "y"]); @@ -964,14 +710,27 @@ mod tests { for tag in ["fr", "fr_FR.UTF-8", "fr-CA", "fr_BE", "fr-CH"] { assert_eq!(lang_from_tag(tag), Some(fr), "{tag}"); } - assert_eq!(tr(fr, "Layer"), "Calque"); - assert_eq!(tr_id(fr, "select.all", "All"), "Tout sélectionner", "an id override wins over the plain label"); - assert_eq!(tr(fr, "All"), "Tout"); - let forms: Vec = [0, 1, 2, 5, 100, u64::MAX].into_iter().map(plural_fr).collect(); + let forms: Vec = [0, 1, 2, 5, 100, u64::MAX].into_iter().map(fr.0.plural).collect(); assert_eq!(forms, [0, 0, 1, 1, 1, 1]); - assert_eq!(trn(fr, 0, "{n} item", "{n} items"), "0 élément"); - assert_eq!(trn(fr, 1, "{n} item", "{n} items"), "1 élément"); - assert_eq!(trn(fr, 3, "{n} item", "{n} items"), "3 éléments"); + } + + /// A language whose catalog is `text`, for testing lookups without depending on a bundled + /// catalog's wording. + fn fixture_lang(plural: fn(u64) -> usize, text: &str) -> Lang { + let catalog = OnceLock::new(); + let _ = catalog.set(Catalog::parse(text)); + Lang(Box::leak(Box::new(LangInfo { code: "xx", name: "x", source: b"", plural, complete_menus: false, catalog }))) + } + + #[test] + fn id_and_context_rows_win_over_the_plain_label() { + let lang = fixture_lang(plural_one_other, "\tAll\tplain all\n@id\tselect.all\tid all\nnavigator\tZoom\tcontext zoom\n\tZoom\tplain zoom\n"); + assert_eq!(tr_id(lang, "select.all", "All"), "id all"); + assert_eq!(tr_id(lang, "edit.other", "All"), "plain all"); + assert_eq!(tr(lang, "All"), "plain all"); + assert_eq!(tr_ctx(lang, "navigator", "Zoom"), "context zoom"); + assert_eq!(tr_ctx(lang, "other", "Zoom"), "plain zoom"); + assert_eq!(tr_ctx(lang, "navigator", "Unlisted"), "Unlisted"); } #[test] @@ -980,61 +739,10 @@ mod tests { assert_eq!(forms, [2, 0, 1, 1, 2, 2, 2, 2, 1, 2, 2, 1]); } - /// Counted status messages read in Simplified Chinese instead of falling back to English (they were - /// missing from the catalog). - #[test] - fn simplified_chinese_counted_messages_are_translated() { - let lang = Lang::from_code("zh-hans").expect("zh-hans registered"); - assert_eq!(trn(lang, 3, "Played {n} step", "Played {n} steps"), "已播放 3 个步骤"); - let brushes = trn(lang, 2, "Imported {n} brush from {group}", "Imported {n} brushes from {group}"); - assert_eq!(fmt(&brushes, &[("group", "水彩")]), "已从 水彩 导入 2 个画笔"); - assert_eq!(trn(lang, 5, "{n} slice", "{n} slices"), "5 个切片"); - } - - /// Counted status messages read in Korean instead of falling back to English (they were - /// missing from the catalog). - #[test] - fn korean_counted_messages_are_translated() { - let lang = Lang::from_code("ko").expect("ko registered"); - assert_eq!(trn(lang, 3, "Played {n} step", "Played {n} steps"), "단계 3개를 재생했습니다"); - let brushes = trn(lang, 2, "Imported {n} brush from {group}", "Imported {n} brushes from {group}"); - assert_eq!(fmt(&brushes, &[("group", "수채화")]), "수채화에서 브러시 2개를 가져왔습니다"); - assert_eq!(trn(lang, 5, "{n} slice", "{n} slices"), "분할 영역 5개"); - } - - /// Counted status messages read in Indonesian instead of falling back to English (they were - /// missing from the catalog). - #[test] - fn indonesian_counted_messages_are_translated() { - let lang = Lang::from_code("id").expect("id registered"); - assert_eq!(trn(lang, 3, "Played {n} step", "Played {n} steps"), "3 langkah diputar"); - let brushes = trn(lang, 2, "Imported {n} brush from {group}", "Imported {n} brushes from {group}"); - assert_eq!(fmt(&brushes, &[("group", "Cat Air")]), "2 kuas diimpor dari Cat Air"); - assert_eq!(trn(lang, 5, "{n} slice", "{n} slices"), "5 slice"); - } - - /// Counted status messages read in Polish with all three forms instead of falling back to - /// English (they were missing from the catalog). - #[test] - fn polish_counted_messages_use_all_three_forms() { - let pl = Lang::from_code("pl").expect("pl registered"); - let steps = |n| trn(pl, n, "Played {n} step", "Played {n} steps"); - assert_eq!( - [steps(1), steps(3), steps(5), steps(12), steps(22)], - ["Odtworzono 1 krok", "Odtworzono 3 kroki", "Odtworzono 5 kroków", "Odtworzono 12 kroków", "Odtworzono 22 kroki"] - ); - let brushes = trn(pl, 2, "Imported {n} brush from {group}", "Imported {n} brushes from {group}"); - assert_eq!(fmt(&brushes, &[("group", "Akwarele")]), "Zaimportowano 2 pędzle z Akwarele"); - assert_eq!(trn(pl, 5, "{n} slice", "{n} slices"), "5 plasterków"); - } - #[test] fn czech_plural_rule() { let forms: Vec = [0, 1, 2, 3, 4, 5, 11, 12, 21, 22, 100, u64::MAX].into_iter().map(plural_cs).collect(); assert_eq!(forms, [2, 0, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2]); - assert_eq!(tr(CS(), "Layer"), "Vrstva"); - assert_eq!(tr_id(CS(), "select.all", "All"), "Vybrat vše", "an id override wins over the plain label"); - assert_eq!(tr(CS(), "All"), "Vše"); } /// The packed catalogs in the binary unpack to exactly the `.tsv` files. @@ -1117,428 +825,6 @@ mod tests { } } } - - /// Languages that claim complete menus have an entry for every label and path segment. - #[test] - fn complete_languages_translate_every_menu_string() { - let mut strings = std::collections::BTreeSet::new(); - for &(path, label, _, _) in crate::menu_catalog::CATALOG { - strings.extend(path.iter().copied()); - strings.insert(label); - } - for &(_, label, path, _) in crate::menus::UI_COMMANDS { - strings.extend(path.iter().copied()); - strings.insert(label); - } - for c in photocraft_engine::command_specs().iter().filter(|c| !c.menu.is_empty()) { - strings.extend(c.menu.iter().copied()); - strings.insert(c.label); - } - strings.remove("---"); - for l in LANGUAGES.iter().filter(|l| l.complete_menus) { - let cat = l.catalog(); - let missing: Vec<_> = strings.iter().filter(|s| cat.plain(s).is_none()).collect(); - assert!(missing.is_empty(), "{}: untranslated menu strings: {missing:#?}", l.code); - } - } - - /// `text` with each `#[cfg(test)] mod … { … }` block removed, by brace counting (the test - /// modules in this crate are well formed). - fn without_test_modules(text: &str) -> String { - let mut out = String::with_capacity(text.len()); - let mut rest = text; - while let Some(at) = rest.find("#[cfg(test)]\nmod ") { - out.push_str(&rest[..at]); - let after = &rest[at..]; - let Some(open) = after.find('{') else { return out }; - let mut depth = 0usize; - let mut end = after.len(); - for (i, c) in after[open..].char_indices() { - match c { - '{' => depth += 1, - '}' => { - depth -= 1; - if depth == 0 { - end = open + i + 1; - break; - } - } - _ => {} - } - } - rest = &after[end..]; - } - out.push_str(rest); - out - } - - #[test] - fn test_modules_are_cut_out_one_by_one() { - let text = "a\n#[cfg(test)]\nmod t { fn x() { tl!(\"hidden\"); } }\nb tl!(\"seen\")\n#[cfg(test)]\nmod u {}\nc"; - let code = without_test_modules(text); - assert!(code.contains("tl!(\"seen\")"), "code after the first test module is scanned: {code}"); - assert!(!code.contains("hidden"), "{code}"); - assert!(code.starts_with("a\n") && code.ends_with("\nc"), "{code}"); - } - - #[test] - fn czech_status_templates_preserve_details_and_plural_counts() { - let cs = CS(); - let template = "Step {step} ({id}) failed: {error}"; - let args = [("step", "2"), ("id", "file.export"), ("error", "disk full: /tmp/image.psd")]; - assert_eq!(fmt(tr(cs, template), &args), "Krok 2 (file.export) selhal: disk full: /tmp/image.psd"); - assert_eq!(fmt(tr(Lang::EN, template), &args), "Step 2 (file.export) failed: disk full: /tmp/image.psd"); - // Languages without these dynamic templates keep the English source. - assert_eq!(tr(JA(), template), template); - for (n, expected) in [(0, "Odstraněno 0 bodů měření"), (1, "Odstraněn 1 bod měření"), (2, "Odstraněny 2 body měření"), (5, "Odstraněno 5 bodů měření")] - { - assert_eq!(trn(cs, n, "{n} sample point removed", "{n} sample points removed"), expected); - } - let one = "Imported {n} swatch from {file}"; - let other = "Imported {n} swatches from {file}"; - assert_eq!(fmt(&trn(cs, 3, one, other), &[("file", "English name.aco")]), "Importovány 3 vzorky ze souboru English name.aco"); - assert_eq!(tr(cs, "Preferences can't be saved"), "Předvolby nelze uložit"); - } - - #[test] - fn czech_dialog_and_hardware_labels_preserve_names() { - let cs = CS(); - for label in [ - "New Crop Preset…", - "Delete Crop Preset…", - "Color Picker (Background Color)", - "Color Picker (Foreground Color)", - "Pro (Medium Gray)", - "Grid view", - "List view", - "Scroll menu up", - "Scroll menu down", - "Commit transform", - "Cancel transform", - ] { - assert_ne!(tr(cs, label), label, "{label}"); - } - for kind in [ - crate::rasterize_prompt::Kind::Type, - crate::rasterize_prompt::Kind::Shape, - crate::rasterize_prompt::Kind::SmartObject, - crate::rasterize_prompt::Kind::Fill, - ] { - assert_ne!(tr(cs, kind.message()), kind.message()); - } - assert_eq!(fmt(tr(cs, "Driver: {driver}"), &[("driver", "Mesa 25.0")]), "Ovladač: Mesa 25.0"); - assert_eq!( - fmt(tr(cs, "Monitor profile on {display}: {profile}"), &[("display", "DELL U2720Q"), ("profile", "sRGB IEC61966-2.1")]), - "Profil monitoru DELL U2720Q: sRGB IEC61966-2.1" - ); - } - - #[test] - fn czech_composed_labels_use_context_and_preserve_document_names() { - let cs = CS(); - assert_eq!(tr(cs, "Zoom"), "Lupa"); - assert_eq!(tr_ctx(cs, "navigator", "Zoom"), "Zvětšení"); - assert_eq!(tr_ctx(Lang::EN, "navigator", "Zoom"), "Zoom"); - let template = "Data Set: {name} ({index}/{total})"; - assert_eq!(fmt(tr(cs, template), &[("name", "Data Set 1"), ("index", "1"), ("total", "2")]), "Datová sada: Data Set 1 (1/2)"); - for (n, expected) in - [(1, "Importován 1 přechod ze souboru Sunset"), (3, "Importovány 3 přechody ze souboru Sunset"), (5, "Importováno 5 přechodů ze souboru Sunset")] - { - assert_eq!(fmt(&trn(cs, n, "Imported {n} gradient from {file}", "Imported {n} gradients from {file}"), &[("file", "Sunset")]), expected); - } - } - - /// Russian dynamic labels added in the fourth pass (filter parameters, Camera Raw panels, - /// hardware status, rasterize prompts, History/paths menus) render in Russian, not English. - #[test] - fn russian_dynamic_status_hardware_and_dialog_labels_are_translated() { - let ru = Lang::from_code("ru").expect("ru registered"); - for kind in [ - crate::rasterize_prompt::Kind::Type, - crate::rasterize_prompt::Kind::Shape, - crate::rasterize_prompt::Kind::SmartObject, - crate::rasterize_prompt::Kind::Fill, - ] { - assert_ne!(tr(ru, kind.message()), kind.message(), "{:?}", kind); - } - assert_eq!(fmt(tr(ru, "Driver: {driver}"), &[("driver", "Mesa 25.0")]), "Драйвер: Mesa 25.0"); - assert_eq!( - fmt(tr(ru, "Monitor profile on {display}: {profile}"), &[("display", "DELL U2720Q"), ("profile", "sRGB IEC61966-2.1")]), - "Профиль монитора для DELL U2720Q: sRGB IEC61966-2.1" - ); - assert_eq!(fmt(tr(ru, "Platform: {os} {arch}"), &[("os", "Linux"), ("arch", "x86_64")]), "Платформа: Linux x86_64"); - assert_eq!(tr(ru, "Graphics adapter"), "Графический адаптер"); - assert_eq!(tr(ru, "Image compositor: {processor}"), "Компоновщик изображения: {processor}"); - // Filter / adjustment parameter names. - for label in [ - "Amount", - "Radius", - "Threshold", - "Angle", - "High Pass", - "Smart Blur", - "Reduce Noise", - "Lens Correction", - "Colored Pencil", - "Angled Strokes", - "Stained Glass", - "Sumi-e", - "Film Grain", - "Unsharp Mask", - "Lens Flare", - ] { - assert!(has(ru, label), "Russian filter label missing: {label}"); - } - // Dynamic composed status templates. - assert_eq!( - fmt(tr(ru, "Step {step} ({id}) failed: {error}"), &[("step", "2"), ("id", "file.export"), ("error", "ok")]), - "Шаг 2 (file.export) не выполнен: ok" - ); - assert_eq!(fmt(tr(ru, "Couldn't open {name}: {error}"), &[("name", "scan.tif"), ("error", "denied")]), "Не удалось открыть scan.tif: denied"); - assert_eq!(fmt(tr(ru, "Exported {path} ({size})"), &[("path", "/tmp/a.png"), ("size", "12 KB")]), "Экспортировано: /tmp/a.png (12 KB)"); - assert_eq!(fmt(tr(ru, "Data Set: {name} ({index}/{total})"), &[("name", "Set 1"), ("index", "1"), ("total", "2")]), "Набор данных: Set 1 (1/2)"); - // Document and layer names stay verbatim. - let text = fmt(tr(ru, "Saved {path}; image assets: {n} in {dir}"), &[("path", "/tmp/a.pcraft"), ("n", "3"), ("dir", "/tmp/assets")]); - assert!(text.contains("/tmp/a.pcraft") && text.contains("/tmp/assets"), "{text}"); - } - - /// Every `tl!("literal")` in the shell has an entry in each language that claims complete menus - /// (so a new label can't ship untranslated by accident). Literals that are deliberately shown as - /// they are (names, units) are listed in `KEEP_AS_IS`. - #[test] - fn every_tl_literal_is_translated() { - const KEEP_AS_IS: &[&str] = &[]; - let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); - let mut literals = std::collections::BTreeSet::new(); - let mut stack = vec![dir]; - while let Some(d) = stack.pop() { - for entry in std::fs::read_dir(&d).into_iter().flatten().flatten() { - let path = entry.path(); - if path.is_dir() { - stack.push(path); - } else if path.extension().is_some_and(|e| e == "rs") && !path.ends_with("lib.rs") { - let text = std::fs::read_to_string(&path).unwrap_or_default().replace("\r\n", "\n"); - // Test modules aside, scan every `tl!("…")`. Each test *module* is cut out - // on its own: cutting the file at the first one hid everything after a small - // test module early in a file (panels.rs has one at ~line 540). A - // `#[cfg(test)]` on a single item doesn't count. - let code = without_test_modules(&text); - let mut rest = code.as_str(); - while let Some(at) = rest.find("tl!(\"") { - rest = &rest[at + 5..]; - let mut end = 0; - let bytes = rest.as_bytes(); - while end < bytes.len() && !(bytes[end] == b'"' && (end == 0 || bytes[end - 1] != b'\\')) { - end += 1; - } - let lit = rest.get(..end).unwrap_or("").replace("\\\"", "\""); - if rest.get(end + 1..end + 2) == Some(")") { - literals.insert(lit); - } - } - } - } - } - assert!(literals.len() > 300, "scan found only {} literals", literals.len()); - for l in LANGUAGES.iter().filter(|l| l.complete_menus) { - let cat = l.catalog(); - let missing: Vec<_> = literals.iter().filter(|s| !KEEP_AS_IS.contains(&s.as_str()) && cat.plain(s).is_none()).collect(); - assert!(missing.is_empty(), "{}: untranslated tl! strings: {missing:#?}", l.code); - } - } - - /// Section names are dynamic labels, so the literal scanner cannot cover them. - #[test] - fn brush_section_names_are_translated() { - for lang in Lang::all().filter(|l| l.complete_menus()) { - for (name, _) in crate::brush_panel::SECTIONS { - assert!(lang.0.catalog().plain(name).is_some(), "{} missing brush section: {name}", lang.code()); - } - } - } - - /// `menus::reveal_label` picks one name per platform, so every name needs a translation - /// whichever OS runs the tests (UI-217-19). - #[test] - fn reveal_labels_are_translated() { - for lang in Lang::all().filter(|l| l.complete_menus()) { - let cat = lang.0.catalog(); - for label in ["Reveal in Finder", "Show in Explorer", "Show in Folder"] { - let tr = cat.plain(label); - assert!(tr.is_some(), "{} missing {label}", lang.code()); - if label != "Reveal in Finder" { - assert!(!tr.unwrap_or_default().contains("Finder"), "{}: {label} names the Finder", lang.code()); - } - } - } - } - - #[test] - fn layer_color_names_are_translated() { - for lang in Lang::all().filter(|l| l.complete_menus()) { - for color in photocraft_doc::LabelColor::ALL { - let cat = lang.0.catalog(); - assert!( - cat.contextual("layerLabel", color.label()).or_else(|| cat.plain(color.label())).is_some(), - "{} missing {}", - lang.code(), - color.label() - ); - } - } - let de = lang_from_tag("de").unwrap(); - assert_eq!(tr_ctx(de, "layerLabel", "No Color"), "Keine Farbe"); - assert_eq!(tr_ctx(de, "layerLabel", "Seafoam"), "Meeresschaum"); - assert_eq!(tr(Lang::EN, "Seafoam"), "Seafoam"); - } - - /// These labels are passed to `tl!` dynamically, so the literal coverage scan misses them. - #[test] - fn russian_dynamic_selection_theme_and_measurement_labels_are_translated() { - let ru = Lang::from_code("ru").expect("ru registered"); - let (inverse_label, _) = - crate::canvas_tool_menu::SELECTION_MENU.iter().flatten().find(|(_, id)| *id == "select.inverse").expect("selection menu has Select Inverse"); - assert_eq!(tr(ru, inverse_label), "Инвертировать выделение"); - for kind in [crate::theme::ThemeKind::ProLight, crate::theme::ThemeKind::ProLightGray] { - assert!(has(ru, kind.label()), "Russian theme label missing {}", kind.label()); - } - for (_, label) in photocraft_engine::analysis_cmds::COLUMNS { - assert!(has(ru, label), "Russian measurement header missing {label}"); - } - assert_eq!(tr(ru, "Pro (Light)"), "Pro (светлая)"); - assert_eq!(tr(ru, "Pro (Light Gray)"), "Pro (светло-серая)"); - assert_eq!(tr(ru, "Date and Time"), "Дата и время"); - assert_eq!(tr(ru, "Integrated Density"), "Интегральная плотность"); - } - - /// Font style labels are built from dynamic words (weights, "Italic"), so the literal - /// scanner cannot cover them; "Light" there is a weight, distinct from the Camera Raw - /// "Light" section (`type_tool::style_label`). - #[test] - fn font_weight_names_are_translated() { - const TERMS: &[&str] = &["Thin", "ExtraLight", "Light", "Regular", "Medium", "SemiBold", "Bold", "ExtraBold", "Black", "Italic"]; - for lang in Lang::all().filter(|l| l.complete_menus()) { - for t in TERMS { - assert!(lang.0.catalog().contextual("fontWeight", t).is_some(), "{} missing font weight: {t}", lang.code()); - } - assert_ne!(tr_ctx(lang, "cameraRaw", "Light"), tr_ctx(lang, "fontWeight", "Light"), "{}: Camera Raw Light vs the font weight", lang.code()); - } - } - - /// Blend mode names come from the colour crate; each must be translated. - #[test] - fn blend_mode_names_are_translated() { - for l in LANGUAGES.iter().filter(|l| l.complete_menus) { - for m in std::iter::once(photocraft_color::BlendMode::PassThrough).chain(photocraft_color::BlendMode::layer_modes()) { - assert!(l.catalog().plain(m.label()).is_some(), "{}: blend mode {:?}", l.code, m.label()); - } - } - } - - /// The Italian catalog names the Dodge/Burn/Sponge *tools* and the blend modes the way Adobe - /// does. `Scherma` is the Dodge tool (with `Brucia` and `Spugna`) **and** the `Screen` blend - /// mode — Adobe's own collision, not ours — while `Schiarisci` is the `Lighten` blend mode and - /// must not be used for the tool. `Screen` used to read `Schermo`, "the display". - #[test] - fn italian_names_the_dodge_tools_and_the_screen_blend_mode_as_photoshop_does() { - let it = LANGUAGES.iter().find(|l| l.code == "it").expect("the Italian catalog is registered"); - for (source, translation) in [("Dodge", "Scherma"), ("Dodge Tool", "Strumento Scherma"), ("Dodge/Burn", "Scherma/Brucia"), ("Screen", "Scherma")] { - assert_eq!(it.catalog().plain(source), Some(translation), "it: {source:?}"); - } - // The tool family stays together, and the blend modes keep Adobe's wording. - for (source, translation) in - [("Burn", "Brucia"), ("Sponge", "Spugna"), ("Lighten", "Schiarisci"), ("Color Dodge", "Colore scherma"), ("Linear Dodge", "Scherma lineare")] - { - assert_eq!(it.catalog().plain(source), Some(translation), "it: {source:?}"); - } - } - - // Dynamic dialog labels generated by label_of() are not seen by the tl! scanner. - #[test] - fn color_lookup_export_scope_labels_are_translated() { - for lang in Lang::all().filter(|l| *l != Lang::EN) { - for source in ["Scope", "Selected"] { - assert!(lang.catalog().plain(source).is_some(), "{}: missing LUT export label {source:?}", lang.code()); - assert_ne!(tr(lang, source), source, "{}: untranslated LUT export label {source:?}", lang.code()); - } - } - } - - #[test] - fn mixer_brush_ui_strings_have_translations_in_every_registered_language() { - const STRINGS: &[&str] = &[ - "Mixer Brush", - "Mixer Brush Tool", - "Wet", - "Load", - "Mix", - "Flow", - "Sample All Layers", - "Current Brush Load", - "Load Brush", - "Clean Brush", - "Load the brush after each stroke", - "Clean the brush after each stroke", - ]; - for lang in Lang::all() { - for source in STRINGS { - let translated = tr(lang, source); - if lang == Lang::EN { - assert_eq!(translated, *source, "English source string {source}"); - } else { - assert_ne!(translated, *source, "{} is missing {source:?}", lang.code()); - } - } - } - } - - #[test] - fn pattern_stamp_ui_strings_have_translations_in_every_registered_language() { - const STRINGS: &[&str] = &["Pattern Stamp Tool", "Pattern Stamp", "Impressionist", "Aligned"]; - for lang in Lang::all() { - for source in STRINGS { - let translated = tr(lang, source); - if lang == Lang::EN { - assert_eq!(translated, *source, "English source string {source}"); - } else { - assert_ne!(translated, *source, "{} is missing {source:?}", lang.code()); - } - } - } - } - - /// Camera Raw includes dynamic colour-band labels and contextual labels that the generic - /// tl! scanner cannot see. Cover the partial catalog too, without claiming whole-app coverage. - #[test] - fn camera_raw_labels_are_translated_in_every_available_language() { - let sources = [include_str!("../camera_raw_ui.rs"), include_str!("../camera_raw_scope_ui.rs")]; - let mut labels = std::collections::BTreeSet::new(); - for source in sources { - let code = source.split("#[cfg(test)]").next().unwrap(); - for marker in ["tl!(\"", "row(ui, &mut dirty, \"", "row(ui, dirty, \"", "section(ui, \"", "wheel(ui, &mut dirty, \"", "=> \""] { - for tail in code.split(marker).skip(1) { - labels.insert(tail.split('"').next().unwrap()); - } - } - } - let bands = sources[0].split("const BANDS:").nth(1).unwrap().split(" = ").nth(1).unwrap().split(';').next().unwrap(); - for band in bands.split('"').skip(1).step_by(2) { - labels.insert(band); - } - assert!(labels.len() >= 66, "missing Camera Raw source labels: {labels:?}"); - for lang in Lang::all().filter(|l| *l != Lang::EN) { - let catalog = lang.catalog(); - let missing: Vec<_> = labels.iter().filter(|s| catalog.contextual("cameraRaw", s).or_else(|| catalog.plain(s)).is_none()).collect(); - assert!(missing.is_empty(), "{}: Camera Raw labels: {missing:?}", lang.code()); - assert_ne!(tr_ctx(lang, "cameraRaw", "Highlights"), tr_ctx(lang, "cameraRaw", "Lights"), "{}: distinct curve regions", lang.code()); - assert_ne!(tr_ctx(lang, "cameraRaw", "Shadows"), tr_ctx(lang, "cameraRaw", "Darks"), "{}: distinct curve regions", lang.code()); - } - let ru = Lang::from_code("ru").unwrap(); - assert_eq!(tr_ctx(ru, "cameraRaw", "Vibrance"), "Красочность"); - assert_eq!(tr_ctx(ru, "cameraRaw", "Aqua"), "Голубые"); - let text = fmt(tr(ru, "Camera Raw Filter ({layer})"), &[("layer", "{Background} 影像")]); - assert_eq!(text, "Фильтр Camera Raw ({Background} 影像)", "user layer names are not translated"); - } } #[cfg(test)] diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 57b15fb8ad..709a425837 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1923,7 +1923,7 @@ mod tests { let items = menu_items(&app); let Some(fr) = crate::i18n::Lang::from_code("fr") else { return }; let ids = |q: &str| search_items(&items, q, fr).iter().map(|i| i.id.clone()).collect::>(); - assert!(!ids("calque").is_empty(), "French menu name"); + assert!(!ids(&crate::i18n::tr(fr, "Layer").to_lowercase()).is_empty(), "French menu name"); assert!(ids("distort").contains(&"edit.transform.distort".to_string()), "English still matches"); assert!(ids("zzzzqqq").is_empty()); } @@ -2252,7 +2252,7 @@ mod quick_export_label_tests { assert_eq!(quick[0].label, format!("Quick Export as {shown_format}")); let id = crate::i18n::Lang::from_code("id").unwrap(); let localized = translated_menu_label(id, quick[0]); - assert_eq!(localized, format!("Ekspor Cepat ke {shown_format}")); + assert_eq!(localized, crate::i18n::tr_id(id, "file.export.quickExportAsPng", "Quick Export as PNG").replace("PNG", shown_format)); let layer = items.iter().find(|i| i.id == "layer.quickExportAsPng").unwrap(); assert_eq!(layer.label, "Quick Export as PNG", "layer export always creates PNG"); } @@ -2290,12 +2290,7 @@ mod reveal_label_tests { let shortcuts = crate::prefs_ui::shortcut_items(&app); assert!(shortcuts.iter().any(|(i, label, _, _)| i == id && label == reveal_label()), "the shortcut list shows the menu label"); let de = crate::i18n::Lang::from_code("de").unwrap(); - let expected = match reveal_label() { - "Reveal in Finder" => "Im Finder anzeigen", - "Show in Explorer" => "Im Explorer anzeigen", - _ => "Im Ordner anzeigen", - }; - assert_eq!(translated_menu_label(de, reveal[0]), expected); + assert_eq!(translated_menu_label(de, reveal[0]), crate::i18n::tr(de, reveal_label())); // The menu catalogue (and docs/parity-checklist.md) keeps Photoshop's macOS name. assert!(crate::menu_catalog::CATALOG.iter().any(|(_, label, _, i)| *i == id && *label == "Reveal in Finder")); } @@ -2371,7 +2366,7 @@ mod window_documents_tests { let bar = crate::native_menu::photocraft_layout(&items, de, "de").bar; assert_eq!(bar.find("window.document.0").map(|i| i.label.as_str()), Some("Layers")); let panel = items.iter().find(|i| i.path == ["Window"] && i.label == "Layers").unwrap(); - assert_eq!(bar.find(&panel.id).map(|i| i.label.as_str()), Some("Ebenen"), "Window › Layers is still translated"); + assert_eq!(bar.find(&panel.id).map(|i| i.label.clone()), Some(translated_menu_label(de, panel)), "Window › Layers is still translated"); } /// The rows go through the control channel's `ui.menu.invoke`, like a click. diff --git a/crates/ui-egui/src/new_doc_ui.rs b/crates/ui-egui/src/new_doc_ui.rs index 4f09d6a028..563a9a5fca 100644 --- a/crates/ui-egui/src/new_doc_ui.rs +++ b/crates/ui-egui/src/new_doc_ui.rs @@ -1029,23 +1029,6 @@ mod tests { out.textures_delta.clear(); } - #[test] - fn new_document_depth_labels_and_tooltips_are_translated() { - // Bit-depth labels are unit/number strings that some languages legitimately write exactly - // like the English source (Turkish and Italian "8 bit"); they only need a catalog entry there. - const SAME_AS_SOURCE: &[(&str, &str)] = &[("tr", "8 bit"), ("tr", "16 bit"), ("it", "8 bit"), ("it", "16 bit")]; - for lang in crate::i18n::Lang::all().filter(|lang| lang.code() != "en") { - for (_, label, tooltip) in DEPTH_OPTIONS { - if SAME_AS_SOURCE.contains(&(lang.code(), *label)) { - assert!(crate::i18n::has(lang, label), "{}: {label}", lang.code()); - } else { - assert_ne!(crate::i18n::tr(lang, label), *label, "{}: {label}", lang.code()); - } - assert_ne!(crate::i18n::tr(lang, tooltip), *tooltip, "{}: {tooltip}", lang.code()); - } - } - } - #[test] fn recent_category_lists_clipboard_then_created_documents_then_uncovered_defaults() { let settings = |w, h, ppi| DocumentSettings { diff --git a/crates/ui-egui/src/notices.rs b/crates/ui-egui/src/notices.rs index 08de9f16ab..79e4dad3db 100644 --- a/crates/ui-egui/src/notices.rs +++ b/crates/ui-egui/src/notices.rs @@ -293,14 +293,9 @@ mod tests { let command = Some("WAYLAND_DISPLAY= photocraft".to_string()); let app = PhotocraftApp::new(Session::new(), Services { is_wayland: true, xwayland_command: command, ..Default::default() }); let notice = &app.ui.notices[0]; - assert!(notice.lines.iter().all(|line| crate::i18n::tr(crate::i18n::Lang::from_code("es").unwrap(), line) != line), "every line is a catalog template"); // the notice keeps its English source text and is translated when drawn (`i18n::t` with - // the current language); `tr` with an explicit language leaves the process-wide language, - // which other tests running in parallel draw with, untouched - let es = crate::i18n::Lang::from_code("es").unwrap(); - assert_eq!(crate::i18n::tr(es, ¬ice.title), "Arrastrar y soltar archivos de forma nativa no está disponible en Wayland"); - assert_ne!(crate::i18n::tr(es, ¬ice.lines[0]), notice.lines[0]); - assert_eq!(crate::i18n::tr(crate::i18n::Lang::EN, ¬ice.title), "Native file drag-and-drop is unavailable"); + // the current language) + assert_eq!(notice.title, "Native file drag-and-drop is unavailable"); } /// #5031: a paste that can't reach the clipboard (a Flatpak on Wayland) says why and how to @@ -325,8 +320,6 @@ mod tests { assert_eq!(shown.len(), 1, "a second paste doesn't stack another notice"); let guidance = english(shown[0]); assert!(guidance.contains(&format!("under XWayland: `{command}`")), "{guidance}"); - let es = crate::i18n::Lang::from_code("es").unwrap(); - assert!(shown[0].lines.iter().chain([&shown[0].title]).all(|line| crate::i18n::tr(es, line) != *line), "every line is a catalog template"); let mut empty = PhotocraftApp::new(Session::new(), services(false)); crate::menus::invoke(&mut empty, &ctx, "edit.paste", serde_json::json!({})).unwrap(); @@ -440,15 +433,16 @@ mod tests { #[test] fn import_warning_punctuation_is_localized_without_changing_english() { let mut app = PhotocraftApp::new(Session::new(), Services::default()); - for (lang, expected) in [ - (crate::i18n::Lang::EN, "Opened x.psd with 3 warnings: first warning …"), - (crate::i18n::Lang::from_code("cs").unwrap(), "Otevřeno: x.psd (3 upozornění): first warning …"), - ] { + for lang in [crate::i18n::Lang::EN, crate::i18n::Lang::from_code("cs").unwrap()] { crate::i18n::with_language(lang, || { let what = crate::i18n::fmt(tl!("Opened {name}"), &[("name", "x.psd")]); let warnings = vec!["first warning".into(), "second warning".into(), "third warning".into()]; io_warnings(&mut app, &what, &warnings); - assert_eq!(app.ui.status, expected); + if lang == crate::i18n::Lang::EN { + assert_eq!(app.ui.status, "Opened x.psd with 3 warnings: first warning …"); + } + let title = crate::i18n::fmt(&crate::i18n::trn(lang, 3, "{what} with {n} warning", "{what} with {n} warnings"), &[("what", &what)]); + assert_eq!(app.ui.status, format!("{title}: first warning …")); assert_eq!(app.ui.notices.last().unwrap().lines, warnings); io_warnings(&mut app, &what, &warnings[..1]); assert_eq!(app.ui.status, format!("{what}: first warning")); diff --git a/crates/ui-egui/src/plugin_ui.rs b/crates/ui-egui/src/plugin_ui.rs index 2b3a7f88c4..b581e6b423 100644 --- a/crates/ui-egui/src/plugin_ui.rs +++ b/crates/ui-egui/src/plugin_ui.rs @@ -103,9 +103,6 @@ mod tests { #[test] fn plugin_menu_path_stays_untranslated_in_other_languages() { let ja = crate::i18n::Lang::from_code("ja").expect("registered language"); - // The names on the path must really differ from the English, or the test proves nothing. - assert_ne!(crate::i18n::tr(ja, "Filter"), "Filter"); - assert_ne!(crate::i18n::tr(ja, "Plug-ins"), "Plug-ins"); let _lang = crate::i18n::language_scope(ja); let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../plugins/tests/fixtures/invert.wasm"); diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 9123e499e2..e7553ce31c 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -3217,34 +3217,6 @@ mod tests { } } - /// Every generated preference label, section title and choice label has an entry in each - /// language that claims complete menus (Japanese, Traditional Chinese, ...). - #[test] - fn preference_labels_are_translated() { - let session = photocraft_engine::Session::new(); - let v: Value = serde_json::from_str(&session.prefs_to_json()).expect("prefs json"); - for lang in crate::i18n::Lang::all().filter(|l| l.complete_menus()) { - let mut missing = Vec::new(); - for (sec, title) in SECTIONS { - if !crate::i18n::has(lang, title) { - missing.push(title.to_string()); - } - let Some(obj) = v.get(sec).and_then(Value::as_object) else { continue }; - for k in obj.keys() { - if sec == "interface" && k == "favoriteThemes" { - continue; // Rendered as star controls beside the theme choices. - } - let mut labels = vec![humanize(k)]; - labels.extend(prefs::choices(&format!("{sec}.{k}")).into_iter().flatten().map(|c| choice_label(c))); - missing.extend(labels.into_iter().filter(|l| !crate::i18n::has(lang, l))); - } - } - missing.sort(); - missing.dedup(); - assert!(missing.is_empty(), "{}: untranslated preference labels: {missing:#?}", lang.code()); - } - } - fn app_with_store() -> (PhotocraftApp, Arc>>) { app_with_saved(None) } diff --git a/crates/ui-egui/src/profile_open_tests.rs b/crates/ui-egui/src/profile_open_tests.rs index 42053729f4..651a93b3aa 100644 --- a/crates/ui-egui/src/profile_open_tests.rs +++ b/crates/ui-egui/src/profile_open_tests.rs @@ -144,32 +144,8 @@ fn files_opened_together_ask_one_at_a_time_before_each_opens() { #[test] fn the_prompt_is_translated_and_still_names_its_file() { let png = adobe_png(); - for (code, sentence, use_embedded, convert) in [ - ( - "en", - "The document “shot {mode}.png” has an embedded color profile that does not match the current RGB working space.", - "Use the embedded profile (Adobe RGB", - "Convert to the working space (sRGB", - ), - ( - "de", - "Das Dokument „shot {mode}.png“ hat ein eingebettetes Farbprofil, das nicht zum aktuellen RGB-Arbeitsfarbraum passt.", - "Eingebettetes Profil verwenden (Adobe RGB", - "In den Arbeitsfarbraum konvertieren (sRGB", - ), - ( - "fr", - "Le document «NBSPshot {mode}.pngNBSP» a un profil colorimétrique incorporé qui ne correspond pas à l'espace de travail RVB actuel.", - "Utiliser le profil incorporé (Adobe RGB", - "Convertir vers l'espace de travail (sRGB", - ), - ( - "ja", - "ドキュメント「shot {mode}.png」の埋め込みカラープロファイルは、現在の RGB 作業用スペースと一致しません。", - "埋め込みプロファイルを使用 (Adobe RGB", - "作業用スペースに変換 (sRGB", - ), - ] { + let template = "The document “{name}” has an embedded color profile that does not match the current {mode} working space."; + for code in ["en", "de", "fr", "ja"] { let services = Services { import: Some(Box::new(|name, bytes, opts| { photocraft_io::import_with_options(name, bytes, opts).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) @@ -183,16 +159,14 @@ fn the_prompt_is_translated_and_still_names_its_file() { assert!(app.session.documents().is_empty(), "{code}: held until answered"); let fields = app.ui.dialogs[0].fields.clone(); let message = fields.get("message").and_then(Value::as_str).unwrap().to_string(); - assert_eq!(message, sentence.replace("NBSP", "\u{a0}"), "{code}: one line, the profiles are on the choices"); + let expected = crate::i18n::fmt(crate::i18n::tr(lang, template), &[("mode", crate::i18n::tr(lang, "RGB")), ("name", "shot {mode}.png")]); + assert_eq!(message, expected, "{code}: one line, the profiles are on the choices"); + if code == "en" { + assert_eq!(message, "The document “shot {mode}.png” has an embedded color profile that does not match the current RGB working space."); + } let name = |k: &str| fields.get(k).and_then(Value::as_str).unwrap().to_string(); - let (use_label, convert_label) = crate::i18n::with_language(lang, || { - ( - crate::i18n::fmt(tl!("Use the embedded profile ({profile})"), &[("profile", &name("embedded"))]), - crate::i18n::fmt(tl!("Convert to the working space ({profile})"), &[("profile", &name("working"))]), - ) - }); - assert!(use_label.starts_with(use_embedded), "{code}: {use_label}"); - assert!(convert_label.starts_with(convert), "{code}: {convert_label}"); + assert!(name("embedded").starts_with("Adobe RGB"), "{code}: {}", name("embedded")); + assert!(name("working").starts_with("sRGB"), "{code}: {}", name("working")); } } diff --git a/crates/ui-egui/src/shortcut_dispatch/tests.rs b/crates/ui-egui/src/shortcut_dispatch/tests.rs index 9bb0e5df24..df66185db1 100644 --- a/crates/ui-egui/src/shortcut_dispatch/tests.rs +++ b/crates/ui-egui/src/shortcut_dispatch/tests.rs @@ -310,9 +310,11 @@ fn a_disabled_shortcut_says_why() { #[test] fn a_disabled_shortcut_uses_the_command_id_translation() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); - crate::i18n::with_language(crate::i18n::Lang::from_code("cs").unwrap(), || { + let cs = crate::i18n::Lang::from_code("cs").unwrap(); + crate::i18n::with_language(cs, || { super::dispatch(&mut app, &egui::Context::default(), "select.all"); - assert!(app.ui.status.starts_with("Vybrat vše není k dispozici:"), "{}", app.ui.status); + let shown = crate::i18n::tr_id(cs, "select.all", &super::label("select.all")).to_string(); + assert!(app.ui.status.contains(&shown), "{}", app.ui.status); assert!(app.ui.status_error); }); } diff --git a/crates/ui-egui/src/stroke_ui_tests.rs b/crates/ui-egui/src/stroke_ui_tests.rs index b79306c3a3..1ef75e4d58 100644 --- a/crates/ui-egui/src/stroke_ui_tests.rs +++ b/crates/ui-egui/src/stroke_ui_tests.rs @@ -139,17 +139,6 @@ fn command_params_do_not_leak_private_dialog_fields() { assert_eq!(out["color"], "#ff0000"); } -#[test] -fn stroke_dialog_labels_are_translated_in_every_catalog() { - const LABELS: &[&str] = - &["Stroke", "Width:", "Color:", "Location", "Location:", "Inside", "Center", "Outside", "Blending", "Mode:", "Opacity:", "Preserve Transparency"]; - for lang in crate::i18n::Lang::all().filter(|lang| lang.complete_menus()) { - for label in LABELS { - assert!(crate::i18n::has(lang, label), "{} is missing Stroke dialog label: {label}", lang.code()); - } - } -} - /// The Stroke dialog's colour swatch opens PhotoCraft's Color Picker on the stroke colour (as in /// Photoshop), not egui's compact popup; OK sets the stroke colour, not the foreground, and the /// stroke is drawn in it. diff --git a/crates/ui-egui/src/symmetry_ui/tests.rs b/crates/ui-egui/src/symmetry_ui/tests.rs index 9f66449572..85fd45b0a3 100644 --- a/crates/ui-egui/src/symmetry_ui/tests.rs +++ b/crates/ui-egui/src/symmetry_ui/tests.rs @@ -184,9 +184,6 @@ fn overlays_follow_document_view_transforms_in_every_theme() { } out.textures_delta.clear(); } - let de = crate::i18n::Lang::from_code("de").unwrap(); - assert_eq!(crate::i18n::tr(de, "Dual Axis"), "Duale Achse"); - assert_eq!(crate::i18n::tr(de, "Transform Symmetry"), "Symmetrie transformieren"); } /// #4495: while the symmetry is transformed the options bar has ✓ and ⊘ (Enter and Esc were the /// only ways out), and the canvas shows the move, rotate or arrow cursor instead of the brush. diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index e25baa6382..e6e3b66c83 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -65,7 +65,7 @@ fn numeric_type_fields_have_localized_accessible_names() { ); PhotocraftApp::setup_context(&h.ctx, Default::default()); h.run_steps(4); - h.get_by_role_and_label(egui::accesskit::Role::SpinButton, "Tamanho da fonte").click(); + h.get_by_role_and_label(egui::accesskit::Role::SpinButton, crate::i18n::tr(pt, "Font size")).click(); h.run(); h.key_press_modifiers(Modifiers::COMMAND, egui::Key::A); h.event(egui::Event::Text("36".into())); diff --git a/crates/ui-egui/tests/it/camera_raw_histogram.rs b/crates/ui-egui/tests/it/camera_raw_histogram.rs index 144522908d..7b1c9502d0 100644 --- a/crates/ui-egui/tests/it/camera_raw_histogram.rs +++ b/crates/ui-egui/tests/it/camera_raw_histogram.rs @@ -80,7 +80,6 @@ fn histogram_matches_visible_preview_at_all_supported_depths_and_cancel_commit_a #[test] fn settings_sections_group_existing_controls_without_rendering_or_document_edits() { - assert_eq!(photocraft_ui_egui::i18n::tr_ctx(photocraft_ui_egui::i18n::Lang::from_code("ru").unwrap(), "cameraRaw", "Light"), "Свет"); for theme in ThemeKind::ALL { let mut h = Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 900.0)).build_eframe(move |cc| { PhotocraftApp::setup_context(&cc.egui_ctx, theme); diff --git a/docs/development.md b/docs/development.md index dc153c4e5a..4e7db51496 100644 --- a/docs/development.md +++ b/docs/development.md @@ -331,7 +331,11 @@ the live command registry (`menus::is_live`) and rewrites [`docs/parity-checklis `crates/ui-egui/src/i18n/mod.rs` registry, in stable language-code order. Its English-key set is derived from UI `tl!` literals, the menu catalog and UI command table, and menu command labels in the engine source. Catalog rows are validated for malformed and duplicate keys; unused legacy -translations do not affect the denominator. Unregistered locale TSV files are ignored. +translations do not affect the denominator. Unregistered locale TSV files are ignored. After the +counts it lists, per language, the source keys the catalog lacks (one line for a language with +none). A missing translation shows in English and fails nothing: `cargo xtask ci` runs the report +as its last step so the list appears in the log, and only prints a warning if the report itself +can't run. ## Testing strategy diff --git a/docs/localization-es.md b/docs/localization-es.md index de47b78716..4212dcac71 100644 --- a/docs/localization-es.md +++ b/docs/localization-es.md @@ -26,8 +26,8 @@ Filter Gallery names, Camera Raw, Lighting Effects, History and Layers controls, dialogs, status wrappers and System Info labels. Before this pass, those controls fell back to English because many are assembled dynamically and cannot be found by the static coverage scan. -`spanish_covers_generated_filter_options_and_gallery_names` in `filter_dialog.rs` enumerates the -generated filter and adjustment surface and fails if a future label has no Spanish row. A few +A future generated label without a Spanish row shows in English. `cargo xtask i18n-coverage` +lists missing rows for the keys it scans, which don't include these generated labels. A few unused legacy labels are retained so the complete catalogs stay aligned. ## Terminology diff --git a/docs/localization-it.md b/docs/localization-it.md index f983c499e4..bb82fee489 100644 --- a/docs/localization-it.md +++ b/docs/localization-it.md @@ -52,9 +52,8 @@ command names joined by a slash, which keep both capitals (*Luminosità/Contrast 2026-10-13: `Screen` was *Schermo* — the display, not the blend mode — and is now *Scherma*, the word Adobe uses. The Dodge/Burn/Sponge tools were left as they were (*Scherma*, *Brucia*, *Spugna*): a first pass also wanted to rename the Dodge tool to *Schiarisci*, but that is the `Lighten` blend -mode and would have given two different things the same name. The wording is pinned by a test -(`i18n::tests::italian_names_the_dodge_tools_and_the_screen_blend_mode_as_photoshop_does`), which -also keeps the tool family together. +mode and would have given two different things the same name. Keep the tool family's names +together when editing these rows. 2026-10-12: a native Italian speaker read the catalog and fixed mistranslations (Trap, HDR Toning, Display Text, Vine Color, Override Action "Save" Commands) and unnatural phrasing (Bevel & @@ -69,10 +68,8 @@ current code; they are translated to stay aligned with the other catalogs. ## Validation -- `italian_covers_generated_filter_options_and_gallery_names` (`crates/ui-egui/src/filter_dialog.rs`) - enumerates every filter and adjustment parameter, symbolic choice and gallery name, like the - Korean test, and requires an Italian row for each. With the catalog on `main` before this change - it lists 388 missing labels; now none. +- Every filter and adjustment parameter, symbolic choice and gallery name has an Italian row. The + catalog on `main` before this change lacked 388 of them. - `cargo test -p photocraft-ui-egui --lib`: the catalog tests pass. Two layout tests, `type_controls_fill_the_panel_at_every_width_and_scale` and `properties_edit_shown_metrics_and_paragraphs_with_a_non_type_primary`, fail on `main` as well diff --git a/docs/localization-ko.md b/docs/localization-ko.md index 0cf8fc97b0..3b21840305 100644 --- a/docs/localization-ko.md +++ b/docs/localization-ko.md @@ -23,14 +23,11 @@ resources, fonts, screenshots or user documents are included. Command-ID translations disambiguate menu names from actions. Photoshop terms are used when the operation matches; PhotoCraft-specific features retain their own meaning. Technology names, units and user-provided names are not translated. -The terminology test protects representative tool names and this distinction. ## Dynamic coverage Generated filter parameter names, symbolic choices, gallery categories/effects, job titles, layer-style menus and shortcut labels now go through the catalog. -The filter test enumerates engine filter/adjustment parameter specs and gallery -names to catch new Korean omissions that literal-only scans cannot find. Two newly introduced literals, `Enable {name}` and `No patterns`, are present in all nine non-English catalogs. Existing fallback behavior is preserved for untranslated dynamic labels in other languages. diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 9f5d6cd066..2ea5a9a0b6 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -13,7 +13,9 @@ labels and path segments, `menus.rs` UI commands, and engine `CommandSpec` label checked against that universe by a source script replicating the xtask (no build). Catalogs hold more rows than the universe because they also translate dynamic `tl!(expr)` strings (~159 call sites the tool can't enumerate). **Not covered anywhere:** engine error and status messages and -filter parameter help, which are English in every language; so no language is `full`. +filter parameter help, which are English in every language; so no language is `full`. The report +also lists, per language, the keys its catalog lacks; those show in English. `cargo xtask ci` prints +it as its last step and never fails on it. **Photoshop 2026** ships UI language packs for cs, da, de, en, es, fi, fr (and fr-CA), hu, it, ja, ko, nb, nl, pl, pt-BR, ru, sk, sv, tr, uk, zh-Hans, zh-Hant (24 `.lproj` markers; one pack is diff --git a/docs/localization-ru.md b/docs/localization-ru.md index ea0eff64df..f3a68f8540 100644 --- a/docs/localization-ru.md +++ b/docs/localization-ru.md @@ -63,12 +63,8 @@ translated to stay aligned with the other catalogs. ## Validation -- `russian_covers_generated_filter_options_and_gallery_names` (`crates/ui-egui/src/filter_dialog.rs`) - enumerates every filter and adjustment parameter, symbolic choice and gallery name and requires - a Russian row for each, like the Korean and Italian tests. -- `russian_dynamic_status_hardware_and_dialog_labels_are_translated` (`crates/ui-egui/src/i18n/mod.rs`) - covers the rasterize prompts, hardware status lines, composed status templates and a sample of - filter labels. +- Every filter and adjustment parameter, symbolic choice and gallery name has a Russian row, as do + the rasterize prompts, hardware status lines and composed status templates. - `cargo test -p photocraft-ui-egui --lib`: 2,998 passed, 0 failed. - `cargo xtask i18n-coverage`: `ru` remains at 100% of the measured keys. - The offscreen `snapshot` example with `PHOTOCRAFT_LOCALE=ru` renders the dialogs with Russian diff --git a/docs/localization-zh-hans.md b/docs/localization-zh-hans.md index d92829f36e..9a84589589 100644 --- a/docs/localization-zh-hans.md +++ b/docs/localization-zh-hans.md @@ -28,8 +28,8 @@ language manually there. - `crates/ui-egui/src/i18n/zh-hans.tsv` contains the translations, independent of the lookup implementation. Its UTF-8 columns are `contextsourcetranslation`. - `crates/ui-egui/src/i18n/mod.rs` registers `zh-hans` with one plural form and - `complete_menus: true`. Shared tests enforce coverage of every menu string, - `tl!` literal, blend mode, brush section and generated preference label. + `complete_menus: true`. `cargo xtask i18n-coverage` lists any menu string, + `tl!` literal or other source key the catalog lacks; those show in English. - Shortcut templates retain `{key}` so the shell supplies the correct key for the current platform. Layer-count messages use one Chinese plural form. @@ -62,9 +62,8 @@ complete catalog coverage does not claim those strings are translated. Run `cargo test -p photocraft-ui-egui`, the touched-crate all-target clippy check, `cargo xtask layers` and `cargo xtask wasm`. The shared catalog tests validate -duplicate keys, placeholders, ellipses, menu coverage, `tl!` literals, brush -sections and blend modes. Chinese-specific tests cover locale selection, fallback, -plural messages, formatted labels and the painted brush section rows and heading. Render and inspect the menus, Preferences and representative +duplicate keys, placeholders, ellipses and plural forms. Chinese-specific tests +cover locale selection, fallback and the painted brush section rows. Render and inspect the menus, Preferences and representative dialogs with the existing offscreen `snapshot` example. Font delivery is separate from the translation data. The catalog adds no font @@ -73,7 +72,6 @@ label layout and platform-specific shortcuts visually on each target platform, and verify web glyph coverage separately. When English labels change, update their source keys and translations together, -preserving contexts and placeholders. The complete-catalog tests catch missing -keys, and the Simplified Chinese regression test checks dynamic shortcut labels, -layer counts and English fallback. Review changed source meanings against the -current English UI. +preserving contexts and placeholders. `cargo xtask i18n-coverage` (also the last +step of `cargo xtask ci`) lists the keys still missing. Review changed source +meanings against the current English UI. diff --git a/docs/localization.md b/docs/localization.md index 5a1355057c..30fe04725a 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -31,7 +31,9 @@ contextual, command-ID and plural translations; missing entries fall back to Eng | हिन्दी | `hi` | one / other | Languages registered with `complete_menus` cover the current menu labels, `tl!` literals, blend -mode names, and generated preference labels; tests enforce that coverage. Vietnamese (`vi`) is +mode names, and generated preference labels. A string a catalog lacks shows in English: +`cargo xtask i18n-coverage` lists the missing strings per language, and `cargo xtask ci` prints +that list as its last step without failing. Vietnamese (`vi`) is selectable, resolves regional tags such as `vi-VN`, and covers all current stable localization keys. `complete_menus` remains false while generated/dynamic long-tail labels are still under review. This does not include every engine error or status message: those remain English, as do @@ -134,7 +136,7 @@ detection remain separate outstanding work. Switching catalogs does not supply m 3. Translate the meaning of the English source keys. Preserve `{name}` placeholders, escaped characters and the trailing `…` on dialog commands. Use `@id` to disambiguate a command label and `@plural` for count-sensitive text. Keep product names such as PhotoCraft. -4. Set `complete_menus` when the catalog covers all enforced keys. Add source and license +4. Set `complete_menus` once `cargo xtask i18n-coverage` lists nothing missing for the language. Add source and license attribution for translation assets, then run the catalog and shell tests. ```sh diff --git a/docs/ui-design.md b/docs/ui-design.md index 8ac01f8e01..24a30a16ed 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -198,7 +198,7 @@ documented in the header of `ja.tsv`). Command ids, menu paths used for logic, t the CLI and MCP always use the English ids and labels. Languages shipped (`complete_menus` marks a catalog that covers every menu string and `tl!` literal; -the tests enforce it): +`cargo xtask i18n-coverage` lists what each catalog lacks): - English (`en`), the source language. - Japanese (`ja`), complete. @@ -225,15 +225,16 @@ the tests enforce it): - To add a language: add `.tsv` and one row in `i18n::LANGUAGES` (code, native name, catalog, plural rule). The dropdown, locale matching and the catalog tests (well-formed, no duplicates, placeholders and ellipses agree, command ids exist) pick it up. Set `complete_menus` once every - menu string is translated; a test then enforces it. A language with more plural forms lists them + menu string is translated. A language with more plural forms lists them all in `@plural` entries (Russian: `one|few|many`); the row's plural rule picks the form. - Translations are clean-room: written from the meaning of the English text in ordinary vocabulary, never from another product's localisation resources. Localised so far: menus, the command palette, dialogs and panels (literals wrapped in `tl!("…")`; widgets such as `checkbox`, `slider_row`, `dropdown` and the buttons translate their labels -themselves). A test fails when a `tl!` literal, a menu string, a blend mode name, brush section name or a generated -preference label has no entry in a language marked `complete_menus`. Not translated: status-bar +themselves). A string with no entry in a catalog shows in English; `cargo xtask i18n-coverage` +lists the missing strings per language, and `cargo xtask ci` prints that list without failing. +Not translated: status-bar messages and errors (they stay English, also for agents), names that are user data (layers, styles, documents), strings assembled with `format!` that were not converted to `fmt`/`trn`. Not done yet: right-to-left layout inside panels and dialogs (the shell mirrors, `direction.rs`), @@ -245,7 +246,7 @@ Simplified Chinese. Its contextual `cameraRaw` entries distinguish tonal regions colour-band names and font-weight Light. The title uses a `{layer}` placeholder; user layer names and the universal RGB/Lab channel symbols stay unchanged. Collapse IDs use English source keys, so switching languages preserves open sections. Mixer tabs wrap within the -panel width. Catalog coverage and actual shell language-switch tests enforce these rules. +panel width. Shell language-switch tests check that the panel draws in the selected language. Native CJK font fallback follows the selected UI script and resets its cache when switching languages; font delivery on the web remains separate work. From a691aaf65bb8afbdb550be314a1dcbe805258d1b Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 15:08:50 -0400 Subject: [PATCH 49/53] =?UTF-8?q?codecs:=20use=20u32=20in=20the=20padded-t?= =?UTF-8?q?runcated-JPEG=20test's=20bounds=20arithmetic=20(u64:=20From=20does=20not=20implement=20=E2=80=94=20E0277)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/codecs/src/codecs/jpeg.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/codecs/src/codecs/jpeg.rs b/crates/codecs/src/codecs/jpeg.rs index 393f8e9512..14d8f8ffec 100644 --- a/crates/codecs/src/codecs/jpeg.rs +++ b/crates/codecs/src/codecs/jpeg.rs @@ -491,7 +491,7 @@ mod tests { assert_eq!(data_end(&padded).0, DataEnd::Truncated); // Well above the frame's pixels / budget, yet far above the scan data: the old // file-length bound admitted this frame. - assert!(padded.len() > (u64::from(20_000) * u64::from(20_000) / MAX_TRUNCATED_PIXELS_PER_BYTE) as usize, "padding too small"); + assert!(padded.len() > (u64::from(20_000u32) * u64::from(20_000u32) / MAX_TRUNCATED_PIXELS_PER_BYTE) as usize, "padding too small"); assert!(crate::decode_as(Format::Jpeg, &padded).is_ok()); let sof = padded.windows(2).position(|w| w == [0xFF, 0xC0] || w == [0xFF, 0xC2]).unwrap(); let mut huge = padded.clone(); From a09c0ce85fe5ddcb934e4a6b634c6d53c33bccbf Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 15:21:47 -0400 Subject: [PATCH 50/53] test(gpu): match the planning error instead of err().expect() --- crates/gpu/src/plan.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/gpu/src/plan.rs b/crates/gpu/src/plan.rs index 333ba2280a..69e4b64852 100644 --- a/crates/gpu/src/plan.rs +++ b/crates/gpu/src/plan.rs @@ -1899,7 +1899,7 @@ mod tests { let mut doc = Document::new("nest", Size::new(8, 8), ColorMode::Rgb, SampleType::U8); doc.layers = layers; let started = std::time::Instant::now(); - let error = plan(&doc).err().expect("2^30 plannings must be refused"); + let Err(error) = plan(&doc) else { panic!("2^30 plannings must be refused") }; assert!(error.0.contains("too many nested layers"), "{}", error.0); assert!(started.elapsed().as_secs() < 10, "{:?}", started.elapsed()); } From ecd1a692a366b0eb1e6cd797a1f0dbc28cd530e3 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 15:22:24 -0400 Subject: [PATCH 51/53] security(algo): bound working sets sized by caller-controlled extents (#8946) * security(algo): bound working sets sized by caller-controlled extents Zero-width inpaint grids, content-aware fill mosaics of turned thin images, Liquify fields over huge canvases, tiny-scale reduction weights, warp tile bookkeeping and per-tile source footprints, and windowed filters whose halo follows an unclamped radius or a stacked Filter Gallery reach all asked for memory sized by file or parameter input. Each is now bounded; halo arithmetic saturates instead of overflowing. * security(algo): budget variants by dimensions, size-check footprints first, widen the tile-halo maths CodeRabbit follow-up: - fill_cow settles the mosaic budget on variant dimensions only, so a variant's image and mask buffers are not built when the mosaic would exceed MAX_MOSAIC_PIXELS (complete falls back to the image itself). - warp validates the footprint's pixel area against MAX_FOOTPRINT_PIXELS before src.has_tiles_in, so an oversized footprint returns without the tile-grid scan. - auto_tile widens the input-derived halo arithmetic to i64 and clamps before the cast, so an i32::MAX radius cannot overflow in debug builds and the 2048 tile cap stands. * fix(algo): drop the duplicate mosaic_size call left behind in fill_cow The variant-budget rework kept both the new dimensions-based budget and the old variants-based one; the old call no longer compiled after mosaic_size started taking dimensions. Keep the comment that explains the fallback. --- crates/algo/src/content_aware.rs | 77 +++++++++++++++++++++++++------- crates/algo/src/inpaint.rs | 12 +++++ crates/algo/src/lib.rs | 33 +++++++++----- crates/algo/src/liquify.rs | 24 ++++++++-- crates/algo/src/resample.rs | 73 +++++++++++++++++++++++++----- crates/algo/src/tests.rs | 9 ++++ crates/algo/src/warp.rs | 29 +++++++++++- 7 files changed, 213 insertions(+), 44 deletions(-) diff --git a/crates/algo/src/content_aware.rs b/crates/algo/src/content_aware.rs index 981e854cc5..84b15a215b 100644 --- a/crates/algo/src/content_aware.rs +++ b/crates/algo/src/content_aware.rs @@ -114,6 +114,21 @@ struct Variant { ok: Vec, } +/// Most pixels the mosaic of the image and its variants may span. It is one dense rectangle, so +/// thin images turned to different angles leave it mostly empty: the cost grows with the square +/// of their length, not with their pixels. +const MAX_MOSAIC_PIXELS: usize = 1 << 25; + +/// Size of the mosaic of an `w`×`h` image and `extra` variant sizes separated by `gap`, or `None` +/// when it spans more than [`MAX_MOSAIC_PIXELS`] (or overflows). +fn mosaic_size(w: usize, h: usize, gap: usize, extra: &[(usize, usize)]) -> Option<(usize, usize)> { + let col_w = extra.iter().map(|v| v.0).max().unwrap_or(0); + let col_h = extra.iter().try_fold(0usize, |n, v| n.checked_add(v.1)?.checked_add(gap))?; + let mw = w.checked_add(gap)?.checked_add(col_w)?; + let mh = h.max(col_h); + (mw.checked_mul(mh)? <= MAX_MOSAIC_PIXELS).then_some((mw, mh)) +} + /// A source buffer and which of its pixels may be sampled. #[derive(Clone, Copy)] struct Src<'a> { @@ -149,13 +164,20 @@ fn sample(src: Src, x: f32, y: f32, out: &mut [f32]) -> bool { /// Transformed copy of the source: rotation (degrees) and scale about the centre, optional mirror. /// `Err` when cancelled. -fn variant(src: Src, deg: f32, scale: f32, mirror: bool, ctl: &Interrupt) -> Result { - let Src { w, h, ch, .. } = src; +/// Dimensions of the [`variant`] copy: the same bounding-box maths without building its pixels. +fn variant_dims(w: usize, h: usize, deg: f32, scale: f32) -> (usize, usize) { let (s, c) = (-deg.to_radians()).sin_cos(); - // Output size: the rotated, scaled bounding box. let (fw, fh) = (w as f32 * scale, h as f32 * scale); let ow = ((fw * c.abs() + fh * s.abs()).round() as usize).max(1); let oh = ((fw * s.abs() + fh * c.abs()).round() as usize).max(1); + (ow, oh) +} + +fn variant(src: Src, deg: f32, scale: f32, mirror: bool, ctl: &Interrupt) -> Result { + let Src { w, h, ch, .. } = src; + // Output size: the rotated, scaled bounding box. + let (ow, oh) = variant_dims(w, h, deg, scale); + let (s, c) = (-deg.to_radians()).sin_cos(); let (icx, icy) = ((w as f32 - 1.0) / 2.0, (h as f32 - 1.0) / 2.0); let (ocx, ocy) = ((ow as f32 - 1.0) / 2.0, (oh as f32 - 1.0) / 2.0); let mut out = Variant { w: ow, h: oh, img: vec![0.0; ow * oh * ch], ok: vec![false; ow * oh] }; @@ -243,17 +265,27 @@ fn fill_cow( let gap = 2 * params.patch_radius + 2; let ok: Vec = par::map(w * h, true, |i| !hole[i] && source[i]); let src = Src { w, h, ch, img: &img, ok: &ok }; - // Variants: the identity (which holds the hole) first, then the adaptations. - let mut extra: Vec = Vec::new(); + // Variants: the identity (which holds the hole) first, then the adaptations. Their pixels are + // expensive, so the mosaic budget is settled on their dimensions before any variant is built; + // when the variants would not fit, the image itself is completed without them. + let mut specs: Vec<(f32, f32, bool)> = Vec::new(); if opts.mirror { - extra.push(variant(src, 0.0, 1.0, true, ctl)?); + specs.push((0.0, 1.0, true)); } for ° in &opts.rotations { - extra.push(variant(src, deg, 1.0, false, ctl)?); + specs.push((deg, 1.0, false)); } if opts.scale { - extra.push(variant(src, 0.0, 0.8, false, ctl)?); - extra.push(variant(src, 0.0, 1.25, false, ctl)?); + specs.push((0.0, 0.8, false)); + specs.push((0.0, 1.25, false)); + } + let dims: Vec<(usize, usize)> = specs.iter().map(|&(deg, scale, _)| variant_dims(w, h, deg, scale)).collect(); + let size = if specs.is_empty() { None } else { mosaic_size(w, h, gap, &dims) }; + let mut extra: Vec = Vec::new(); + if size.is_some() { + for &(deg, scale, mirror) in &specs { + extra.push(variant(src, deg, scale, mirror, ctl)?); + } } let role = |s: usize| { if hole[s] { @@ -265,14 +297,9 @@ fn fill_cow( } }; // Mosaic: identity at the left, variants in a column to its right, gaps that are no image. - // Without variants it is the image itself. - let (mw, mh, mosaic, roles): (usize, usize, Option>, Vec) = if extra.is_empty() { - (w, h, None, par::map(w * h, true, role)) - } else { - let col_w = extra.iter().map(|v| v.w).max().unwrap_or(0); - let col_h: usize = extra.iter().map(|v| v.h + gap).sum(); - let mw = w + gap + col_w; - let mh = h.max(col_h); + // Without variants it is the image itself; so it is when the variants would not fit + // (`size` above is then None and `extra` is empty). + let (mw, mh, mosaic, roles): (usize, usize, Option>, Vec) = if let Some((mw, mh)) = size { let mut mimg = vec![0.0f32; mw * mh * ch]; let mut roles = vec![Px::Void; mw * mh]; for y in 0..h { @@ -297,6 +324,8 @@ fn fill_cow( oy += v.h + gap; } (mw, mh, Some(mimg), roles) + } else { + (w, h, None, par::map(w * h, true, role)) }; drop((extra, ok)); ctl.check()?; @@ -695,6 +724,20 @@ pub fn default_brush_size(hole: Rect) -> f64 { #[cfg(test)] mod tests { + use super::mosaic_size; + + fn blank(w: usize, h: usize) -> (usize, usize) { + (w, h) + } + + #[test] + fn a_thin_image_with_turned_variants_does_not_get_a_quadratic_mosaic() { + // 40000×1 plus its 90° copy (1×40000): about 1.6 billion mosaic cells for 80000 pixels. + assert_eq!(mosaic_size(40_000, 1, 8, &[blank(1, 40_000)]), None); + assert_eq!(mosaic_size(usize::MAX, 1, 8, &[blank(1, 1)]), None); + assert_eq!(mosaic_size(100, 100, 8, &[blank(100, 100)]), Some((208, 108))); + } + use super::*; /// Vertical stripes (period 6) in grey, with a hole in the middle. diff --git a/crates/algo/src/inpaint.rs b/crates/algo/src/inpaint.rs index 2dd40198d8..87ed2a3576 100644 --- a/crates/algo/src/inpaint.rs +++ b/crates/algo/src/inpaint.rs @@ -319,6 +319,10 @@ fn peel_affordable(w: usize, px: &[Px], hb: Bounds, sources: usize) -> bool { /// Bounds of the hole pixels of a role grid. fn hole_bounds(w: usize, h: usize, px: &[Px]) -> Option { + // A zero-width grid holds no pixels whatever its height; don't size a row table by it. + if w == 0 || h == 0 { + return None; + } // Per row the first and last hole column; the union of the rows' spans. let rows: Vec> = par::map(h, w * h >= PAR_MIN, |y| { let row = px.get(y * w..(y + 1) * w)?; @@ -2047,6 +2051,14 @@ mod tests { assert!((a.gain.0..=a.gain.1).contains(&g) && b.abs() <= a.bias + 1e-6); } + /// A zero-width image has no pixels however tall it is claimed to be: no per-row table. + #[test] + fn zero_width_images_complete_without_sizing_by_height() { + let p = CompleteParams::default(); + assert_eq!(complete(0, 1 << 40, 3, &[], &[], &p), Some(Vec::new())); + assert_eq!(complete(1 << 40, 0, 3, &[], &[], &p), Some(Vec::new())); + } + /// Smooth gradient plus deterministic fine noise. fn texture(w: usize, h: usize) -> Vec { let mut rng = Rng::new(7); diff --git a/crates/algo/src/lib.rs b/crates/algo/src/lib.rs index 35c195f399..3336a7690b 100644 --- a/crates/algo/src/lib.rs +++ b/crates/algo/src/lib.rs @@ -560,7 +560,7 @@ pub enum FilterParams { impl FilterParams { /// Pixels read outside an output tile. pub fn halo(&self) -> Halo { - let g = |sigma: f32| Halo::Radius((sigma.max(0.0) * 3.0).ceil() as i32 + 1); + let g = |sigma: f32| Halo::Radius(((sigma.max(0.0) * 3.0).ceil() as i32).saturating_add(1)); match self { FilterParams::GaussianBlur { radius } | FilterParams::HighPass { radius } => g(*radius), FilterParams::UnsharpMask { radius, .. } => Halo::Radius(sharpen::exact::halo(*radius)), @@ -570,9 +570,9 @@ impl FilterParams { | FilterParams::Median { radius } | FilterParams::DustAndScratches { radius, .. } | FilterParams::Minimum { radius, .. } - | FilterParams::Maximum { radius, .. } => Halo::Radius(radius.max(0.0).ceil() as i32 + 1), + | FilterParams::Maximum { radius, .. } => Halo::Radius((radius.max(0.0).ceil() as i32).saturating_add(1)), FilterParams::MotionBlur { distance, .. } => Halo::Radius((distance.abs() / 2.0).ceil() as i32 + 2), - FilterParams::Mosaic { cell_size } => Halo::Radius(cell_size.max(1.0).ceil() as i32 + 1), + FilterParams::Mosaic { cell_size } => Halo::Radius((cell_size.max(1.0).ceil() as i32).saturating_add(1)), FilterParams::Emboss { height, .. } => Halo::Radius(height.abs().ceil() as i32 + 2), FilterParams::FindEdges => Halo::Radius(1), FilterParams::AddNoise { .. } | FilterParams::Solarize | FilterParams::Invert | FilterParams::Desaturate => Halo::Radius(0), @@ -697,7 +697,7 @@ impl FilterParams { /// Halos of the second filter batch. fn halo_ext(p: &FilterParams) -> Halo { - let r = |v: f32| Halo::Radius(v.max(0.0).ceil() as i32 + 1); + let r = |v: f32| Halo::Radius((v.max(0.0).ceil() as i32).saturating_add(1)); match p { FilterParams::ColorHalftone { max_radius, .. } => r(max_radius * 3.0), FilterParams::Crystallize { cell_size, .. } => r(cell_size.max(1.0) * 3.0), @@ -718,8 +718,9 @@ fn halo_ext(p: &FilterParams) -> Halo { FilterParams::Relight { intensity, .. } if *intensity == 0.0 => Halo::Radius(0), FilterParams::Relight { .. } => Halo::Radius(relight::halo_radius_max()), FilterParams::ReduceNoise { .. } => r(denoise::reach()), - FilterParams::SmartBlur { radius, .. } => r(*radius), - FilterParams::LensBlur { radius, .. } => r(*radius), + // The kernels clamp the radius to 100 px: size the source window by what they use. + FilterParams::SmartBlur { radius, .. } => r(radius.min(100.0)), + FilterParams::LensBlur { radius, .. } => r(radius.min(100.0)), FilterParams::ShapeBlur { radius, .. } => r(shape_blur::radius(*radius)), FilterParams::TiltShift { blur, .. } => r(gallery::reach(*blur)), FilterParams::IrisBlur { pins } => r(gallery::reach(pins.iter().map(|p| p.blur).fold(0.0, f32::max))), @@ -998,10 +999,18 @@ pub fn apply_in_with( } /// Results do not depend on the tiling, so wide-halo filters use bigger tiles to keep the -/// re-read margin (and its cost) below ~2× the tile area. +/// re-read margin (and its cost) below ~2× the tile area. The radius is input-derived, so the +/// halo arithmetic is done in `i64` and the result capped: an `i32::MAX` radius must not +/// overflow in debug builds, and must not produce a tile bigger than the hard cap. fn auto_tile(params: &FilterParams, bounds: Rect) -> i32 { match params.halo_for(bounds) { - Halo::Radius(r) => TILE.max((2 * r + 63) / 64 * 64).min(2048), + // The radius is input-derived: widen to i64 before the arithmetic and cap the + // conversion, so an i32::MAX radius cannot overflow (debug panic) and cannot make a + // tile bigger than the hard cap below (131_072 = 2048 * 64, one tile row). + Halo::Radius(r) => { + let halo = ((2 * i64::from(r) + 63) / 64 * 64).min(131_072); + TILE.max(halo as i32).min(2048) + } Halo::Bounds => TILE, } } @@ -1095,14 +1104,16 @@ fn apply_tiled_with_impl( return None; } let halo = params.halo_for(bounds); - if matches!(params, FilterParams::UnsharpMask { .. }) { - let Halo::Radius(reach) = halo else { return None }; + // Every windowed filter: a Filter Gallery stack sums its effects' reach, and a radius is + // caller-supplied, so a short parameter set can ask for a huge source window per tile. + if let Halo::Radius(reach) = halo { let (w, h) = (area.width().min(tile as u32) as usize, area.height().min(tile as u32) as usize); let read_samples = w.checked_add(2 * reach as usize)?.checked_mul(h.checked_add(2 * reach as usize)?)?.checked_mul(fmt.channels())?; let tile_count = u64::from(area.width()).div_ceil(tile as u64).checked_mul(u64::from(area.height()).div_ceil(tile as u64))?; // Reject hostile source-window sizes before Image::read allocates them. // Ordinary filter parameters and automatic tiles fit these bounds. - if read_samples > 512 * 1024 * 1024 || read_samples.checked_mul(std::mem::size_of::())? > isize::MAX as usize || tile_count > 1_048_576 { + let too_many_tiles = matches!(params, FilterParams::UnsharpMask { .. }) && tile_count > 1_048_576; + if read_samples > 512 * 1024 * 1024 || read_samples.checked_mul(std::mem::size_of::())? > isize::MAX as usize || too_many_tiles { return None; } } diff --git a/crates/algo/src/liquify.rs b/crates/algo/src/liquify.rs index ee678d1aa9..9ebb3115c9 100644 --- a/crates/algo/src/liquify.rs +++ b/crates/algo/src/liquify.rs @@ -209,11 +209,20 @@ pub fn auto_cell(bounds: Rect) -> f64 { if bounds.size().area() <= 4_200_000 { 2.0 } else { 4.0 } } +/// Most nodes of a field's displacement grid (12 bytes each with the freeze mask): 192 MB. +const MAX_FIELD_NODES: usize = 1 << 24; + impl LiquifyField { pub fn new(bounds: Rect, cell: f64) -> Self { - let cell = cell.clamp(1.0, 64.0); - let w = (f64::from(bounds.width()) / cell).ceil() as usize + 2; - let h = (f64::from(bounds.height()) / cell).ceil() as usize + 2; + let mut cell = cell.clamp(1.0, 64.0); + let nodes = |cell: f64| ((f64::from(bounds.width()) / cell).ceil() as usize + 2, (f64::from(bounds.height()) / cell).ceil() as usize + 2); + let (mut w, mut h) = nodes(cell); + // The extent is the document canvas, which a file declares: past the node budget the + // field gets coarser (it is interpolated anyway) instead of asking for gigabytes. + while w.saturating_mul(h) > MAX_FIELD_NODES { + cell *= 2.0; + (w, h) = nodes(cell); + } LiquifyField { bounds, cell, w, h, d: vec![[0.0; 2]; w * h], freeze: vec![0.0; w * h], cursor: None } } @@ -1122,6 +1131,15 @@ mod tests { assert!(dd < 1e-3, "slow and fast pointer fields differ by {dd} px"); } + #[test] + fn a_huge_canvas_gets_a_coarser_field_not_a_huge_one() { + // 400000² px at 2 px per node is 4·10¹⁰ nodes. + let f = LiquifyField::new(Rect::new(0, 0, 400_000, 400_000), 2.0); + assert!(f.w * f.h <= MAX_FIELD_NODES && f.d.len() == f.w * f.h && f.freeze.len() == f.d.len()); + assert!(f.cell > 64.0); + assert_eq!(LiquifyField::new(Rect::new(0, 0, 100, 100), 2.0).cell, 2.0); + } + #[test] fn a_stroke_interrupted_by_a_jump_restarts_its_spacing() { // A segment that does not start where the last one ended begins a new run of dabs there. diff --git a/crates/algo/src/resample.rs b/crates/algo/src/resample.rs index bd728e379d..4ad84a10c0 100644 --- a/crates/algo/src/resample.rs +++ b/crates/algo/src/resample.rs @@ -159,8 +159,37 @@ fn cover(o: i32, scale: f64) -> (i32, i32) { (a, ((o + 1) as f64 / scale + 1e-9).floor().max(a as f64 + 1.0) as i32) } -/// Unclamped weights of output sample `o` over source samples `lo..lo + len`. -fn weights(o: i32, scale: f64, kernel: Kernel, edge: Option<(i32, i32)>) -> (i32, Vec) { +/// The source samples worth a weight each: the edge, or the content with one sample of slack on +/// each side to carry the weight of everything beyond it (read as the default pixel). +fn keep_range(edge: Option<(i32, i32)>, content: (i32, i32)) -> (i64, i64) { + let (a, b) = edge.unwrap_or((content.0.saturating_sub(1), content.1.saturating_add(1))); + (i64::from(a), i64::from(b.max(a))) +} + +/// Adds `v` spread evenly over the source samples `[a, b)` to `w`, which holds the samples +/// from `lo`: samples beyond either end of it add to that end slot instead of needing a slot. +fn spread(w: &mut [f64], lo: i64, a: i64, b: i64, v: f64) { + let Some(last) = w.len().checked_sub(1) else { return }; + let each = v / (b - a).max(1) as f64; + let end = lo + w.len() as i64; + let (ia, ib) = (a.max(lo), b.min(end)); + if ia < ib { + for slot in w.get_mut((ia - lo) as usize..(ib - lo) as usize).into_iter().flatten() { + *slot += each; + } + } + if a < lo { + w[0] += each * (b.min(lo) - a) as f64; + } + if b > end { + w[last] += each * (b - a.max(end)) as f64; + } +} + +/// Weights of output sample `o` over source samples `lo..lo + len`, unclamped except that +/// reductions fold what lies beyond the edge (or the `content`) onto its ends, so a tiny scale +/// doesn't cost a weight per source sample it covers. +fn weights(o: i32, scale: f64, kernel: Kernel, edge: Option<(i32, i32)>, content: (i32, i32)) -> (i32, Vec) { match kernel { Kernel::Nearest => { let u = (o as f64 + 0.5) / scale - 0.5; @@ -168,7 +197,11 @@ fn weights(o: i32, scale: f64, kernel: Kernel, edge: Option<(i32, i32)>) -> (i32 } Kernel::Bilinear if scale < 0.5 => { let (a, b) = cover(o, scale); - (a, vec![1.0 / (b - a) as f64; (b - a) as usize]) + let (ka, kb) = keep_range(edge, content); + let (lo, hi) = (i64::from(a).clamp(ka, kb), (i64::from(b) - 1).clamp(ka, kb)); + let mut w = vec![0.0; (hi - lo + 1) as usize]; + spread(&mut w, lo, i64::from(a), i64::from(b), 1.0); + (lo as i32, w) } Kernel::Bilinear => { let u = position(o, scale, edge); @@ -179,18 +212,18 @@ fn weights(o: i32, scale: f64, kernel: Kernel, edge: Option<(i32, i32)>) -> (i32 // Averaged down to four times the target first (pixels of `inner`), then filtered. let inner = 4.0 * scale; let inner_edge = edge.map(|(a, b)| ((a as f64 * inner - 1e-9).ceil() as i32, ((b + 1) as f64 * inner - 1e-9).ceil() as i32 - 1)); - let (k0, kw) = weights(o, 0.25, Kernel::Keys { a, stretch }, inner_edge); + let (k0, kw) = weights(o, 0.25, Kernel::Keys { a, stretch }, inner_edge, (i32::MIN / 4, i32::MAX / 4)); let clamp = |k: i32| inner_edge.map_or(k, |(a, b)| k.clamp(a, b.max(a))); let boxes: Vec<(i32, i32)> = (k0..).zip(&kw).map(|(k, _)| cover(clamp(k), inner)).collect(); - let lo = boxes.iter().map(|b| b.0).min().unwrap_or(0); - let hi = boxes.iter().map(|b| b.1).max().unwrap_or(lo); - let mut w = vec![0.0; (hi - lo).max(0) as usize]; + let (ka, kb) = keep_range(edge, content); + let lo = i64::from(boxes.iter().map(|b| b.0).min().unwrap_or(0)); + let hi = i64::from(boxes.iter().map(|b| b.1).max().unwrap_or(lo as i32)); + let (klo, khi) = (lo.clamp(ka, kb), (hi - 1).clamp(ka, kb)); + let mut w = vec![0.0; (khi - klo + 1) as usize]; for ((a, b), v) in boxes.into_iter().zip(kw) { - for slot in w.get_mut((a - lo) as usize..(b - lo) as usize).into_iter().flatten() { - *slot += v / (b - a) as f64; - } + spread(&mut w, klo, i64::from(a), i64::from(b), v); } - (lo, w) + (klo as i32, w) } Kernel::Keys { a, stretch } => { let u = position(o, scale, edge); @@ -225,7 +258,7 @@ fn taps(dst0: i32, dst1: i32, scale: f64, kernel: Kernel, edge: Option<(i32, i32 let kernel = if scale == 1.0 { Kernel::Nearest } else { kernel }; (dst0..dst1) .map(|o| { - let (lo, w) = weights(o, scale, kernel, edge); + let (lo, w) = weights(o, scale, kernel, edge, content); let hi = lo + w.len() as i32 - 1; // Fold the weights of taps beyond the edge onto the edge sample. let start = clamp(lo); @@ -724,6 +757,22 @@ mod tests { assert!((p[0] - 0.5).abs() < 0.05, "{p:?}"); } + /// A reduction by a tiny scale covers millions of source samples per output sample; only the + /// ones in the content get a weight, the rest fold onto its ends or into `outside`. + #[test] + fn tiny_scales_do_not_get_a_weight_per_covered_sample() { + for kernel in [Kernel::Bilinear, Kernel::Keys { a: -0.5, stretch: 1.0 }] { + for edge in [None, Some((0, 99))] { + let t = taps(0, 3, 1e-6, kernel, edge, (0, 99)); + for t in &t { + assert!(t.weights.len() <= 100, "{}", t.weights.len()); + let total = t.weights.iter().sum::() + t.outside; + assert!((total - 1.0).abs() < 1e-3, "{total}"); + } + } + } + } + #[test] fn transparent_edges_do_not_darken() { let mut s = Surface::new(PixelFormat::RGBA32F); diff --git a/crates/algo/src/tests.rs b/crates/algo/src/tests.rs index b7fc92113c..6d7e879346 100644 --- a/crates/algo/src/tests.rs +++ b/crates/algo/src/tests.rs @@ -640,3 +640,12 @@ fn mix_selection_reads_the_selection_once_per_tile() { assert!(a.iter().zip(&b).all(|(x, y)| x.to_bits() == y.to_bits()), "alpha {alpha}: results differ"); } } + +#[test] +fn blur_halos_follow_the_clamped_radius_and_never_overflow() { + let smart = |radius| FilterParams::SmartBlur { radius, threshold: 25.0, quality: BlurQuality::default(), mode: SmartBlurMode::default() }; + assert_eq!(smart(1e9).halo(), Halo::Radius(101)); + assert_eq!(smart(f32::INFINITY).halo(), Halo::Radius(101)); + let gaussian = FilterParams::GaussianBlur { radius: f32::INFINITY }; + assert_eq!(gaussian.halo(), Halo::Radius(i32::MAX)); +} diff --git a/crates/algo/src/warp.rs b/crates/algo/src/warp.rs index dd20daf6c9..87c6bde2c3 100644 --- a/crates/algo/src/warp.rs +++ b/crates/algo/src/warp.rs @@ -164,6 +164,11 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2 /// [`warp_triangles`], reporting progress per destination tile to `ctl`. A cancelled `ctl` skips /// the remaining tiles; the caller discards the result. +/// Destination tiles one triangle render may keep bookkeeping for (a 1M-tile render is 69 Gpx). +const MAX_DST_TILES: u64 = 1 << 20; +/// Source pixels one destination tile may read densely. +const MAX_FOOTPRINT_PIXELS: u64 = 1 << 25; + fn triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2])], tris: &[[usize; 3]], interp: Interp, ctl: &Interrupt) -> Surface { let mut fmt = src.format(); let converted; @@ -190,6 +195,12 @@ fn triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2])], tris y1 = y1.max(v.0[1]); } let dst = Rect::new(x0.max(-lim).floor() as i32 - 1, y0.max(-lim).floor() as i32 - 1, x1.min(lim).ceil() as i32 + 1, y1.min(lim).ceil() as i32 + 1); + // Bookkeeping is kept per destination tile, so a mesh flung across the whole coordinate range + // would ask for millions of entries before drawing anything: draw nothing instead. + let ts_px = photocraft_geom::TILE_SIZE as u64; + if u64::from(dst.width()).div_ceil(ts_px).saturating_add(1).saturating_mul(u64::from(dst.height()).div_ceil(ts_px).saturating_add(1)) > MAX_DST_TILES { + return out; + } // Triangles: (vertex indices), binned per destination tile. let tiles: Vec = dst.tiles().map(|tc| tc.rect().intersect(&dst)).filter(|r| !r.is_empty()).collect(); let mut bins: Vec> = vec![Vec::new(); tiles.len()]; @@ -304,7 +315,11 @@ fn triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2])], tris return None; } let foot = Rect::new(fx0.floor() as i32 - 3, fy0.floor() as i32 - 3, fx1.ceil() as i32 + 3, fy1.ceil() as i32 + 3).intersect(&src_rect); - if foot.is_empty() || !src.has_tiles_in(foot) { + // Sparse components far apart in the source can land in one tile: the dense window + // between them is not read (the tile stays empty). The pixel-area cap is checked first: + // `has_tiles_in` walks the source tile grid, so an oversized footprint must return + // without paying for that scan. + if foot.is_empty() || foot.size().area() > MAX_FOOTPRINT_PIXELS || !src.has_tiles_in(foot) { return None; } let mut px = src.read_region(foot); @@ -510,6 +525,18 @@ mod tests { surf } + #[test] + fn a_mesh_flung_across_the_coordinate_range_gets_no_per_tile_bookkeeping() { + // About 8000 × 8000 destination tiles from three vertices. + let src = sample(SampleType::U8); + let far = 1_000_000.0; + let verts = [([-far, -far], [0.0, 0.0]), ([far, -far], [40.0, 0.0]), ([0.0, far], [0.0, 24.0])]; + let t = std::time::Instant::now(); + let out = warp_triangles(&src, Rect::new(0, 0, 40, 24), &verts, &[[0, 1, 2]], Interp::Nearest); + assert!(out.content_bounds().is_empty()); + assert!(t.elapsed().as_secs() < 5); + } + #[test] fn a_thin_layer_stretched_into_a_curve_keeps_the_curve() { // #5793: a 3 px wide column stretched 100x and bent into an arch. The grid was sized in From a75a4143107c6208645741d0b72e92bb5eade434 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 19:29:31 +0000 Subject: [PATCH 52/53] CI: cargo xtask ci is the gate, and the workflow runs it --- .github/workflows/ci.yml | 46 +++++----------------------------------- docs/development.md | 2 +- xtask/src/main.rs | 46 +++++++++++++++++++++++++++++++++++----- 3 files changed, 47 insertions(+), 47 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf1a9c04a5..df23bef706 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,8 @@ name: CI # Quality gates for every push to main and every pull request. The Linux gate runs through the -# suite's shared gate workflow in craft-common-actions; the Windows legs, the test variants and -# the corpus, wasm, web and fuzz jobs are PhotoCraft's own. +# suite's shared gate workflow in craft-common-actions (`cargo xtask ci`); the Windows legs, the +# test variant and the corpus, web and fuzz jobs are PhotoCraft's own. on: push: branches: [main] @@ -126,31 +126,15 @@ jobs: # The tests build with the optional craft-fonts input (crates/text/build.rs), so the # Japanese font tests that skip without it run. Same commit as release.yml. craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695 - # The wasm32 build is its own job. - targets: "" # Cargo.toml's dev profile already keeps line tables only. dev-debug: "" env: | PHOTOCRAFT_NO_PRINT=1 LPDEST=photocraft-no-such-printer timeout-minutes: 100 - # `--features heif`: official builds open HEIC photos via the optional photocraft-heif crate. - # `--lib --bins --tests` leaves out the examples, which cargo test would build and link but - # never run (Clippy's `--all-targets` still checks they compile). nextest skips doctests, so - # they run last. The `ci` profile fails fast and terminates hung tests (.config/nextest.toml). - # docs/scorecard.md must match its sources; the check reads committed files only. - # panic_hunt: every command against adversarial params must return Err, never panic or hang - # (AGENTS.md Rule 9). It is `#[ignore]`d (slow), so the test run skips it; the same packages, - # features and targets keep it from rebuilding anything. - command: | - cargo fmt --all -- --check - cargo clippy --workspace --all-targets --locked --features heif -- -D warnings - cargo xtask layers - cargo xtask scorecard --check - cargo nextest run --profile ci --workspace --locked --features heif --lib --bins --tests - cargo nextest run --profile ci --workspace --locked --features heif --lib --bins --tests \ - --run-ignored only -E 'binary_id(photocraft-engine::panic_hunt)' - cargo test --workspace --locked --features heif --doc + # fmt, clippy, layers, scorecard, tests, panic_hunt, doctests and the wasm32 check, with + # `--locked --features heif` (xtask/src/main.rs). + command: cargo xtask ci # Windows tests run as two legs in parallel: the unit tests and the integration tests. As one job # they took ~28 min on the Windows runner; the legs take ~13 and ~15. Splitting the integration @@ -316,26 +300,6 @@ jobs: - name: Corpus tests run: cargo xtask test-corpus - wasm: - if: github.event.action != 'labeled' || github.event.label.name == 'full-ci' - timeout-minutes: 30 - name: wasm32 check - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable - with: - targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 - with: - key: wasm32 - save-if: ${{ github.ref == 'refs/heads/main' }} - cache-on-failure: true - - name: Check wasm-safe crates - run: cargo xtask wasm - web: timeout-minutes: 60 name: web build and wasm size gate diff --git a/docs/development.md b/docs/development.md index 4b47202d8e..fb61b4a660 100644 --- a/docs/development.md +++ b/docs/development.md @@ -40,7 +40,7 @@ cargo run --release -p photocraft -- --control 7878 --control-token-file .privat --automation-read-root work --automation-write-root work cargo test --workspace # everything cargo xtask test # tests (nextest when installed) + doctests -cargo xtask ci # fmt + clippy + tests + doctests + layers + wasm +cargo xtask ci # the CI gate: fmt, clippy, layers, scorecard, tests, panic_hunt, doctests, wasm cargo xtask stats # tests and lines per crate cargo xtask parity # Photoshop menu coverage -> docs/parity-checklist.md ``` diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 0f40543829..ad603238f4 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -26,7 +26,9 @@ commands: wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates (one call, plus one per optional web feature) test workspace tests (cargo nextest when installed, else cargo test), then doctests - ci fmt --check, clippy -D warnings, test, doctest, layers, wasm (stops at first failure) + ci the CI gate: fmt --check, clippy -D warnings, layers, scorecard --check, tests + (nextest --profile ci), panic_hunt, doctests and wasm, with --locked --features heif + (stops at first failure) corpus [--all | --pngsuite | --psd | --psd-tools | --pixls | --atn | --photoshop | --gimp] [--local] [--update-manifest] show where test corpora live and their pins (xtask/src/corpus_pins.rs), or fetch them into corpus/ (pinned commits, sha256-verified; --all = every corpus but pixls, @@ -213,6 +215,31 @@ fn cmd_test() -> Result<(), String> { run(c, &format!("cargo {tool} {}", TEST_TARGETS.join(" "))) } +/// What the CI gate adds to every build: `--locked`, and the optional photocraft-heif crate that +/// official builds open HEIC photos with. +const GATE_ARGS: [&str; 4] = ["--workspace", "--locked", "--features", "heif"]; + +/// The gate's tests. nextest's `ci` profile stops at the first failure and terminates hung tests +/// (.config/nextest.toml). `panic_hunt` runs only the engine's `#[ignore]`d panic hunt (AGENTS.md +/// Rule 9) with the same packages, features and targets as the tests, so nothing rebuilds. +fn cmd_gate_tests(panic_hunt: bool) -> Result<(), String> { + let mut c = cargo(); + if nextest_available() { + c.args(["nextest", "run", "--profile", "ci"]).args(GATE_ARGS).args(&TEST_TARGETS[1..]); + if panic_hunt { + c.args(["--run-ignored", "only", "-E", "binary_id(photocraft-engine::panic_hunt)"]); + } + } else if panic_hunt { + // `-- --ignored` across the workspace would run every ignored test, not only the hunt. + c.args(["test", "-p", "photocraft-engine", "--locked", "--test", "panic_hunt", "--", "--ignored"]); + } else { + eprintln!("note: cargo-nextest is not installed, running cargo test (cargo install cargo-nextest --locked)"); + c.arg("test").args(GATE_ARGS).args(&TEST_TARGETS[1..]); + } + let what = format!("cargo {}", c.get_args().map(|a| a.to_string_lossy()).collect::>().join(" ")); + run(c, &what) +} + /// nextest does not run doctests. fn cmd_doctest() -> Result<(), String> { let mut c = cargo(); @@ -241,13 +268,22 @@ fn cmd_ci() -> Result<(), String> { "clippy", Box::new(|| { let mut c = cargo(); - c.args(["clippy", "--workspace", "--all-targets", "--keep-going", "--", "-D", "warnings"]); - run(c, "cargo clippy --workspace --all-targets --keep-going -- -D warnings") + c.arg("clippy").args(GATE_ARGS).args(["--all-targets", "--keep-going", "--", "-D", "warnings"]); + run(c, "cargo clippy --workspace --locked --features heif --all-targets --keep-going -- -D warnings") }), ), - ("test", Box::new(cmd_test)), - ("doctest", Box::new(cmd_doctest)), ("layers", Box::new(cmd_layers)), + ("scorecard", Box::new(|| scorecard::run(&root(), &["--check"]))), + ("test", Box::new(|| cmd_gate_tests(false))), + ("panic_hunt", Box::new(|| cmd_gate_tests(true))), + ( + "doctest", + Box::new(|| { + let mut c = cargo(); + c.arg("test").args(GATE_ARGS).arg("--doc"); + run(c, "cargo test --workspace --locked --features heif --doc") + }), + ), ("wasm", Box::new(cmd_wasm)), ]; let mut done = Vec::new(); From 6ca5bf359de5e3b37ad1799e7fb6e49c61c8229c Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 15:33:01 -0400 Subject: [PATCH 53/53] security(compose): bound dense working sets and nested Blend If evaluation (#8949) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * security(compose): bound dense working sets and repeated group evaluation Smart-filter mixing and stroke fitting work tile by tile instead of over the union of sparse surfaces; render_layer admits its rect like render; hidden layers no longer disable the proxy and uniform-area shortcuts; Blend If groups nested deeper than two are judged by their result, so nesting costs polynomial instead of exponential work; effect builds past an absolute byte cap are skipped. * CodeRabbit #8949: exact tile skips in filter_mask, allocated-tile scan in fit_stroke, contrasting backdrop in the nested-group test - filter_mask mix/unmix: bare tiles (no tiles on either side) skip for non-alpha formats too when the two surfaces' default pixels are equal; differing defaults still get processed, so the clone's default pixel is never left wrong under a non-unit mask - shape_split fit_stroke: scan the union of the three surfaces' allocated tiles (exact skip when all three defaults are transparent) instead of every tile of a 300k-canvas document; non-transparent defaults fall back to the full scan - tests: the nested-groups assertion now uses a black backdrop so the asserted white can only come from rendering through the group chain fixes CodeRabbit review comments (major/minor) on filter_mask.rs:27,29, shape_split.rs:126, tests.rs:2354 * compose: skip a BTreeSet in fit_stroke — a sorted Vec compiles under the wasm toolchain (E0599 on ::drain there) --- crates/compose/src/filter_mask.rs | 127 +++++++++++++++++++----------- crates/compose/src/lib.rs | 55 ++++++++++++- crates/compose/src/proxy.rs | 22 +++++- crates/compose/src/shape_split.rs | 33 +++++++- crates/compose/src/tests.rs | 34 ++++++++ crates/compose/src/uniform.rs | 3 +- 6 files changed, 222 insertions(+), 52 deletions(-) diff --git a/crates/compose/src/filter_mask.rs b/crates/compose/src/filter_mask.rs index b845720456..2d9031f5d4 100644 --- a/crates/compose/src/filter_mask.rs +++ b/crates/compose/src/filter_mask.rs @@ -21,27 +21,38 @@ pub fn mix(base: &Surface, filtered: &Surface, mask: &LayerMask) -> Surface { return out; } let n = fmt.channels(); - let (b, f) = (base.read_region(area), filtered.read_region(area)); - let mut k = Vec::new(); - mask.values_into(area, &mut k); - let mut o = vec![0.0f32; b.len()]; - for (i, ((bp, fp), op)) in b.chunks_exact(n).zip(f.chunks_exact(n)).zip(o.chunks_exact_mut(n)).enumerate() { - let m = k.get(i).copied().unwrap_or(1.0).clamp(0.0, 1.0); - if !fmt.alpha { - for c in 0..n { - op[c] = bp[c] + (fp[c] - bp[c]) * m; - } + // Tile by tile: the union of two sparse surfaces far apart is a rectangle of mostly nothing, + // and transparent pixels in tiles neither surface has stay as they are. + let whole = area; + let same_default = base.default_pixel() == filtered.default_pixel(); + for t in whole.tiles() { + let area = t.rect().intersect(&whole); + let bare = !base.has_tiles_in(area) && !filtered.has_tiles_in(area); + if area.is_empty() || (bare && (fmt.alpha || same_default)) { continue; } - let a = n - 1; - let oa = bp[a] + (fp[a] - bp[a]) * m; - op[a] = oa; - for c in 0..a { - let pm = bp[c] * bp[a] + (fp[c] * fp[a] - bp[c] * bp[a]) * m; - op[c] = if oa > 0.0 { (pm / oa).clamp(0.0, 1.0) } else { 0.0 }; + let (b, f) = (base.read_region(area), filtered.read_region(area)); + let mut k = Vec::new(); + mask.values_into(area, &mut k); + let mut o = vec![0.0f32; b.len()]; + for (i, ((bp, fp), op)) in b.chunks_exact(n).zip(f.chunks_exact(n)).zip(o.chunks_exact_mut(n)).enumerate() { + let m = k.get(i).copied().unwrap_or(1.0).clamp(0.0, 1.0); + if !fmt.alpha { + for c in 0..n { + op[c] = bp[c] + (fp[c] - bp[c]) * m; + } + continue; + } + let a = n - 1; + let oa = bp[a] + (fp[a] - bp[a]) * m; + op[a] = oa; + for c in 0..a { + let pm = bp[c] * bp[a] + (fp[c] * fp[a] - bp[c] * bp[a]) * m; + op[c] = if oa > 0.0 { (pm / oa).clamp(0.0, 1.0) } else { 0.0 }; + } } + out.write_region(area, &o); } - out.write_region(area, &o); out.prune(); out } @@ -61,38 +72,51 @@ pub fn unmix(base: &Surface, mixed: &Surface, mask: &LayerMask) -> Surface { return out; } let n = fmt.channels(); - let (b, x) = (base.read_region(area), mixed.read_region(area)); - let mut k = Vec::new(); - mask.values_into(area, &mut k); - let mut o = vec![0.0f32; b.len()]; - for (i, ((bp, xp), op)) in b.chunks_exact(n).zip(x.chunks_exact(n)).zip(o.chunks_exact_mut(n)).enumerate() { - let m = k.get(i).copied().unwrap_or(1.0).clamp(0.0, 1.0); - if m < HIDDEN { - // A transparent base pixel (Photoshop's cache keeps white there) stays empty, so the - // stored pixels don't grow past the content. - if !fmt.alpha || bp[n - 1] > 0.0 { - op.copy_from_slice(bp); + // Tile by tile: the union of two sparse surfaces far apart is a rectangle of mostly nothing, + // and transparent pixels in tiles neither surface has stay as they are. + let whole = area; + // Non-alpha still needs every tile whose defaults differ (an unmix through a non-unit mask + // would change that default, and the clone already holds the old one). + let same_default = base.default_pixel() == mixed.default_pixel(); + for t in whole.tiles() { + let area = t.rect().intersect(&whole); + let bare = !base.has_tiles_in(area) && !mixed.has_tiles_in(area); + if area.is_empty() || (bare && (fmt.alpha || same_default)) { + continue; + } + let (b, x) = (base.read_region(area), mixed.read_region(area)); + let mut k = Vec::new(); + mask.values_into(area, &mut k); + let mut o = vec![0.0f32; b.len()]; + for (i, ((bp, xp), op)) in b.chunks_exact(n).zip(x.chunks_exact(n)).zip(o.chunks_exact_mut(n)).enumerate() { + let m = k.get(i).copied().unwrap_or(1.0).clamp(0.0, 1.0); + if m < HIDDEN { + // A transparent base pixel (Photoshop's cache keeps white there) stays empty, so the + // stored pixels don't grow past the content. + if !fmt.alpha || bp[n - 1] > 0.0 { + op.copy_from_slice(bp); + } + continue; } - continue; - } - if !fmt.alpha { - for c in 0..n { - op[c] = (bp[c] + (xp[c] - bp[c]) / m).clamp(0.0, 1.0); + if !fmt.alpha { + for c in 0..n { + op[c] = (bp[c] + (xp[c] - bp[c]) / m).clamp(0.0, 1.0); + } + continue; + } + let a = n - 1; + let fa = (bp[a] + (xp[a] - bp[a]) / m).clamp(0.0, 1.0); + if fa <= 0.0 { + continue; + } + op[a] = fa; + for c in 0..a { + let pm = bp[c] * bp[a] + (xp[c] * xp[a] - bp[c] * bp[a]) / m; + op[c] = (pm / fa).clamp(0.0, 1.0); } - continue; - } - let a = n - 1; - let fa = (bp[a] + (xp[a] - bp[a]) / m).clamp(0.0, 1.0); - if fa <= 0.0 { - continue; - } - op[a] = fa; - for c in 0..a { - let pm = bp[c] * bp[a] + (xp[c] * xp[a] - bp[c] * bp[a]) / m; - op[c] = (pm / fa).clamp(0.0, 1.0); } + out.write_region(area, &o); } - out.write_region(area, &o); out.prune(); out } @@ -122,6 +146,19 @@ mod tests { m } + #[test] + fn mixing_sparse_surfaces_far_apart_does_not_build_their_union() { + // 10¹⁰ px between two single pixels would be 160 GB of floats read densely. + let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::F32, true); + let (mut base, mut filtered) = (Surface::new(fmt), Surface::new(fmt)); + base.write_pixel(0, 0, &[0.2, 0.4, 0.6, 1.0]); + filtered.write_pixel(100_000, 100_000, &[1.0, 0.0, 0.0, 1.0]); + let mixed = mix(&base, &filtered, &LayerMask::reveal_all()); + assert_eq!(mixed.pixel(100_000, 100_000), vec![1.0, 0.0, 0.0, 1.0]); + let back = unmix(&base, &mixed, &LayerMask::reveal_all()); + assert_eq!(back.pixel(100_000, 100_000), vec![1.0, 0.0, 0.0, 1.0]); + } + #[test] fn unmix_inverts_mix_wherever_the_mask_shows_the_filter() { for fmt in [PixelFormat::new(ColorMode::Rgb, SampleType::F32, true), PixelFormat::new(ColorMode::Rgb, SampleType::F32, false)] { diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index e74cfd6d2f..6cb6707e0b 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -150,6 +150,22 @@ fn scratch_reuse_preserves_pixels_and_bounds_retention() { }); } +/// Every layer the compositor draws, groups before their children: hidden layers and everything +/// inside hidden groups are left out. +pub(crate) fn visible_layers(layers: &[Layer]) -> Vec<&Layer> { + fn rec<'a>(layers: &'a [Layer], out: &mut Vec<&'a Layer>) { + for l in layers.iter().filter(|l| l.visible) { + out.push(l); + if let Some(children) = l.children() { + rec(children, out); + } + } + } + let mut out = Vec::new(); + rec(layers, &mut out); + out +} + /// Composite the whole document over `rect`, in parallel 256² tiles on /// native targets (single-threaded on wasm). pub fn render(doc: &Document, rect: Rect) -> Buffer { @@ -383,7 +399,11 @@ pub fn flatten(doc: &Document) -> Buffer { } /// Render an arbitrary subset: a single layer (e.g. for thumbnails), isolated. +/// Rects past [`MAX_RENDER_PIXELS`] give an empty buffer. pub fn render_layer(layer: &Layer, rect: Rect) -> Buffer { + if u64::from(rect.width()) * u64::from(rect.height()) > MAX_RENDER_PIXELS { + return Buffer::transparent(Rect::EMPTY); + } let mut buf = Buffer::transparent(rect); let patterns = pattern::PreparedPatterns::new(&[], pattern::PREPARED_PATTERN_BYTES); composite_stack_at( @@ -1720,14 +1740,34 @@ fn blend_if_level(x: f32) -> f32 { if (v - r).abs() < 1e-4 { r } else { v } } +/// Blend If groups inside one another whose own colour is still rendered separately. Each extra +/// level multiplies the work by the tree's depth, so the cost is polynomial, not exponential. +const MAX_BLEND_IF_GROUP_DEPTH: u32 = 2; + +thread_local! { + /// Blend If groups being rendered for their own colour on this thread. + static BLEND_IF_GROUPS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// Apply `layer`'s Blend If to a finished composite: `out` (the backdrop with the layer drawn) /// is mixed back towards `before` (the backdrop without it) where the ranges hide the layer. /// Mixing premultiplied colour by `k` equals compositing the layer at `k` × its alpha, since /// every blend mode's source-over result is linear in the source alpha. fn apply_blend_if(layer: &Layer, before: &Buffer, out: &mut Buffer, cx: &Ctx) { // "This Layer" is the layer's own colour; adjustment layers (no content of their own) are - // judged by their result. - let own = if matches!(layer.content, LayerContent::Adjustment(_)) { None } else { render_content(layer, out.rect, cx) }; + // judged by their result. So are groups nested deeper than `MAX_BLEND_IF_GROUP_DEPTH` Blend + // Ifs: a group's own colour re-composites its children, which doubles the work at every + // level of Blend If groups inside one another. + let is_group = matches!(layer.content, LayerContent::Group(_)); + let nested = BLEND_IF_GROUPS.with(std::cell::Cell::get); + let own = if matches!(layer.content, LayerContent::Adjustment(_)) || (is_group && nested >= MAX_BLEND_IF_GROUP_DEPTH) { + None + } else { + BLEND_IF_GROUPS.with(|d| d.set(nested + u32::from(is_group))); + let own = render_content(layer, out.rect, cx); + BLEND_IF_GROUPS.with(|d| d.set(nested)); + own + }; for (i, (p, b)) in out.px.iter_mut().zip(&before.px).enumerate() { let this = match &own { Some(o) => Some(o.px[i]).filter(|q| q[3] > 0.0), @@ -2226,12 +2266,21 @@ fn fx_region_bytes(layers: &[Layer], canvas: Rect) -> u64 { bytes(layers, canvas, 0) } +/// Most bytes one layer's effect maps and source may take to build, whatever the cache budget. +const MAX_FX_BUILD_BYTES: u64 = if cfg!(target_pointer_width = "64") { 4 << 30 } else { 512 << 20 }; + /// The layer's effect maps over its region (layer bounds grown by the effect reach, within the /// canvas grown likewise; see [`effect_region`]), built once per layer state and shared by every /// tile. `rect` is what the caller composites the layer over (see [`effect_region`]). fn effect_maps(layer: &Layer, rect: Rect, cx: &Ctx) -> std::sync::Arc { use std::hash::{Hash, Hasher}; - let (region, clipped) = effect_region(layer, rect, cx); + let (mut region, clipped) = effect_region(layer, rect, cx); + // Non-local effects keep their whole source region, which a few distant pixels stretch over + // the canvas: past the cap the effects are left out instead of allocating it. + let region_px = u64::from(region.width()) * u64::from(region.height()); + if (effects::map_count(layer) as u64 + 1).saturating_mul(region_px).saturating_mul(4) > MAX_FX_BUILD_BYTES { + region = Rect::EMPTY; + } static NO_CACHE: std::sync::OnceLock = std::sync::OnceLock::new(); if *NO_CACHE.get_or_init(|| std::env::var_os("PHOTOCRAFT_FX_NOCACHE").is_some()) { let shape = if region.is_empty() { Vec::new() } else { effect_shape(layer, region, cx) }; diff --git a/crates/compose/src/proxy.rs b/crates/compose/src/proxy.rs index f48f10d83f..dba123e21c 100644 --- a/crates/compose/src/proxy.rs +++ b/crates/compose/src/proxy.rs @@ -112,7 +112,8 @@ pub fn proxy_document(doc: &Document, k: u32) -> Document { /// with it. Empty vector masks have constant coverage, including inversion and density. /// Nonempty paths can still anchor fill layouts even when their mask density is zero. pub fn proxy_faithful(doc: &Document) -> bool { - doc.walk().iter().all(|(_, _, l)| !crate::effects::has_effects(l) && l.vector_mask.as_ref().is_none_or(|v| !v.enabled || v.path.is_empty())) + // Hidden layers (and what they hold) are not composited, so they don't count. + crate::visible_layers(&doc.layers).iter().all(|l| !crate::effects::has_effects(l) && l.vector_mask.as_ref().is_none_or(|v| !v.enabled || v.path.is_empty())) } #[cfg(test)] @@ -263,6 +264,25 @@ mod tests { assert!(proxy_faithful(&doc)); } + #[test] + fn a_hidden_layer_with_effects_does_not_rule_out_the_proxy_or_the_uniform_shortcut() { + let mut doc = two_colors(Size::new(32, 16), SampleType::U8); + doc.layers[1].effects.items.push(Effect::default_drop_shadow()); + doc.layers[1].effects.enabled = true; + assert!(!proxy_faithful(&doc)); + assert!(crate::uniform::UniformAreas::new(&doc).is_none()); + doc.layers[1].visible = false; + assert!(proxy_faithful(&doc)); + assert!(crate::uniform::UniformAreas::new(&doc).is_some()); + // Inside a hidden group too. + doc.layers[1].visible = true; + let group = Layer::group("g", vec![doc.layers.remove(1)]); + doc.layers.push(group); + assert!(!proxy_faithful(&doc)); + doc.layers[1].visible = false; + assert!(proxy_faithful(&doc)); + } + #[test] fn empty_vector_masks_keep_constant_coverage_at_every_depth() { for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { diff --git a/crates/compose/src/shape_split.rs b/crates/compose/src/shape_split.rs index 09d404669b..b2fd28116e 100644 --- a/crates/compose/src/shape_split.rs +++ b/crates/compose/src/shape_split.rs @@ -8,7 +8,7 @@ use std::sync::{Mutex, OnceLock}; use photocraft_color::{PixelFormat, SampleType}; use photocraft_doc::vector::ShapeLayer; -use photocraft_geom::Rect; +use photocraft_geom::{Rect, TileCoord}; use photocraft_raster::Surface; /// (fill, stroke), last use, and the shape's pixels the stroke was fitted to (kept alive so @@ -118,13 +118,42 @@ fn fit_stroke(fill: &Surface, stroke: &mut Surface, cache: &Surface, canvas: Rec if r.is_empty() { return; } + let float = stroke.format().sample == SampleType::F32; + // Tiles none of the three reaches read as the defaults; with transparent defaults the fit + // leaves the stroke's default pixel there, so those tiles are exact skips and we can scan + // just the union of the three surfaces' allocated tiles instead of every canvas tile (two + // sparse regions on a 300k×300k document would otherwise walk ~1.4M empty tiles). A + // non-transparent default is everywhere, so the scan falls back to the full rectangle. + let whole = r; + let bare_transparent = [fill, &*stroke, cache].iter().all(|s| s.default_pixel().iter().all(|v| *v == 0.0)); + if bare_transparent { + let mut coords: Vec = fill.tiles().map(|(c, _)| *c).chain(stroke.tiles().map(|(c, _)| *c)).chain(cache.tiles().map(|(c, _)| *c)).collect(); + coords.sort_unstable(); + coords.dedup(); + for c in coords { + let r = c.rect().intersect(&whole); + if !r.is_empty() { + fit_stroke_rect(fill, stroke, cache, r, float); + } + } + } else { + for t in whole.tiles() { + let r = t.rect().intersect(&whole); + if !r.is_empty() { + fit_stroke_rect(fill, stroke, cache, r, float); + } + } + } +} + +/// [`fit_stroke`] over one rectangle. +fn fit_stroke_rect(fill: &Surface, stroke: &mut Surface, cache: &Surface, r: Rect, float: bool) { let n = r.width() as usize * r.height() as usize; let (mut f, mut s, mut c) = (vec![[0.0f32; 4]; n], vec![[0.0f32; 4]; n], vec![[0.0f32; 4]; n]); fill.read_rgba_into(r, &mut f); stroke.read_rgba_into(r, &mut s); cache.read_rgba_into(r, &mut c); let mut out = Vec::with_capacity(n * 4); - let float = stroke.format().sample == SampleType::F32; for ((fp, sp), cp) in f.iter().zip(&s).zip(&c) { let mut p = *sp; if fp[3] < 1.0 - 1e-3 { diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 8f111269a8..11da5a62f9 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1737,6 +1737,20 @@ fn vector_mask_combines_with_pixel_mask() { assert!(close4(px(&d, 6, 1), [1.0; 4])); } +#[test] +fn effect_maps_over_distant_pixels_are_not_built_over_the_canvas() { + // Two pixels 190000 apart: the effect source region spans 3.6·10¹⁰ px. + let mut doc = Document::new("big", Size::new(200_000, 200_000), ColorMode::Rgb, SampleType::U8); + let mut l = solid_layer("fx", Rect::new(0, 0, 1, 1), [1.0, 0.0, 0.0, 1.0]); + l.surface_mut().unwrap().fill_rect(Rect::new(190_000, 190_000, 190_001, 190_001), &[1.0, 0.0, 0.0, 1.0]); + l.effects.items.push(photocraft_doc::Effect::default_drop_shadow()); + doc.layers.push(l); + let t = std::time::Instant::now(); + let px = render(&doc, Rect::new(0, 0, 4, 4)); + assert_eq!(px.px.len(), 16); + assert!(t.elapsed().as_secs() < 20, "{:?}", t.elapsed()); +} + #[test] fn effect_maps_are_cached_and_invalidated_by_pixel_changes() { let mut doc = doc_white(64, 64); @@ -2326,6 +2340,26 @@ fn blend_if_this_layer_hides_by_the_layers_own_value() { assert!(close4(px(&d, 1, 1), [0.0, 0.0, 0.0, 1.0])); } +#[test] +fn nested_blend_if_groups_do_not_double_the_work_at_every_level() { + // Each Blend If group re-renders its children for its own colour; 40 levels would be 2^40. + let mut inner = solid_layer("dot", Rect::new(0, 0, 4, 4), [1.0, 1.0, 1.0, 1.0]); + inner.blend_if.set(0, [range([10, 10], [255, 255]), FULL]); + let mut layers = vec![inner]; + for _ in 0..40 { + let mut g = Layer::group("g", layers); + g.blend_if.set(0, [range([10, 10], [255, 255]), FULL]); + layers = vec![g]; + } + // A black backdrop, not doc_white's white: the asserted white can then only come from the + // inner layer rendered through the whole group chain, not from the backdrop itself. + let mut d = Document::with_background("t", Size::new(4, 4), ColorMode::Rgb, SampleType::U8, Color::BLACK); + d.layers.extend(layers); + let t = std::time::Instant::now(); + assert!(close4(px(&d, 1, 1), [1.0, 1.0, 1.0, 1.0])); + assert!(t.elapsed().as_secs() < 10, "{:?}", t.elapsed()); +} + #[test] fn blend_if_underlying_layer_hides_by_the_backdrop_value() { // Backdrop black on the left, white on the right; a red layer over all of it. diff --git a/crates/compose/src/uniform.rs b/crates/compose/src/uniform.rs index e9423d0e69..02bc4a4111 100644 --- a/crates/compose/src/uniform.rs +++ b/crates/compose/src/uniform.rs @@ -32,7 +32,8 @@ impl<'a> UniformAreas<'a> { return None; } let mut surfaces = Vec::new(); - for (_, _, l) in doc.walk() { + // Hidden layers are not composited: one with an effect must not rule the shortcut out. + for l in crate::visible_layers(&doc.layers) { let effects = l.effects.enabled && !l.effects.items.is_empty(); if effects || l.vector_mask.is_some() || l.video.is_some() || l.blend == BlendMode::Dissolve { return None;