diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 60d8c5e905..ba70d6e1df 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -201,10 +201,10 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>, }; if multiple { let picked = dialog.pick_files(); - Box::pin(async move { picked.await.map(|files| FileDialogAnswer::Paths(files.iter().map(path_of).collect())) }) + Box::pin(async move { picked.await.map(|files| answer(files.iter().map(|f| f.path()), false, FileDialogAnswer::Paths)) }) } else { let picked = dialog.pick_file(); - Box::pin(async move { picked.await.map(|file| FileDialogAnswer::Paths(vec![path_of(&file)])) }) + Box::pin(async move { picked.await.map(|file| answer([file.path()], false, FileDialogAnswer::Paths)) }) } } FileDialogRequest::Folder { initial_dir } => { @@ -212,7 +212,7 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>, dialog = dialog.set_directory(dir); } let picked = dialog.pick_folder(); - Box::pin(async move { picked.await.map(|folder| FileDialogAnswer::FolderTo(path_of(&folder))) }) + Box::pin(async move { picked.await.map(|folder| answer_one(folder.path(), true, FileDialogAnswer::FolderTo)) }) } FileDialogRequest::Save { suggested } => { let filters = save_filters(&suggested); @@ -247,10 +247,10 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>, .map_err(|e| e.to_string()) .and_then(|conn| crate::portal_save::save_as(&conn, req)); match portal { - Ok(path) => path.map(|p| FileDialogAnswer::SaveTo(p.to_string_lossy().into_owned())), + Ok(path) => path.map(|p| answer_one(&p, true, FileDialogAnswer::SaveTo)), Err(e) => { log::info!("portal save dialog unavailable ({e}), using rfd"); - dialog.save_file().await.map(|file| FileDialogAnswer::SaveTo(path_of(&file))) + dialog.save_file().await.map(|file| answer_one(file.path(), true, FileDialogAnswer::SaveTo)) } } }) @@ -259,7 +259,7 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>, { let _ = filters; let picked = dialog.save_file(); - Box::pin(async move { picked.await.map(|file| FileDialogAnswer::SaveTo(path_of(&file))) }) + Box::pin(async move { picked.await.map(|file| answer_one(file.path(), true, FileDialogAnswer::SaveTo)) }) } } }; @@ -273,8 +273,22 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>, } } -fn path_of(file: &rfd::FileHandle) -> String { - file.path().to_string_lossy().into_owned() +/// The dialog's answer for the chosen `paths`, or the error for one whose name isn't valid +/// Unicode, which a lossy copy would turn into another file's name (#6601). `saving`: the paths +/// are to be written to. +fn answer<'a>(paths: impl IntoIterator, saving: bool, make: impl FnOnce(Vec) -> FileDialogAnswer) -> FileDialogAnswer { + match paths.into_iter().map(|p| photocraft_ui_egui::file_open::path_text(p, saving)).collect() { + Ok(paths) => make(paths), + Err(e) => FileDialogAnswer::Unavailable(e), + } +} + +/// [`answer`] for a dialog that picks one path. +fn answer_one(path: &Path, saving: bool, make: impl FnOnce(String) -> FileDialogAnswer) -> FileDialogAnswer { + match photocraft_ui_egui::file_open::path_text(path, saving) { + Ok(path) => make(path), + Err(e) => FileDialogAnswer::Unavailable(e), + } } /// Runs `future` to completion on this thread, sleeping until it is woken. @@ -757,6 +771,31 @@ mod tests { use photocraft_ui_egui::{PhotocraftApp, prefs_ui}; use serde_json::{Value, json}; + /// #6601: a chosen path whose name isn't valid Unicode is reported, never handed on lossily + /// (Open would fail with "No such file", Save would write a second, renamed file). + #[test] + fn a_chosen_path_that_is_not_valid_unicode_is_an_error_not_another_name() { + #[cfg(unix)] + let bad = { + use std::os::unix::ffi::OsStrExt; + std::env::temp_dir().join(std::ffi::OsStr::from_bytes(b"img\xFF.png")) + }; + #[cfg(windows)] + let bad = { + use std::os::windows::ffi::OsStringExt; + let mut wide: Vec = "img".encode_utf16().collect(); + wide.push(0xD800); + wide.extend(".png".encode_utf16()); + std::env::temp_dir().join(std::ffi::OsString::from_wide(&wide)) + }; + let good = std::env::temp_dir().join("ok.png"); + let save = |path: &Path| answer_one(path, true, FileDialogAnswer::SaveTo); + assert!(matches!(save(&bad), FileDialogAnswer::Unavailable(e) if e.contains("not valid Unicode") && e.contains("another name"))); + assert!(matches!(save(&good), FileDialogAnswer::SaveTo(p) if p == good.to_str().unwrap())); + let open = answer([good.as_path(), bad.as_path()], false, FileDialogAnswer::Paths); + assert!(matches!(open, FileDialogAnswer::Unavailable(e) if e.contains("open it again"))); + } + /// #6121: the file type picked in the save dialog gives the name its extension where the /// dialog left the one it opened with, and only there. #[test] diff --git a/contributors/people.toml b/contributors/people.toml index b16a778dee..c54a3c4bb1 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -197,3 +197,7 @@ sync_github_name = true [people.webdevtodayjason] display_name = "Jason Brashear" + +[people.AndreeSalazar] +real_name = "Andreé Salazar" +sync_github_name = true diff --git a/crates/codecs/src/codecs/png.rs b/crates/codecs/src/codecs/png.rs index f894b3a30d..085246b3f1 100644 --- a/crates/codecs/src/codecs/png.rs +++ b/crates/codecs/src/codecs/png.rs @@ -676,8 +676,8 @@ mod stereo_tests { #[test] fn reads_both_defined_views() { - assert_eq!(stereo(&png_with(&[0])), Some(PngStereo::Horizontal)); - assert_eq!(stereo(&png_with(&[1])), Some(PngStereo::Vertical)); + assert_eq!(stereo(&png_with(&[0])), Some(PngStereo::CrossFuse)); + assert_eq!(stereo(&png_with(&[1])), Some(PngStereo::DivergingFuse)); assert_eq!(stereo(&png_with(&[])), None); // Reserved and unknown mode bytes are not a pairing we can keep. assert_eq!(stereo(&png_with(&[2])), None); @@ -687,6 +687,24 @@ mod stereo_tests { assert_eq!(stereo(b"not a png"), None); } + #[test] + fn ster_mode_bytes_describe_eye_order_not_vertical_packing() { + // PNG Extensions 1.4.0 §3.6: both views occupy horizontal halves. + // Each view is eight columns, so the second starts on an eight-column boundary. + for (mode, left_pixel, right_pixel) in [(0, [0, 0, 255], [255, 0, 0]), (1, [255, 0, 0], [0, 0, 255])] { + let pixels: Vec = [left_pixel; 8].into_iter().chain([right_pixel; 8]).flatten().collect(); + let img = Image::from_raw(16, 1, ChannelLayout::Rgb, SampleType::U8, pixels.clone()).unwrap(); + let pairing = if mode == 0 { PngStereo::CrossFuse } else { PngStereo::DivergingFuse }; + let opts = EncodeOptions { png_stereo: Some(pairing), ..Default::default() }; + let bytes = crate::encode(&img, Format::Png, &opts).unwrap(); + let chunk = bytes.windows(4).position(|w| w == b"sTER").unwrap(); + assert_eq!(bytes[chunk + 4], mode, "the specification's literal mode byte"); + let decoded = decode(&bytes, &Limits::default()).unwrap(); + assert_eq!(decoded.dimensions(), (16, 1)); + assert_eq!(decoded.data(), pixels); + } + } + #[test] fn a_broken_crc_is_not_a_stereo_mark() { let mut bytes = png_with(&[0]); @@ -699,7 +717,7 @@ mod stereo_tests { #[test] fn encode_writes_the_chunk_before_idat_and_it_reads_back() { let img = Image::from_raw(2, 1, ChannelLayout::Rgb, SampleType::U8, vec![9; 6]).unwrap(); - for view in [PngStereo::Horizontal, PngStereo::Vertical] { + for view in [PngStereo::CrossFuse, PngStereo::DivergingFuse] { let opts = EncodeOptions { png_stereo: Some(view), ..Default::default() }; let bytes = crate::encode(&img, Format::Png, &opts).unwrap(); assert_eq!(stereo(&bytes), Some(view)); @@ -716,12 +734,12 @@ mod stereo_tests { #[test] fn interlaced_and_indexed_encoders_keep_the_mark() { let img = Image::from_raw(2, 2, ChannelLayout::Rgb, SampleType::U8, vec![9; 12]).unwrap(); - let opts = EncodeOptions { png_stereo: Some(PngStereo::Vertical), png_interlaced: true, ..Default::default() }; + let opts = EncodeOptions { png_stereo: Some(PngStereo::DivergingFuse), png_interlaced: true, ..Default::default() }; let bytes = crate::encode(&img, Format::Png, &opts).unwrap(); - assert_eq!(stereo(&bytes), Some(PngStereo::Vertical)); + assert_eq!(stereo(&bytes), Some(PngStereo::DivergingFuse)); let pal = [[255, 0, 0], [0, 0, 255]]; - let bytes = encode_indexed(2, 1, &[0, 1], &pal, None, None, Some(PngStereo::Horizontal)).unwrap(); - assert_eq!(stereo(&bytes), Some(PngStereo::Horizontal)); + let bytes = encode_indexed(2, 1, &[0, 1], &pal, None, None, Some(PngStereo::CrossFuse)).unwrap(); + assert_eq!(stereo(&bytes), Some(PngStereo::CrossFuse)); assert_eq!(palette(&bytes).unwrap().map(|p| p.colors), Some(pal.to_vec())); } } diff --git a/crates/codecs/src/codecs/tga.rs b/crates/codecs/src/codecs/tga.rs index 7b52533d7b..2f382efcf3 100644 --- a/crates/codecs/src/codecs/tga.rs +++ b/crates/codecs/src/codecs/tga.rs @@ -88,3 +88,88 @@ fn rle_row(row: &[u8], bpp: usize, out: &mut Vec) { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn run_packets_at_the_128_pixel_boundary() { + for bpp in 1..=4 { + for (pixels, headers) in [(1, vec![0]), (2, vec![0x81]), (127, vec![0xfe]), (128, vec![0xff]), (129, vec![0xff, 0])] { + let pixel: Vec = (1..=bpp as u8).collect(); + let mut encoded = Vec::new(); + rle_row(&pixel.repeat(pixels), bpp, &mut encoded); + let expected: Vec = headers.into_iter().flat_map(|header| std::iter::once(header).chain(pixel.iter().copied())).collect(); + assert_eq!(encoded, expected, "{pixels} pixels, {bpp} bytes per pixel"); + } + } + } + + #[test] + fn raw_packets_at_the_128_pixel_boundary() { + for bpp in 1..=4 { + for pixels in [127usize, 128, 129] { + let row: Vec = (0..pixels).flat_map(|x| vec![x as u8; bpp]).collect(); + let mut encoded = Vec::new(); + rle_row(&row, bpp, &mut encoded); + let mut expected = vec![(pixels.min(128) - 1) as u8]; + expected.extend_from_slice(&row[..pixels.min(128) * bpp]); + if pixels > 128 { + expected.push(0); + expected.extend_from_slice(&row[128 * bpp..]); + } + assert_eq!(encoded, expected, "{pixels} pixels, {bpp} bytes per pixel"); + } + } + } + + #[test] + fn raw_packets_stop_before_a_run_of_complete_pixels() { + // Equal first channels alone do not make an RGBA run; alpha participates too. + let a = [10, 20, 30, 40]; + let b = [10, 20, 30, 41]; + let c = [50, 60, 70, 80]; + let row = [a, b, c, c, a].concat(); + let mut encoded = Vec::new(); + rle_row(&row, 4, &mut encoded); + assert_eq!(encoded, [vec![1], a.to_vec(), b.to_vec(), vec![0x81], c.to_vec(), vec![0], a.to_vec()].concat()); + } + + #[test] + fn rejects_sample_types_and_layouts_targa_cannot_store() { + for sample in [SampleType::U16, SampleType::F16, SampleType::F32] { + let img = Image::new(1, 1, ChannelLayout::Rgb, sample).unwrap(); + assert!(matches!(encode(&img), Err(CodecError::Encode { format: Format::Tga, .. })), "{sample:?}"); + } + for layout in [ChannelLayout::Cmyk, ChannelLayout::CmykA, ChannelLayout::Lab, ChannelLayout::LabA] { + let img = Image::new(1, 1, layout, SampleType::U8).unwrap(); + assert!(matches!(encode(&img), Err(CodecError::Encode { format: Format::Tga, .. })), "{layout:?}"); + } + } + + #[test] + fn dimensions_fit_the_unsigned_16_bit_header() { + for (width, height) in [(65535, 1), (1, 65535)] { + let img = Image::new(width, height, ChannelLayout::Gray, SampleType::U8).unwrap(); + let file = encode(&img).unwrap(); + assert_eq!(&file[12..14], &(width as u16).to_le_bytes()); + assert_eq!(&file[14..16], &(height as u16).to_le_bytes()); + } + for (width, height) in [(65536, 1), (1, 65536)] { + let img = Image::new(width, height, ChannelLayout::Gray, SampleType::U8).unwrap(); + assert!(matches!(encode(&img), Err(CodecError::Encode { format: Format::Tga, .. })), "{width}x{height}"); + } + } + + #[test] + fn invalid_buffers_are_rejected_before_targa_encoding() { + // Image keeps its buffer private, so malformed buffers must fail at construction. + for layout in [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba] { + let len = 2 * 2 * layout.channels(); + for bytes in [len - 1, len + 1] { + assert!(matches!(Image::from_u8(2, 2, layout, vec![0; bytes]), Err(CodecError::InvalidImage(_))), "{layout:?}: {bytes} bytes"); + } + } + } +} diff --git a/crates/codecs/src/options.rs b/crates/codecs/src/options.rs index 258779d823..1f7b249722 100644 --- a/crates/codecs/src/options.rs +++ b/crates/codecs/src/options.rs @@ -103,18 +103,18 @@ pub enum PngCompression { /// How a PNG's `sTER` chunk pairs its two views (PNG Extensions 1.4.0, §3.6). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PngStereo { - /// Left and right views side by side, left first. - Horizontal, - /// Left and right views stacked, left on top. - Vertical, + /// Side by side with the right-eye view first (cross-eyed viewing, mode 0). + CrossFuse, + /// Side by side with the left-eye view first (parallel viewing, mode 1). + DivergingFuse, } impl PngStereo { /// The view for an `sTER` mode byte; reserved and unknown values are `None`. pub fn from_mode(mode: u8) -> Option { match mode { - 0 => Some(Self::Horizontal), - 1 => Some(Self::Vertical), + 0 => Some(Self::CrossFuse), + 1 => Some(Self::DivergingFuse), _ => None, } } @@ -122,8 +122,8 @@ impl PngStereo { /// The `sTER` mode byte. pub fn mode(self) -> u8 { match self { - Self::Horizontal => 0, - Self::Vertical => 1, + Self::CrossFuse => 0, + Self::DivergingFuse => 1, } } } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 268b0b6b43..8752bbd7bb 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -933,7 +933,8 @@ pub struct FileEncoding { #[serde(skip_serializing_if = "Option::is_none")] pub webp_quality: Option, /// PNG stereo pairing (the `sTER` chunk's mode byte, PNG Extensions 1.4.0 §3.6): `Some(0)` - /// side by side, `Some(1)` stacked, so saving a stereo PNG keeps the mark (#6063). + /// cross-fuse (right eye first), `Some(1)` diverging-fuse (left eye first). Both are + /// side by side; saving a stereo PNG keeps this eye-order mark (#6063). #[serde(skip_serializing_if = "Option::is_none")] pub png_stereo: Option, } diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 904c3e7dce..c5e9d464ce 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -276,11 +276,35 @@ pub(crate) fn join(dir: &str, name: &str) -> String { } } -/// A file-name-safe version of a layer name. +/// A file-name-safe version of a layer name. A Windows device name (`nul`, `con.v2`) gets a `_` +/// before its first dot, so the file or folder is usable on Windows, wherever it was written. pub(crate) fn sanitize(name: &str) -> String { let s: String = name.chars().map(|c| if c.is_alphanumeric() || matches!(c, '-' | '_' | ' ' | '.') { c } else { '_' }).collect(); let s = s.trim().trim_matches('.').to_string(); - if s.is_empty() { "layer".into() } else { s } + if s.is_empty() { "layer".into() } else { portable_device_name(s) } +} + +/// `name` with a `_` before its first dot when it is a Windows device name (`nul` → `nul_`, +/// `com1.v2` → `com1_.v2`); a `_` at the end would leave the device stem in place. +pub(crate) fn portable_device_name(name: String) -> String { + if !is_windows_device_name(&name) { + return name; + } + match name.split_once('.') { + Some((device, rest)) => format!("{device}_.{rest}"), + None => format!("{name}_"), + } +} + +/// Whether `component` names a Windows device (`CON`, `NUL`, `COM1`…, with any extension): +/// Windows opens the device instead of a file or folder of that name. `COM` and `LPT` take the +/// superscript digits ¹ ² ³ as well, as Windows does. +pub(crate) fn is_windows_device_name(component: &str) -> bool { + let stem = component.split('.').next().unwrap_or(component).trim_end_matches(' ').to_ascii_uppercase(); + let port = |n: &str| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³"); + matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$") + || stem.strip_prefix("COM").is_some_and(port) + || stem.strip_prefix("LPT").is_some_and(port) } pub(crate) fn import(name: &str, bytes: &[u8]) -> Result { @@ -762,7 +786,42 @@ fn xml_escape(s: &str) -> String { } fn xml_unescape(s: &str) -> String { - s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").replace("&", "&") + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(i) = rest.find('&') { + out.push_str(&rest[..i]); + rest = &rest[i..]; + let decoded = rest.as_bytes().iter().take(13).position(|&b| b == b';').and_then(|e| { + let c = match &rest[1..e] { + "lt" => '<', + "gt" => '>', + "quot" => '"', + "apos" => '\'', + "amp" => '&', + n => { + let n = n.strip_prefix('#')?; + let code = match n.strip_prefix(['x', 'X']) { + Some(h) => u32::from_str_radix(h, 16).ok()?, + None => n.parse().ok()?, + }; + char::from_u32(code)? + } + }; + Some((c, e + 1)) + }); + match decoded { + Some((c, len)) => { + out.push(c); + rest = &rest[len..]; + } + None => { + out.push('&'); + rest = &rest[1..]; + } + } + } + out.push_str(rest); + out } /// The element `…` (first occurrence) as (start, end, inner). @@ -863,6 +922,18 @@ fn short_num(v: f64, decimals: usize) -> String { if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s } } +/// `v` to three significant digits, with no trailing zeros (0.031714 → "0.0317", 0.4 → "0.4"). +/// Two decimals threw away most of a slow shutter speed: a stored 0.031714 s printed as "0.03 s", +/// which is 5 % off, and a stored 0.0215 s printed as "0.02 s", 7 % off (#6958). +fn sig3(v: f64) -> String { + let magnitude = v.abs().log10().floor(); + // Clamped at both ends: a value at the large end must not ask for a negative field width, and + // at the small end the field is widened rather than truncated, so a positive exposure never + // reads as "0 s". 320 is beyond f64's decimal range, so it always terminates. + let decimals = if magnitude.is_finite() { (2.0 - magnitude).clamp(0.0, 320.0) } else { 0.0 }; + short_num(v, decimals as usize) +} + /// Exposure time as cameras print it: "1/250 s" for fractions of a second, "0.4 s" or "2 s" /// when the reciprocal isn't a whole number or the exposure is a second or longer. fn exposure_text(t: f64) -> Option { @@ -870,10 +941,18 @@ fn exposure_text(t: f64) -> Option { return None; } let r = 1.0 / t; - if t < 1.0 && r.round() > 1.0 && (r - r.round()).abs() <= r * 0.01 { - return Some(format!("1/{} s", r.round())); + let n = r.round(); + // Below 1/10 s the reciprocal is above 10, so its nearest whole number puts the shown time + // within 5 % of the stored one, and is the form a photographer expects ("1/32 s"). The test + // used to be relative to the reciprocal, so it shrank as the exposure slowed: under 1/50 s it + // fell below half a rounding step and nearly every fraction fell through to the decimal form + // (#6958). Above 1/10 s the fraction is only right when the reciprocal is very nearly whole + // (1/2 s, 1/4 s) — 0.4 s stays "0.4 s" rather than becoming "1/3 s". + let fraction = n.is_finite() && (1.0..=1e9).contains(&n) && (t < 0.1 || (t < 1.0 && n > 1.0 && (r - n).abs() <= r * 0.01)); + if fraction { + return Some(format!("1/{} s", n as u64)); } - Some(format!("{} s", short_num(t, if t < 1.0 { 2 } else { 1 }))) + Some(format!("{} s", sig3(t))) } /// EXIF (`2024:02:17 12:40:19`) or XMP (`2024-02-17T12:40:19.00+01:00`) dates as diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index 53b0f1024f..2a52155ca2 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -648,6 +648,54 @@ fn camera_data_formats_like_photoshop_and_falls_back_to_xmp() { assert_eq!(camera_data(Some(b"II*\0\xff\xff\xff\xff"), Some("0/0 exif:ExposureTime=\"-1/0\"")), json!({})); } +/// A slow shutter speed whose reciprocal is not whole prints as the nearest fraction a camera +/// would write, not as two decimals. The old 1 %-of-the-reciprocal test shrank as the exposure +/// slowed, so below 1/50 s it fell below half a rounding step and nearly every such fraction fell +/// through to the decimal form (#6958). +#[test] +fn camera_data_prints_a_slow_exposure_as_the_fraction_a_camera_writes() { + let text = |t: f64| exposure_text(t).expect("an exposure"); + // The two values from the report: 31714/1000000 s and 215/10000 s. + assert_eq!(text(0.031_714), "1/32 s", "a stored 0.031714 s is about 1/32 s"); + assert_eq!(text(0.0215), "1/47 s", "a stored 0.0215 s is about 1/47 s"); + // The rest of a slow shutter scale, and the exact reciprocals that already worked. + assert_eq!(text(0.125), "1/8 s"); + assert_eq!(text(1.0 / 60.0), "1/60 s"); + assert_eq!(text(1.0 / 4000.0), "1/4000 s"); + assert_eq!(text(0.04), "1/25 s"); + assert_eq!(text(0.09), "1/11 s"); + // Every shown time stays within 5 % of the stored one — the point of the report. + for t in [0.031_714, 0.0215, 0.04, 0.09, 0.125, 1.0 / 60.0, 1.0 / 4000.0] { + let n: f64 = text(t).split('/').nth(1).and_then(|d| d.split(' ').next()).expect("1/n s").parse().expect("a number"); + assert!((1.0 / n / t - 1.0).abs() <= 0.05, "{t} shown as {} is more than 5 % off", text(t)); + } + // A tenth of a second and longer keeps the Photoshop forms: a near-whole reciprocal is a + // fraction, anything else is the decimal at three significant digits. + assert_eq!(text(0.5), "1/2 s"); + assert_eq!(text(0.25), "1/4 s"); + assert_eq!(text(0.4), "0.4 s"); + assert_eq!(text(0.105), "0.105 s", "two decimals would read 0.11 s"); + assert_eq!(text(1.25), "1.25 s", "one decimal would read 1.3 s"); + assert_eq!(text(2.0), "2 s"); + // A hostile value cannot panic, and a positive exposure never reads as "0 s" (never-crash, rule 9). + assert_ne!(exposure_text(f64::MIN_POSITIVE).as_deref(), Some("0 s")); + assert_ne!(exposure_text(f64::MIN_POSITIVE), None); + assert_eq!(exposure_text(0.0), None); + assert_eq!(exposure_text(-1.0), None); + assert_eq!(exposure_text(f64::NAN), None); + assert_eq!(exposure_text(f64::INFINITY), None); + assert_ne!(exposure_text(f64::MAX), None); + // The same through a real EXIF block, so the round trip to File Info is covered too. + let camera = |t: f64| { + let exif = photocraft_algo::exif::build(&photocraft_algo::exif::CameraInfo { exposure_time: Some(t), ..Default::default() }); + camera_data(Some(&exif), None)["exposure"].as_str().map(str::to_owned) + }; + assert_eq!(camera(0.031_714), Some("1/32 s".to_owned())); + assert_eq!(camera(0.0215), Some("1/47 s".to_owned())); + // An absurd exposure is capped to one dialog line, as every other camera field is. + assert!(camera(f64::MAX).expect("an exposure").len() <= 200, "a huge exposure must stay on one dialog line"); +} + const MULTILINGUAL_XMP: &str = r#" {}", sanitize(name)); + } + // Names that only start like a device are files like any other. + for name in ["console", "normal", "com10", "lpt0", "nullify", "con_", "com⁴", "com¹¹"] { + assert_eq!(sanitize(name), name); + } +} diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index b6ced69ee2..9bbc897149 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -301,7 +301,16 @@ pub(crate) fn resample_doc(doc: &mut Document, nw: u32, nh: u32, filter: Resampl *surf = resize_surface_in_canvas(surf, sx, sy, if is_mask { Resample::Bilinear } else { filter }, canvas); }); let k = ((sx + sy) / 2.0) as f32; - for_each_layer(&mut doc.layers, &mut |l| scale_effects(&mut l.effects, k)); + for_each_layer(&mut doc.layers, &mut |l| { + scale_effects(&mut l.effects, k); + // Canvas resampling scales the whole appearance, including shape strokes (#8341). + // Dash lengths and offsets are already expressed in multiples of this width. + if let LayerContent::Shape(sh) = &mut l.content + && let Some(stroke) = &mut sh.stroke + { + stroke.width *= k; + } + }); for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) { ch.surface = resize_surface_in_canvas(&ch.surface, sx, sy, Resample::Bilinear, canvas); } diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 1344971850..c38608224c 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -86,7 +86,7 @@ choice!(UiFontSize { Tiny = "tiny", Small = "small", Medium = "medium", Large = choice!(LogDestination { Metadata = "metadata", TextFile = "textFile", Both = "both" } default Metadata); choice!(LogDetail { SessionsOnly = "sessionsOnly", Concise = "concise", Detailed = "detailed" } default Concise); choice!(Ask { Always = "always", Never = "never", Ask = "ask" } default Always); -choice!(QuickExportFormat { Png = "png", Jpg = "jpg", Gif = "gif", Webp = "webp" } default Png); +choice!(QuickExportFormat { Png = "png", Jpg = "jpg", Gif = "gif", Webp = "webp", Exr = "exr" } default Png); choice!(ExportLocation { Ask = "ask", SameFolder = "sameFolder" } default Ask); choice!(ExportMetadata { None = "none", Copyright = "copyright", All = "all" } default Copyright); choice!( diff --git a/crates/engine/src/print_cmds/tests.rs b/crates/engine/src/print_cmds/tests.rs index 3dab10b65a..de9e145793 100644 --- a/crates/engine/src/print_cmds/tests.rs +++ b/crates/engine/src/print_cmds/tests.rs @@ -987,3 +987,16 @@ fn a_rejected_printable_area_fails_the_print_and_changes_nothing() { let r = s.execute("file.print", json!({"paper": "a3", "scaleToFit": true, "printable": [0, 0, 60, 1191.14], "cornerCropMarks": true, "dryRun": true})); assert!(r.is_err(), "{r:?}"); } + +#[test] +fn package_of_a_document_named_after_a_windows_device_is_written() { + // On Windows a folder named `nul` is the NUL device: the save failed with "path not found". + let dir = tmp("package-device-name"); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8, "name": "nul.psd"})).unwrap(); + let r = s.execute("file.package", json!({"dir": format!("{dir}/pkg")})).unwrap(); + assert_eq!(r["folder"].as_str().unwrap(), format!("{dir}/pkg/nul_")); + let document = r["document"].as_str().unwrap(); + assert!(document.ends_with("pkg/nul_/nul_.pcraft"), "{document}"); + assert!(std::path::Path::new(document).is_file()); +} diff --git a/crates/engine/src/web_cmds.rs b/crates/engine/src/web_cmds.rs index f77a020819..894cdca40b 100644 --- a/crates/engine/src/web_cmds.rs +++ b/crates/engine/src/web_cmds.rs @@ -19,7 +19,7 @@ use photocraft_geom::Rect; use serde_json::{Value, json}; use crate::commands::CommandSpec; -use crate::file_cmds::{join, native_doc, stem, write_file}; +use crate::file_cmds::{is_windows_device_name, join, native_doc, portable_device_name, stem, write_file}; use crate::{EngineError, Result, Session}; fn bad(cmd: &str, msg: impl Into) -> EngineError { @@ -732,7 +732,7 @@ fn slice_file(name: &str) -> String { /// Generated filenames should remain usable on Windows even when a document or slice is named /// after a reserved device. Caller-supplied path components are still rejected below. fn portable_generated_stem(stem: String) -> String { - if is_windows_device_asset_name(&stem) { format!("{stem}_") } else { stem } + portable_device_name(stem) } fn html_escape(s: &str) -> String { @@ -1088,6 +1088,15 @@ fn quick_export(s: &mut Session, p: &Value) -> Result { }; let d = s.active().ok_or(EngineError::NoDocument)?; let doc = d.doc.clone(); + if fmt == "exr" { + // EXR keeps floating-point scene values. The ordinary exporter converts to linear + // light; the web/sRGB pipeline would instead clamp HDR and negative values. + let opts = photocraft_io::ExportOptions { xmp: photocraft_io::XmpEmbed::None, ..Default::default() }; + let out = photocraft_io::export(&doc, "exr", &opts).map_err(other)?; + write_file(&path, &out.bytes)?; + crate::automate_cmds::fire_event(s, "export"); + return Ok(json!({"path": path, "format": fmt, "bytes": out.bytes.len(), "warnings": out.warnings})); + } let metadata = serde_json::to_value(prefs.metadata).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(); let mut o = serde_json::Map::new(); o.insert( @@ -1296,13 +1305,6 @@ fn asset_settings(spec: &AssetSpec) -> WebSettings { } } -fn is_windows_device_asset_name(component: &str) -> bool { - let stem = component.split('.').next().unwrap_or(component).trim_end_matches(' ').to_ascii_uppercase(); - matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$") - || stem.strip_prefix("COM").is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")) - || stem.strip_prefix("LPT").is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")) -} - /// Layer names and `default` folders are document data, so reject unsafe output components. fn asset_path_components(relative: &str) -> Result> { // Split both separator styles even on Unix, where a backslash would otherwise be a legal @@ -1316,7 +1318,7 @@ fn asset_path_components(relative: &str) -> Result> { || part.ends_with(' ') || part.contains(':') || part.contains('\0') - || is_windows_device_asset_name(part) + || is_windows_device_name(part) { return Err(other(format!("asset path {relative:?} has an unsafe component {part:?}"))); } @@ -1537,7 +1539,7 @@ pub fn specs() -> Vec { "Export Preferences…", &["File", "Export"], None, - r##"{"quickExportFormat":"png|jpg|gif|webp"?,"quickExportLocation":"ask|sameFolder"?,"jpegQuality":1..100?,"webpLossless":bool?,"webpQuality":1..100?,"metadata":"none|copyright|all"?,"convertToSrgb":bool?} → {values}"##, + r##"{"quickExportFormat":"png|jpg|gif|webp|exr"?,"quickExportLocation":"ask|sameFolder"?,"jpegQuality":1..100?,"webpLossless":bool?,"webpQuality":1..100?,"metadata":"none|copyright|all"?,"convertToSrgb":bool?} → {values}"##, |_| Ok(()), export_preferences ), diff --git a/crates/engine/src/web_cmds/tests.rs b/crates/engine/src/web_cmds/tests.rs index ea8fe40177..795c3c7b7e 100644 --- a/crates/engine/src/web_cmds/tests.rs +++ b/crates/engine/src/web_cmds/tests.rs @@ -167,6 +167,19 @@ fn reserved_document_and_slice_names_export_with_portable_names() { assert!(std::path::Path::new(result["html"].as_str().unwrap()).is_file()); } +#[test] +fn a_dotted_device_file_name_exports_without_the_device_stem() { + // `nul.v2_` would still be the NUL device on Windows; `nul_.v2` is a file. + let dir = tmp("reserved-dotted-name"); + let mut s = session(8); + s.execute("slice.new", json!({"rect": [0, 0, 16, 16], "name": "logo"})).unwrap(); + let path = format!("{dir}/nul.v2.png"); + let result = s.execute("file.export.saveForWebLegacy", json!({"path": path, "slices": "all", "format": "png24", "html": true})).unwrap(); + let html = result["html"].as_str().unwrap(); + assert!(html.ends_with("/nul_.v2.html"), "{html}"); + assert!(std::path::Path::new(html).is_file()); +} + #[test] fn platform_writer_failure_stops_export_and_preserves_saved_settings() { let mut s = session(8); @@ -995,3 +1008,38 @@ fn web_exif_that_does_not_fit_is_reported_and_bad_exif_is_harmless() { assert_eq!(decode(&path).dimensions(), (64, 48)); } } + +#[test] +fn quick_export_exr_preserves_linear_hdr_and_alpha() { + use photocraft_codecs::{ChannelLayout, Format, Image, SampleType}; + let dir = tmp("quick_exr_hdr"); + let samples = [-0.25, 2.0, 0.125, 1.0, 4.0, 0.25, 0.5, 0.5]; + let image = Image::from_f32(2, 1, ChannelLayout::Rgba, &samples).unwrap(); + let bytes = photocraft_codecs::encode(&image, Format::OpenExr, &Default::default()).unwrap(); + let doc = photocraft_io::import("hdr.exr", &bytes).unwrap().document; + let mut s = Session::new(); + s.add_document(doc, Some(format!("{dir}/source.psd"))); + for convert in [true, false] { + s.execute("file.export.exportPreferences", json!({"quickExportFormat": "exr", "quickExportLocation": "sameFolder", "convertToSrgb": convert})).unwrap(); + let result = s.execute("file.export.quickExport", json!({})).unwrap(); + assert_eq!(result["format"], "exr"); + let path = format!("{dir}/source.exr"); + assert_eq!(result["path"], path); + assert_eq!(result["bytes"].as_u64(), Some(std::fs::metadata(&path).unwrap().len())); + let decoded = decode(&path); + assert_eq!(decoded.sample_type(), SampleType::F32); + let actual = decoded.convert(ChannelLayout::Rgba, SampleType::F32).to_f32_samples().unwrap(); + for (got, expected) in actual.iter().zip(samples) { + assert!((got - expected).abs() < 0.0001, "EXR preserves scene values: {actual:?}"); + } + assert_eq!(actual.len(), samples.len()); + } + // The format preference must never silently overwrite the source EXR. + let own = format!("{dir}/source.exr"); + s.active_mut().unwrap().path = Some(own.clone()); + let before = std::fs::read(&own).unwrap(); + assert!(s.execute("file.export.quickExport", json!({})).is_err()); + assert_eq!(std::fs::read(&own).unwrap(), before); + s.execute("file.export.quickExport", json!({"path": format!("{dir}/explicit.exr")})).unwrap(); + assert_eq!(decode(&format!("{dir}/explicit.exr")).dimensions(), (2, 1)); +} diff --git a/crates/engine/tests/export_shape_strokes.rs b/crates/engine/tests/export_shape_strokes.rs new file mode 100644 index 0000000000..0875f4bfc0 --- /dev/null +++ b/crates/engine/tests/export_shape_strokes.rs @@ -0,0 +1,105 @@ +//! Regression coverage for #8341: canvas resizing must scale shape strokes too. +use photocraft_doc::{LayerContent, LayerId}; +use photocraft_engine::{ + Session, + web_cmds::{WebSettings, web_document}, +}; +use serde_json::json; + +fn shape_session(depth: u32) -> (Session, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 1024, "height": 1024, "depth": depth, "background": "transparent"})).unwrap(); + let id = s.execute("shape.create", json!({"rect": [256, 256, 512, 512], "fill": null, "stroke": {"width": 32, "color": "#000000"}})).unwrap()["layer"] + .as_u64() + .unwrap(); + (s, LayerId(id)) +} + +#[test] +fn web_downscale_keeps_stroke_only_shape_proportions_at_every_depth() { + for depth in [8, 16, 32] { + let (s, id) = shape_session(depth); + let original = &s.active().unwrap().doc; + for params in [json!({"percent": 25}), json!({"width": 256, "height": 256})] { + let (scaled, sx, sy) = web_document(original, ¶ms, &WebSettings::default()).unwrap(); + assert_eq!((scaled.size.width, scaled.size.height), (256, 256)); + assert_eq!((sx, sy), (0.25, 0.25)); + let LayerContent::Shape(sh) = &scaled.layer(id).unwrap().content else { + panic!("shape lost"); + }; + assert_eq!(sh.stroke.as_ref().unwrap().width, 8.0, "{depth}-bit stroke"); + let pixels = photocraft_compose::flatten(&scaled); + let alpha = |x: usize| pixels.px[128 * 256 + x][3]; + assert!(alpha(62) > 0.99, "scaled stroke is visible"); + assert!(alpha(52) < 0.01, "stroke must not retain its original width"); + assert!(alpha(128) < 0.01, "no fill is introduced"); + } + let LayerContent::Shape(sh) = &original.layer(id).unwrap().content else { + panic!("shape lost"); + }; + assert_eq!(sh.stroke.as_ref().unwrap().width, 32.0, "export leaves the source untouched"); + } +} + +#[test] +fn image_size_scales_strokes_once_and_undo_restores_them() { + let (mut s, id) = shape_session(8); + s.execute("shape.edit", json!({"layer": id.0, "stroke": {"dashes": [2, 1], "dashOffset": 0.5}})).unwrap(); + let before = s.active().unwrap().doc.layer(id).unwrap().clone(); + s.execute("image.imageSize", json!({"width": 512, "height": 512})).unwrap(); + let LayerContent::Shape(sh) = &s.active().unwrap().doc.layer(id).unwrap().content else { + panic!("shape lost"); + }; + assert_eq!(sh.stroke.as_ref().unwrap().width, 16.0); + assert_eq!(sh.stroke.as_ref().unwrap().dashes, vec![2.0, 1.0], "dash lengths stay in stroke-width units"); + assert_eq!(sh.stroke.as_ref().unwrap().dash_offset, 0.5); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.layer(id).unwrap(), &before); + s.execute("edit.redo", json!({})).unwrap(); + let LayerContent::Shape(sh) = &s.active().unwrap().doc.layer(id).unwrap().content else { + panic!("shape lost"); + }; + assert_eq!(sh.stroke.as_ref().unwrap().width, 16.0); +} + +#[test] +fn grouped_strokes_scale_up_and_unchanged_size_is_a_noop() { + let (mut s, id) = shape_session(8); + s.edit("Group", |doc, _| { + let children = std::mem::take(&mut doc.layers); + doc.layers.push(photocraft_doc::Layer::group("Outer", vec![photocraft_doc::Layer::group("Inner", children)])); + Ok(()) + }) + .unwrap(); + let original = &s.active().unwrap().doc; + for (width, expected) in [(1024, 32.0), (1280, 40.0), (256, 8.0)] { + let (scaled, _, _) = web_document(original, &json!({"width": width}), &WebSettings::default()).unwrap(); + let LayerContent::Shape(sh) = &scaled.layer(id).unwrap().content else { + panic!("shape lost"); + }; + assert_eq!(sh.stroke.as_ref().unwrap().width, expected); + } +} + +#[test] +fn png_export_contains_the_scaled_stroke_and_keeps_the_source_document() { + let (mut s, _) = shape_session(8); + let before = s.active().unwrap().doc.clone(); + let mut bytes = Vec::new(); + photocraft_engine::web_cmds::save_for_web_with_writer( + &mut s, + &json!({"format": "png24", "path": "stroke.png", "width": 256, "height": 256}), + &mut |_, encoded| { + bytes = encoded.to_vec(); + Ok(()) + }, + ) + .unwrap(); + let image = photocraft_codecs::decode(&bytes).unwrap().convert(photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8); + assert_eq!(image.dimensions(), (256, 256)); + let alpha = |x: usize| image.data()[(128 * 256 + x) * 4 + 3]; + assert_eq!(alpha(62), 255); + assert_eq!(alpha(52), 0); + assert_eq!(alpha(128), 0); + assert_eq!(s.active().unwrap().doc, before); +} diff --git a/crates/engine/tests/xmp_charref.rs b/crates/engine/tests/xmp_charref.rs new file mode 100644 index 0000000000..a4b98da4e5 --- /dev/null +++ b/crates/engine/tests/xmp_charref.rs @@ -0,0 +1,24 @@ +use photocraft_engine::file_cmds::read_file_info; + +#[test] +fn numeric_character_references_are_decoded() { + let xmp = "\ + Line one Line two © & co A &#38;\ + "; + let info = read_file_info(Some(xmp)); + assert_eq!(info["description"], "Line one\nLine two © & co A &"); +} + +/// Many `&` with no `;` near them stay literal, and are scanned a bounded distance each (a +/// multi-megabyte XMP once made this quadratic). +#[test] +fn a_long_run_of_ampersands_without_semicolons_is_left_unchanged() { + let run = "&".repeat(200_000); + let xmp = format!( + "\ + {run}\ + " + ); + let info = read_file_info(Some(&xmp)); + assert_eq!(info["description"], run.as_str()); +} diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index 984dcdbc57..ac1bd27ffa 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -844,6 +844,64 @@ mod tests { assert!(r.warnings.iter().all(|w| !w.contains("font")), "{:?}", r.warnings); } + /// #4806: text placed by a `transform` (Illustrator writes `matrix(1 0 0 1 x y)` on every text + /// element), on the text or on a group around it, lands where resvg draws it. usvg leaves the + /// glyph outlines' absolute transforms at identity, so reading them dropped the transform: the + /// text went off the canvas or kept its untransformed place and size. + /// + /// Shaped with the bundled Inter alone, never a system font: on a machine without fonts (a + /// bare CI image or VM) the test would otherwise have nothing to check, and the drawing and + /// its reference come from the same parse, so both use that face. + #[test] + fn transformed_text_lands_where_resvg_draws_it() { + fn inter() -> Arc { + let mut db = usvg::fontdb::Database::new(); + db.load_font_data(photocraft_text::fonts::INTER_REGULAR.to_vec()); + Arc::new(db) + } + assert!(inter().faces().next().is_some(), "the bundled Inter loads"); + let font = r#"font-size="24" font-family="Inter" fill="black""#; + let cases = [ + format!(r#"IHI"#), + format!(r#"IHI"#), + format!(r#"IHI"#), + format!(r#"IHI"#), + format!(r#"IHI"#), + format!(r#"IHI"#), + ]; + for c in cases { + let svg = format!(r#"{c}"#); + let parsed = parse_with(svg.as_bytes(), inter).unwrap(); + assert!(!parsed.fontless_text, "{c}"); + let mut reference = tiny_skia::Pixmap::new(120, 60).unwrap(); + resvg::render(&parsed.tree, tiny_skia::Transform::default(), &mut reference.as_mut()); + let r = import_parsed("t", parsed, MAX_GROUP_DEPTH).unwrap(); + let [layer] = r.document.layers.as_slice() else { panic!("one text layer, got {}: {c}", r.document.layers.len()) }; + assert!(matches!(layer.content, LayerContent::Group(_)), "text stays outline shapes: {c}"); + let imported = photocraft_compose::flatten(&r.document); + let (mut both, mut either) = (0usize, 0usize); + let (mut a_box, mut b_box) = ([i32::MAX, i32::MAX, i32::MIN, i32::MIN], [i32::MAX, i32::MAX, i32::MIN, i32::MIN]); + let grow = |bx: &mut [i32; 4], x: i32, y: i32| *bx = [bx[0].min(x), bx[1].min(y), bx[2].max(x), bx[3].max(y)]; + for (i, px) in reference.pixels().iter().enumerate() { + let (x, y) = ((i % 120) as i32, (i / 120) as i32); + let (a, b) = (imported.get(x, y)[3] > 0.5, px.alpha() > 127); + if a { + grow(&mut a_box, x, y); + } + if b { + grow(&mut b_box, x, y); + } + both += usize::from(a && b); + either += usize::from(a || b); + } + assert!(either > 50, "resvg draws the text on the canvas: {c}"); + // Same place and size to the pixel; the outlines are filled by a different rasteriser, + // so the edges of thin stems may differ by half a pixel. + assert!(a_box.iter().zip(b_box).all(|(a, b)| (a - b).abs() <= 1), "imported {a_box:?}, resvg {b_box:?}: {c}"); + assert!(both * 10 >= either * 7, "imported glyphs cover what resvg draws ({both} of {either} px): {c}"); + } + } + /// #4266: a group clipped by one path (the PhotoCraft icon's rounded square) stays editable /// shapes with the clip as its vector mask, and looks as resvg draws the drawing; a clip of /// two shapes is still rasterised, with the warning. diff --git a/crates/io/tests/flat.rs b/crates/io/tests/flat.rs index b5cdadaac2..98833d45d5 100644 --- a/crates/io/tests/flat.rs +++ b/crates/io/tests/flat.rs @@ -1176,7 +1176,7 @@ fn a_lab_document_with_one_layer_is_not_reported_as_flattened() { fn a_stereo_png_keeps_its_stereo_mark_on_save() { use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, PngStereo, SampleType as CS}; let img = Image::from_raw(4, 2, ChannelLayout::Rgb, CS::U8, vec![40u8; 24]).unwrap(); - for view in [PngStereo::Horizontal, PngStereo::Vertical] { + for view in [PngStereo::CrossFuse, PngStereo::DivergingFuse] { let bytes = photocraft_codecs::encode(&img, Format::Png, &EncodeOptions { png_stereo: Some(view), ..Default::default() }).unwrap(); let doc = import("stereo.png", &bytes).unwrap().document; assert_eq!(doc.file_encoding.png_stereo, Some(view.mode())); diff --git a/crates/io/tests/svg.rs b/crates/io/tests/svg.rs index 4f21bd5dcb..d1baf418fa 100644 --- a/crates/io/tests/svg.rs +++ b/crates/io/tests/svg.rs @@ -221,57 +221,6 @@ fn text_becomes_outline_shapes_or_warns_without_a_font() { assert!(dark > 4, "glyph strokes cross the middle row: {dark}"); } -/// #4806: text placed by a `transform` (Illustrator writes `matrix(1 0 0 1 x y)` on every text -/// element), on the text or on a group around it, lands where resvg draws it. usvg leaves the -/// glyph outlines' absolute transforms at identity, so reading them dropped the transform: the -/// text went off the canvas or kept its untransformed place and size. -#[test] -fn transformed_text_lands_where_resvg_draws_it() { - let font = "font-size=\"24\" font-family=\"DejaVu Sans, Arial, Helvetica, sans-serif\" fill=\"black\""; - let cases = [ - format!("IHI"), - format!("IHI"), - format!("IHI"), - format!("IHI"), - format!("IHI"), - format!("IHI"), - ]; - for c in cases { - let svg = format!("{c}"); - let r = open(&svg); - let d = &r.document; - if d.layers.is_empty() { - // No font at all on this machine: the import says so instead. - assert!(r.warnings.iter().any(|w| w.contains("font")), "{:?}", r.warnings); - return; - } - assert!(matches!(d.layers[0].content, LayerContent::Group(_)), "text stays outline shapes: {c}"); - let imported = photocraft_compose::flatten(d); - let reference = svg::rasterize(svg.as_bytes(), 1.0).unwrap(); - let (mut both, mut either) = (0usize, 0usize); - let (mut a_box, mut b_box) = ([i32::MAX, i32::MAX, i32::MIN, i32::MIN], [i32::MAX, i32::MAX, i32::MIN, i32::MIN]); - let grow = |bx: &mut [i32; 4], x: i32, y: i32| *bx = [bx[0].min(x), bx[1].min(y), bx[2].max(x), bx[3].max(y)]; - for y in 0..60 { - for x in 0..120 { - let (a, b) = (imported.get(x, y)[3] > 0.5, reference.get(x, y)[3] > 0.5); - if a { - grow(&mut a_box, x, y); - } - if b { - grow(&mut b_box, x, y); - } - both += usize::from(a && b); - either += usize::from(a || b); - } - } - assert!(either > 50, "resvg draws the text on the canvas: {c}"); - // Same place and size to the pixel; the outlines are filled by a different rasteriser, so - // the edges of thin stems may differ by half a pixel. - assert!(a_box.iter().zip(b_box).all(|(a, b)| (a - b).abs() <= 1), "imported {a_box:?}, resvg {b_box:?}: {c}"); - assert!(both * 10 >= either * 7, "imported glyphs cover what resvg draws ({both} of {either} px): {c}"); - } -} - #[test] fn size_comes_from_the_viewbox_and_huge_drawings_shrink_to_fit() { let r = open(&format!("")); diff --git a/crates/ui-egui/src/export_dialog.rs b/crates/ui-egui/src/export_dialog.rs index 5dc318228a..29ee74b1aa 100644 --- a/crates/ui-egui/src/export_dialog.rs +++ b/crates/ui-egui/src/export_dialog.rs @@ -129,10 +129,19 @@ pub fn open_layer(app: &mut PhotocraftApp, layer: photocraft_doc::LayerId) -> Re let ldoc = photocraft_engine::layer_menu_cmds::layer_document(&st.doc, layer).map_err(|e| e.to_string())?; let id = open(app)?; if let Some(d) = app.ui.dialogs.iter_mut().find(|d| d.id == id) { - d.fields.insert("__layer".into(), json!(layer.0)); - d.fields.insert("__label".into(), json!(crate::i18n::fmt(crate::i18n::t("Export As: {name}"), &[("name", &ldoc.name)]))); - d.fields.insert("__w".into(), json!(ldoc.size.width)); - d.fields.insert("__h".into(), json!(ldoc.size.height)); + let f = &mut d.fields; + f.insert("__layer".into(), json!(layer.0)); + f.insert("__label".into(), json!(crate::i18n::fmt(crate::i18n::t("Export As: {name}"), &[("name", &ldoc.name)]))); + f.insert("__w".into(), json!(ldoc.size.width)); + f.insert("__h".into(), json!(ldoc.size.height)); + // `open` seeded width and height from the document; in scale mode they follow the layer's + // trimmed size, so the dialog shows and edits the size the layer exports at. Exact pixels + // (sizing "pixels") are a size the user asked for, so those stay. + if s(f, "sizing") != "pixels" + && let Ok(size) = resize_from_scale(f64::from(ldoc.size.width), f64::from(ldoc.size.height), n(f, "scale", 100.0)) + { + write_size(f, size, "scale"); + } } Ok(id) } @@ -565,6 +574,74 @@ fn estimate_native(doc: &Document, proxy: &Document, f: &Map) -> Some(scaled_estimate(bytes.len(), doc, proxy, f)) } +/// Exports up to this many pixels get an exact size: the full image encoded as Export will +/// write it, as Photoshop's Export As reports (#6595). Larger ones keep the proxy estimate, which +/// reads low for detailed images (a downscaled proxy compresses better). +#[cfg(not(target_arch = "wasm32"))] +const EXACT_ESTIMATE_PX: f64 = 40_000_000.0; + +/// The exported file's size in bytes, from encoding the full export, or `None` when it is too +/// large to encode for an estimate or can't be encoded. +#[cfg(not(target_arch = "wasm32"))] +fn exact_estimate(doc: &Document, f: &Map) -> Option { + // The size `export_document` will make: exact pixels only in pixel mode, else the scale. + let (w, h) = export_pixels(f64::from(doc.size.width), f64::from(doc.size.height), f, None).ok()?; + let px = w * h; + if !px.is_finite() || px > EXACT_ESTIMATE_PX { + return None; + } + let full = export_document(doc, f, None).ok()?; + let bytes = photocraft_io::export(&full, &format!("estimate.{}", s_fmt(f)), &settings(f).io_options()).ok()?.bytes; + u64::try_from(bytes.len()).ok() +} + +/// The exact size for `signature`, worked out off the UI thread: `None` until it is ready (the +/// proxy estimate shows meanwhile), then `Some(None)` when there is no exact size. +#[cfg(not(target_arch = "wasm32"))] +fn poll_or_start_exact_estimate(ui: &mut egui::Ui, key: egui::Id, signature: &str, doc: Arc, fields: &Map) -> Option> { + type Done = Arc>>>; + let done_key = key.with("exact-estimate"); + if let Some((sig, size)) = ui.data(|d| d.get_temp::<(String, Option)>(done_key)) + && sig == signature + { + return Some(size); + } + let pending_key = key.with("exact-estimate-pending"); + if let Some((sig, result)) = ui.data(|d| d.get_temp::<(String, Done)>(pending_key)) { + if sig == signature { + let size = result.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take()?; + ui.data_mut(|d| { + d.remove::<(String, Done)>(pending_key); + d.insert_temp(done_key, (sig, size)); + }); + return Some(size); + } + // Settings changed meanwhile: that worker's answer is for other settings. + if result.lock().unwrap_or_else(std::sync::PoisonError::into_inner).is_none() { + return None; + } + } + let result: Done = Arc::new(Mutex::new(None)); + let shared = result.clone(); + let ctx = ui.ctx().clone(); + let fields = fields.clone(); + let started = std::thread::Builder::new().name("export-estimate".into()).spawn(move || { + let size = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let source = source_document_from(doc.clone(), &fields).unwrap_or(doc); + exact_estimate(&source, &fields) + })) + .unwrap_or(None); + *shared.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(size); + ctx.request_repaint(); + }); + if started.is_ok() { + ui.data_mut(|d| d.insert_temp(pending_key, (signature.to_string(), result))); + None + } else { + Some(None) + } +} + fn write_size(f: &mut Map, size: ExportSize, sizing: &str) { f.insert("width".into(), json!(size.width)); f.insert("height".into(), json!(size.height)); @@ -884,7 +961,7 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = None; + let est = + exact.or_else(|| preview.as_ref().and_then(|preview| preview.estimate)).map_or("—".to_string(), |b| crate::sizing::human_bytes(b as f64)); ui.label(egui::RichText::new(format!("{} ≈ {est}", s_fmt(f).to_uppercase())).color(t.text_dim).size(11.5)); }); }); @@ -1113,6 +1195,33 @@ mod tests { } } + /// Layer › Export As opens with the layer's trimmed size, not the document's: `open` seeds the + /// size fields from the document, and the layer replaces them in scale mode. A remembered + /// percent scales the layer; remembered exact pixels stay the size the user asked for. + #[test] + fn layer_export_as_opens_with_the_layer_size() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 40, "height": 30})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("select.rect", json!({"x": 4, "y": 2, "width": 10, "height": 6})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + let layer = app.session.active().unwrap().active_layer.unwrap(); + let id = open_layer(&mut app, layer).unwrap(); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!((f["__w"].as_f64(), f["__h"].as_f64()), (Some(10.0), Some(6.0))); + assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(10.0), Some(6.0))); + + app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), json!({"scale": 50}))); + let id = open_layer(&mut app, layer).unwrap(); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(5.0), Some(3.0))); + + app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), json!({"sizing": "pixels", "width": 120, "height": 80}))); + let id = open_layer(&mut app, layer).unwrap(); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(120.0), Some(80.0))); + } + /// Only the dialog's own settings are remembered: not the destination, the document size or /// the layer the dialog was opened with. #[test] @@ -1284,6 +1393,116 @@ mod tests { } } + /// #6595: Export As shows the size the file will have, as Photoshop does. The proxy estimate + /// read 36–81 % of it for a detailed image; up to `EXACT_ESTIMATE_PX` the full export is + /// encoded off the UI thread instead. + #[test] + fn the_estimate_is_the_exported_files_size() { + let mut doc = Document::with_background( + "x", + photocraft_doc::Size::new(900, 300), + photocraft_doc::ColorMode::Rgb, + photocraft_doc::SampleType::U8, + photocraft_doc::Color::WHITE, + ); + let id = doc.layers[0].id; + let surf = doc.layer_mut(id).unwrap().surface_mut().unwrap(); + let mut seed = 12345u32; + for y in (0..300).step_by(2) { + for x in (0..900).step_by(2) { + seed = seed.wrapping_mul(1664525).wrapping_add(1013904223); + let v = (seed >> 24) as f32 / 255.0; + surf.fill_rect(photocraft_geom::Rect::new(x, y, x + 2, y + 2), &[v, x as f32 / 900.0, y as f32 / 300.0, 1.0]); + } + } + let doc = Arc::new(doc); + let f = Map::from_iter([("format".into(), json!("jpg")), ("scale".into(), json!(100)), ("quality".into(), json!(32))]); + let actual = photocraft_io::export(&export_document(&doc, &f, None).unwrap(), "x.jpg", &settings(&f).io_options()).unwrap().bytes.len() as u64; + let proxy = preview_proxy(&doc, &f).unwrap(); + let old = estimate_native(&doc, &proxy, &f).unwrap(); + assert!((old as f64 / actual as f64 - 1.0).abs() > 0.1, "the proxy estimate was off: {old} vs {actual}"); + + let ctx = egui::Context::default(); + let key = egui::Id::new("exact-estimate-test"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + let size = loop { + let mut size = None; + let mut output = ctx.run_ui(Default::default(), |ctx| { + egui::CentralPanel::default().show(ctx, |ui| size = poll_or_start_exact_estimate(ui, key, "jpg32", doc.clone(), &f)); + }); + output.textures_delta.clear(); + if let Some(size) = size { + break size; + } + assert!(std::time::Instant::now() < deadline, "the exact estimate did not arrive"); + std::thread::sleep(std::time::Duration::from_millis(10)); + }; + assert_eq!(size, Some(actual)); + // Over the limit there is no exact size; the proxy estimate stays. + let huge = Map::from_iter([ + ("format".into(), json!("jpg")), + ("sizing".into(), json!("pixels")), + ("width".into(), json!(10_000)), + ("height".into(), json!(10_000)), + ]); + assert_eq!(exact_estimate(&doc, &huge), None); + // In scale mode the size comes from the scale, as the export's does, not from stale pixels. + let big = Arc::new(Document::with_background( + "big", + photocraft_doc::Size::new(1000, 1000), + photocraft_doc::ColorMode::Rgb, + photocraft_doc::SampleType::U8, + photocraft_doc::Color::WHITE, + )); + let stale = Map::from_iter([ + ("format".into(), json!("png")), + ("sizing".into(), json!("scale")), + ("scale".into(), json!(1000)), + ("width".into(), json!(100)), + ("height".into(), json!(100)), + ]); + assert_eq!(exact_estimate(&big, &stale), None, "10000 × 10000 at 1000 %, over the limit"); + let small = Map::from_iter([ + ("format".into(), json!("png")), + ("sizing".into(), json!("scale")), + ("scale".into(), json!(10)), + ("width".into(), json!(9000)), + ("height".into(), json!(9000)), + ]); + let at_10 = photocraft_io::export(&export_document(&big, &small, None).unwrap(), "x.png", &settings(&small).io_options()).unwrap().bytes.len() as u64; + assert_eq!(exact_estimate(&big, &small), Some(at_10), "100 × 100 at 10 %, within it"); + } + + /// #8341: Export As and its reduced preview carry the stroke width with the path. + #[test] + fn export_as_downscales_stroke_only_shapes_and_preview() { + for depth in [8, 16, 32] { + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width": 1024, "height": 1024, "depth": depth, "background": "transparent"})).unwrap(); + let id = session.execute("shape.create", json!({"rect": [256, 256, 512, 512], "fill": null, "stroke": {"width": 32}})).unwrap()["layer"] + .as_u64() + .unwrap(); + let doc = session.active().unwrap().doc.clone(); + let fields = Map::from_iter([("format".into(), json!("png")), ("scale".into(), json!(25))]); + for limit in [None, Some(128)] { + let out = export_document(&doc, &fields, limit).unwrap(); + let side = if limit.is_some() { 128 } else { 256 }; + assert_eq!((out.size.width, out.size.height), (side, side)); + let shape = out.layer(photocraft_doc::LayerId(id)).unwrap(); + let photocraft_doc::LayerContent::Shape(shape) = &shape.content else { + panic!("shape lost"); + }; + assert_eq!(shape.stroke.as_ref().unwrap().width, side as f32 / 32.0); + let pixels = photocraft_compose::flatten(&out); + let row = (side / 2 * side) as usize; + assert!(pixels.px[row + (side / 4) as usize][3] > 0.99); + assert!(pixels.px[row + (side / 4 - side / 32) as usize][3] < 0.01); + assert!(pixels.px[row + (side / 2) as usize][3] < 0.01); + } + assert_eq!(session.active().unwrap().doc, doc, "export leaves the source unchanged"); + } + } + #[test] fn export_document_scales_and_flattens() { let doc = Document::with_background( diff --git a/crates/ui-egui/src/file_dialog.rs b/crates/ui-egui/src/file_dialog.rs index c7ac09dbc7..bddc6796d3 100644 --- a/crates/ui-egui/src/file_dialog.rs +++ b/crates/ui-egui/src/file_dialog.rs @@ -46,8 +46,9 @@ pub enum FileDialogAnswer { SaveTo(String), /// The selected folder's path (desktop). FolderTo(String), - /// No dialog could be shown (on Linux: no desktop portal and no zenity, #3354); the message - /// says what to install. Reported as an error rather than taken for Cancel. + /// No dialog could be shown (on Linux: no desktop portal and no zenity, #3354; the message + /// says what to install), or the file chosen can't be used (a name that isn't valid Unicode, + /// #6601). Reported as an error rather than taken for Cancel. Unavailable(String), } diff --git a/crates/ui-egui/src/file_dialog/tests.rs b/crates/ui-egui/src/file_dialog/tests.rs index b7df1d9fde..62eab8b49e 100644 --- a/crates/ui-egui/src/file_dialog/tests.rs +++ b/crates/ui-egui/src/file_dialog/tests.rs @@ -1070,6 +1070,45 @@ fn same_folder_quick_export_asks_before_replacing_the_documents_own_file() { std::fs::remove_dir_all(&dir).ok(); } +/// #6597: once Save As or Save a Copy writes over the file another open document is bound to, +/// that document no longer has the file and reads as unsaved (so closing it asks, and its Save +/// asks for a name), and a notice says so. Documents bound to other files are left alone. +#[test] +fn saving_over_the_file_of_another_open_document_detaches_it() { + let dir = std::env::temp_dir().join(format!("photocraft-save-over-tab-{}", std::process::id())); + let (a, d) = (dir.join("a.psd").to_string_lossy().into_owned(), dir.join("d.psd").to_string_lossy().into_owned()); + let (mut app, open, written) = app(); + let saved_as = |app: &mut PhotocraftApp, path: &str| { + let st = app.session.active_mut().unwrap(); + st.path = Some(path.to_string()); + st.saved_revision = st.revision; + }; + // A lives in a.psd and D in d.psd, both saved. + saved_as(&mut app, &a); + app.run("file.new", json!({"width": 4, "height": 4})).unwrap(); + saved_as(&mut app, &d); + // B, in another tab, is saved as a.psd. + app.run("file.new", json!({"width": 4, "height": 4})).unwrap(); + app.save_as(Some(a.clone())).unwrap(); + let docs = app.session.documents(); + assert_eq!((docs[0].path.as_deref(), docs[0].is_dirty()), (None, true), "A lost a.psd and is unsaved"); + assert_eq!((docs[1].path.as_deref(), docs[1].is_dirty()), (Some(d.as_str()), false), "D is untouched"); + assert_eq!(docs[2].path.as_deref(), Some(a.as_str()), "B lives in a.psd"); + let notice = app.ui.notices.last().expect("a notice"); + assert!(notice.title.contains("a.psd was saved over") && notice.lines.len() == 1, "{} {:?}", notice.title, notice.lines); + // C's Save a Copy onto a.psd detaches B the same way, and C doesn't take the file. + app.run("file.new", json!({"width": 4, "height": 4})).unwrap(); + let ctx = egui::Context::default(); + menus::invoke(&mut app, &ctx, "file.saveACopy", json!({})).unwrap(); + app.poll_file_dialog(&ctx, None); + answer(&open, Some(FileDialogAnswer::SaveTo(a.clone()))); + app.poll_file_dialog(&ctx, None); + let docs = app.session.documents(); + assert_eq!((docs[2].path.as_deref(), docs[2].is_dirty()), (None, true), "B lost a.psd and is unsaved"); + assert_eq!(docs[3].path, None, "a copy doesn't bind C to a.psd"); + assert_eq!(*written.borrow(), [a.clone(), a]); +} + #[test] fn cancelling_multi_layer_quick_export_writes_nothing() { let (mut app, open, written) = app(); diff --git a/crates/ui-egui/src/file_open.rs b/crates/ui-egui/src/file_open.rs index 1cf61e98d3..19d8819e33 100644 --- a/crates/ui-egui/src/file_open.rs +++ b/crates/ui-egui/src/file_open.rs @@ -68,6 +68,21 @@ fn dropped_name(f: &egui::DroppedFileHandle) -> String { f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into()) } +/// `path` as the `String` file paths are carried in, or what to tell the user when its name isn't +/// valid Unicode (#6601): non-UTF-8 bytes on Linux, an unpaired surrogate on Windows. A lossy copy +/// would name another file, so Open would fail with "No such file" and Save would write a second, +/// renamed file beside the original. `saving`: the path is to be written to. +pub fn path_text(path: &std::path::Path, saving: bool) -> Result { + path.to_str().map(str::to_owned).ok_or_else(|| { + let line = if saving { + tl!("{path}: the path is not valid Unicode; save under another name.") + } else { + tl!("{path}: the path is not valid Unicode; rename the file and open it again.") + }; + crate::i18n::fmt(line, &[("path", &path.to_string_lossy())]) + }) +} + /// The file name of `path` (the document's display name), or `path` itself when it has none. pub fn display_name(path: &str) -> String { std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| path.to_string()) @@ -270,7 +285,7 @@ impl PhotocraftApp { // Opens append: a background open's tab, or a document. let (docs, opens) = (self.session.documents().len(), self.jobs.opens.len()); if f.path().is_absolute() { - self.open_path(&f.path().to_string_lossy())?; + self.open_path(&path_text(f.path(), false)?)?; } else { self.open_bytes(name, &crate::read_dropped(&**f)?)?; } @@ -289,7 +304,7 @@ impl PhotocraftApp { let Some(next) = self.drop_places.pop_front() else { return }; let (name, placed) = match next { DropPlace::File(f) if f.path().is_absolute() => { - (dropped_name(&f), self.run("file.placeEmbedded", serde_json::json!({"path": f.path().to_string_lossy()}))) + (dropped_name(&f), path_text(f.path(), false).and_then(|path| self.run("file.placeEmbedded", serde_json::json!({"path": path})))) } DropPlace::File(f) => { let name = dropped_name(&f); diff --git a/crates/ui-egui/src/file_open/tests.rs b/crates/ui-egui/src/file_open/tests.rs index 93b8db2906..84db932180 100644 --- a/crates/ui-egui/src/file_open/tests.rs +++ b/crates/ui-egui/src/file_open/tests.rs @@ -1002,6 +1002,37 @@ fn opening_type_in_a_missing_font_warns() { assert_eq!(app.open_bytes("warned.psd", b"x").unwrap(), vec![warning]); } +/// A file name that isn't valid Unicode on this platform: non-UTF-8 bytes on Unix, an unpaired +/// surrogate on Windows. +fn non_unicode_path() -> std::path::PathBuf { + #[cfg(unix)] + let name = { + use std::os::unix::ffi::OsStrExt; + std::ffi::OsStr::from_bytes(b"img\xFF.png").to_owned() + }; + #[cfg(windows)] + let name = { + use std::os::windows::ffi::OsStringExt; + let mut wide: Vec = "img".encode_utf16().collect(); + wide.push(0xD800); + wide.extend(".png".encode_utf16()); + std::ffi::OsString::from_wide(&wide) + }; + std::env::temp_dir().join(name) +} + +/// #6601: a lossy copy of such a name is another file's name, so it is refused instead. +#[test] +fn a_path_that_is_not_valid_unicode_is_refused_not_rewritten() { + let bad = non_unicode_path(); + let open = path_text(&bad, false).unwrap_err(); + assert!(open.contains("not valid Unicode") && open.contains("open it again"), "{open}"); + let save = path_text(&bad, true).unwrap_err(); + assert!(save.contains("not valid Unicode") && save.contains("another name"), "{save}"); + let good = std::env::temp_dir().join("img é.png"); + assert_eq!(path_text(&good, true).as_deref(), Ok(good.to_str().unwrap())); +} + /// #6598: opening a file that is already open shows its document, as Photoshop does, rather than /// a second copy bound to the same file (saving one would overwrite the other). The path is /// compared as the file it names: another spelling of it, a symlink to it or, with background diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index f3b756ed5e..fe282c85db 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3903,6 +3903,7 @@ navigator Zoom Zvětšení This document's layers: {needed} of {budget} for layers Vrstvy tohoto dokumentu: {needed} z {budget} pro vrstvy This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Vrstvy tohoto dokumentu potřebují {needed}, ale mohou zabrat {budget}: překreslení celého dokumentu probíhá na CPU GPU Memory (MB, 0 = Automatic) Paměť GPU (MB, 0 = Automaticky) + {path}: the path is not valid Unicode; save under another name. {path}: cesta není platný Unicode; uložte soubor pod jiným názvem. {name} was changed by another app after it was opened or saved here. Soubor {name} změnila jiná aplikace poté, co byl zde otevřen nebo uložen. Replace it with this document, or cancel and use File › Save As to keep both. Nahraďte ho tímto dokumentem, nebo zrušte a použijte Soubor › Uložit jako, abyste zachovali obojí. Decrease Brush Size Zmenšit velikost štětce @@ -3946,3 +3947,5 @@ navigator Zoom Zvětšení Pro Light Gray Pro (světle šedý) Pro Light Pro (světlý) Resize Windows to Fit Přizpůsobit velikost oken + + EXR EXR diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 36804daec0..f24e5f6fb1 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3303,6 +3303,7 @@ curves Smooth Glätten This document's layers: {needed} of {budget} for layers Ebenen dieses Dokuments: {needed} von {budget} für Ebenen This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Die Ebenen dieses Dokuments brauchen {needed}, dürfen aber {budget} belegen: Das ganze Dokument wird auf der CPU neu gezeichnet GPU Memory (MB, 0 = Automatic) GPU-Speicher (MB, 0 = Automatisch) + {path}: the path is not valid Unicode; save under another name. {path}: Der Dateipfad enthält ungültige Zeichen. Speichere unter einem anderen Namen. {name} was changed by another app after it was opened or saved here. {name} wurde von einer anderen App geändert, nachdem es hier geöffnet oder gespeichert wurde. Replace it with this document, or cancel and use File › Save As to keep both. Ersetze die Datei durch dieses Dokument oder brich ab und nutze Datei › Speichern unter, um beide zu behalten. Demosaic Demosaicing @@ -3344,3 +3345,5 @@ curves Smooth Glätten Pro Light Gray Pro (Hellgrau) Pro Light Pro (Hell) Resize Windows to Fit Fenstergröße anpassen + + EXR EXR diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index e081c17e96..0451778f60 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3992,6 +3992,7 @@ curves Smooth Εξομάλυνση This document's layers: {needed} of {budget} for layers Επίπεδα αυτού του εγγράφου: {needed} από {budget} για επίπεδα This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Τα επίπεδα αυτού του εγγράφου χρειάζονται {needed} αλλά μπορούν να καταλάβουν {budget}: η επανασχεδίαση όλου του εγγράφου γίνεται στη CPU GPU Memory (MB, 0 = Automatic) Μνήμη GPU (MB, 0 = Αυτόματο) + {path}: the path is not valid Unicode; save under another name. {path}: η διαδρομή δεν είναι έγκυρο Unicode· αποθηκεύστε με άλλο όνομα. {name} was changed by another app after it was opened or saved here. Το {name} άλλαξε από άλλη εφαρμογή αφού ανοίχτηκε ή αποθηκεύτηκε εδώ. Replace it with this document, or cancel and use File › Save As to keep both. Αντικαταστήστε το με αυτό το έγγραφο ή ακυρώστε και χρησιμοποιήστε Αρχείο › Αποθήκευση ως για να κρατήσετε και τα δύο. Decrease Brush Size Μείωση μεγέθους πινέλου @@ -4035,3 +4036,5 @@ curves Smooth Εξομάλυνση Pro Light Gray Pro ανοιχτού γκρι Pro Light Φωτεινό Pro Resize Windows to Fit Προσαρμογή μεγέθους παραθύρων + + EXR EXR diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 0424d0af5b..c9a138a313 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4254,6 +4254,7 @@ navigator Zoom Zoom This document's layers: {needed} of {budget} for layers Capas de este documento: {needed} de {budget} para capas This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Las capas de este documento necesitan {needed} pero pueden ocupar {budget}: los redibujados de todo el documento usan la CPU GPU Memory (MB, 0 = Automatic) Memoria de GPU (MB, 0 = Automático) + {path}: the path is not valid Unicode; save under another name. {path}: la ruta no es Unicode válido; guarde con otro nombre. {name} was changed by another app after it was opened or saved here. Otra aplicación cambió {name} después de abrirlo o guardarlo aquí. Replace it with this document, or cancel and use File › Save As to keep both. Reemplázalo con este documento, o cancela y usa Archivo › Guardar como para conservar ambos. Decrease Brush Size Reducir tamaño del pincel @@ -4297,3 +4298,5 @@ navigator Zoom Zoom Pro Light Gray Pro (gris claro) Pro Light Pro (claro) Resize Windows to Fit Redimensionar ventanas para ajustar + + EXR EXR diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index d58b16f561..d5df62d148 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3301,6 +3301,7 @@ curves Smooth Lisser This document's layers: {needed} of {budget} for layers Calques de ce document : {needed} sur {budget} pour les calques This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Les calques de ce document ont besoin de {needed} mais peuvent occuper {budget} : le document entier se redessine sur le CPU GPU Memory (MB, 0 = Automatic) Mémoire GPU (Mo, 0 = Automatique) + {path}: the path is not valid Unicode; save under another name. {path} : le chemin n’est pas un Unicode valide ; enregistrez sous un autre nom. Replace Remplacer {name} was changed by another app after it was opened or saved here. {name} a été modifié par une autre application après avoir été ouvert ou enregistré ici. Replace it with this document, or cancel and use File › Save As to keep both. Remplacez-le par ce document, ou annulez et utilisez Fichier › Enregistrer sous pour garder les deux. @@ -3346,3 +3347,5 @@ curves Smooth Lisser Pro Light Gray Pro gris clair Pro Light Pro clair Resize Windows to Fit Redimensionner les fenêtres + + EXR EXR diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 754e4f21ba..286ce41f0a 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4209,6 +4209,7 @@ cameraRaw Yellow Sárga This document's layers: {needed} of {budget} for layers A dokumentum rétegei: {needed} / {budget} a rétegeknek This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU A dokumentum rétegeinek {needed} kell, de {budget} foglalhatnak: a teljes dokumentum újrarajzolása a CPU-n fut GPU Memory (MB, 0 = Automatic) GPU-memória (MB, 0 = Automatikus) + {path}: the path is not valid Unicode; save under another name. {path}: az elérési út nem érvényes Unicode-szöveg. Mentsd más néven. {name} was changed by another app after it was opened or saved here. A(z) {name} fájlt egy másik alkalmazás módosította, miután itt megnyitották vagy mentették. Replace it with this document, or cancel and use File › Save As to keep both. Cserélje le erre a dokumentumra, vagy válassza a Mégse gombot, és a Fájl › Mentés másként paranccsal tartsa meg mindkettőt. Decrease Brush Size Ecsetméret csökkentése @@ -4252,3 +4253,5 @@ cameraRaw Yellow Sárga Pro Light Gray Pro világosszürke Pro Light Pro világos Resize Windows to Fit Ablakok átméretezése a képhez + + EXR EXR diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 8cdc09e1f1..1fe7c32b24 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3304,6 +3304,7 @@ curves Smooth Haluskan This document's layers: {needed} of {budget} for layers Layer dokumen ini: {needed} dari {budget} untuk layer This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Layer dokumen ini membutuhkan {needed} tetapi boleh memakai {budget}: penggambaran ulang seluruh dokumen memakai CPU GPU Memory (MB, 0 = Automatic) Memori GPU (MB, 0 = Otomatis) + {path}: the path is not valid Unicode; save under another name. {path}: jalur bukan Unicode yang valid; simpan dengan nama lain. Replace Ganti {name} was changed by another app after it was opened or saved here. {name} diubah oleh aplikasi lain setelah dibuka atau disimpan di sini. Replace it with this document, or cancel and use File › Save As to keep both. Ganti dengan dokumen ini, atau batalkan dan gunakan Berkas › Simpan Sebagai untuk menyimpan keduanya. @@ -3349,3 +3350,5 @@ curves Smooth Haluskan Pro Light Gray Pro Abu-abu Terang Pro Light Pro Terang Resize Windows to Fit Ubah ukuran jendela agar pas + + EXR EXR diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index e9b85c84ce..3750f43092 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4413,6 +4413,7 @@ shape Appearance Aspetto This document's layers: {needed} of {budget} for layers Livelli di questo documento: {needed} su {budget} per i livelli This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU I livelli di questo documento richiedono {needed} ma possono occupare {budget}: i ridisegni dell'intero documento usano la CPU GPU Memory (MB, 0 = Automatic) Memoria GPU (MB, 0 = Automatico) + {path}: the path is not valid Unicode; save under another name. {path}: il percorso non è Unicode valido; salva con un altro nome. {name} was changed by another app after it was opened or saved here. {name} è stato modificato da un'altra app dopo essere stato aperto o salvato qui. Replace it with this document, or cancel and use File › Save As to keep both. Sostituiscilo con questo documento, oppure annulla e usa File › Salva con nome per tenerli entrambi. Demosaic Demosaicizzazione @@ -4452,3 +4453,5 @@ shape Appearance Aspetto Pro Light Gray Pro grigio chiaro Pro Light Pro chiaro Resize Windows to Fit Ridimensiona finestre per adattare + + EXR EXR diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 6c321e10af..dcc8b0aa10 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3968,6 +3968,7 @@ curves Smooth スムーズ This document's layers: {needed} of {budget} for layers このドキュメントのレイヤー: レイヤー用 {budget} のうち {needed} This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU このドキュメントのレイヤーには {needed} が必要ですが、使えるのは {budget} です: ドキュメント全体の再描画は CPU で行われます GPU Memory (MB, 0 = Automatic) GPU メモリ(MB、0 = 自動) + {path}: the path is not valid Unicode; save under another name. {path}: パスが有効な Unicode ではありません。別の名前で保存してください。 Replace 置き換え {name} was changed by another app after it was opened or saved here. {name} は、ここで開くか保存した後に別のアプリで変更されました。 Replace it with this document, or cancel and use File › Save As to keep both. この書類で置き換えるか、キャンセルしてファイル › 名前を付けて保存で両方を残してください。 @@ -4013,3 +4014,5 @@ curves Smooth スムーズ Pro Light Gray プロ(ライトグレー) Pro Light プロ(ライト) Resize Windows to Fit ウィンドウサイズを合わせる + + EXR EXR diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 5f048fcbd1..bff7a0e69c 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3496,6 +3496,7 @@ curves Smooth 매끄럽게 This document's layers: {needed} of {budget} for layers 이 문서의 레이어: 레이어용 {budget} 중 {needed} This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU 이 문서의 레이어에 {needed}가 필요하지만 {budget}만 사용할 수 있습니다: 문서 전체 다시 그리기는 CPU를 사용합니다 GPU Memory (MB, 0 = Automatic) GPU 메모리(MB, 0 = 자동) + {path}: the path is not valid Unicode; save under another name. {path}: 경로가 올바른 유니코드가 아닙니다. 다른 이름으로 저장하세요. Replace 대치 {name} was changed by another app after it was opened or saved here. {name}이(가) 여기서 열거나 저장한 뒤 다른 앱에서 변경되었습니다. Replace it with this document, or cancel and use File › Save As to keep both. 이 문서로 대치하거나, 취소하고 파일 › 다른 이름으로 저장을 사용해 둘 다 보관하세요. @@ -3542,3 +3543,5 @@ curves Smooth 매끄럽게 Pro Light Gray 프로 밝은 회색 Pro Light 프로 밝게 Resize Windows to Fit 창 크기 맞추기 + + EXR EXR diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index b71526cc21..961a707a6e 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4052,6 +4052,7 @@ curves Smooth Vloeiend maken This document's layers: {needed} of {budget} for layers Lagen van dit document: {needed} van {budget} voor lagen This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU De lagen van dit document hebben {needed} nodig maar mogen {budget} gebruiken: het hele document wordt op de CPU opnieuw getekend GPU Memory (MB, 0 = Automatic) GPU-geheugen (MB, 0 = Automatisch) + {path}: the path is not valid Unicode; save under another name. {path}: het pad is geen geldige Unicode; sla op onder een andere naam. {name} was changed by another app after it was opened or saved here. {name} is door een andere app gewijzigd nadat het hier is geopend of bewaard. Replace it with this document, or cancel and use File › Save As to keep both. Vervang het door dit document, of annuleer en gebruik Bestand › Opslaan als om beide te houden. Decrease Brush Size Penseelgrootte verkleinen @@ -4095,3 +4096,5 @@ curves Smooth Vloeiend maken Pro Light Gray Pro lichtgrijs Pro Light Pro licht Resize Windows to Fit Venstergrootte aanpassen + + EXR EXR diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 90aa05e51f..6ecf85ef2b 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3906,6 +3906,7 @@ curves Smooth Wygładź This document's layers: {needed} of {budget} for layers Warstwy tego dokumentu: {needed} z {budget} na warstwy This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Warstwy tego dokumentu potrzebują {needed}, ale mogą zająć {budget}: odświeżanie całego dokumentu używa CPU GPU Memory (MB, 0 = Automatic) Pamięć GPU (MB, 0 = Automatycznie) + {path}: the path is not valid Unicode; save under another name. {path}: ścieżka nie jest prawidłowym Unicode; zapisz pod inną nazwą. Replace Zastąp {name} was changed by another app after it was opened or saved here. Plik {name} został zmieniony przez inną aplikację po otwarciu lub zapisaniu go tutaj. Replace it with this document, or cancel and use File › Save As to keep both. Zastąp go tym dokumentem albo anuluj i użyj Plik › Zapisz jako, aby zachować oba. @@ -3951,3 +3952,5 @@ curves Smooth Wygładź Pro Light Gray Pro (jasnoszary) Pro Light Pro (jasny) Resize Windows to Fit Dopasuj rozmiar okien + + EXR EXR diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 7dcd2eed24..bf40b31e38 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4082,6 +4082,7 @@ layerLabel No Color Sem cor This document's layers: {needed} of {budget} for layers Camadas deste documento: {needed} de {budget} para camadas This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU As camadas deste documento precisam de {needed} mas podem ocupar {budget}: o documento inteiro é redesenhado na CPU GPU Memory (MB, 0 = Automatic) Memória da GPU (MB, 0 = Automático) + {path}: the path is not valid Unicode; save under another name. {path}: o caminho não é Unicode válido; salve com outro nome. {name} was changed by another app after it was opened or saved here. {name} foi alterado por outro app depois de ser aberto ou salvo aqui. Replace it with this document, or cancel and use File › Save As to keep both. Substitua-o por este documento, ou cancele e use Arquivo › Salvar como para manter os dois. Decrease Brush Size Diminuir tamanho do pincel @@ -4274,3 +4275,5 @@ layerLabel No Color Sem cor Pro Light Gray Pro cinza claro Pro Light Pro claro Resize Windows to Fit Redimensionar janelas para caber + + EXR EXR diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index d8638248f7..9352cd240a 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3268,6 +3268,7 @@ curves Smooth Сгладить This document's layers: {needed} of {budget} for layers Слои этого документа: {needed} из {budget} для слоёв This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Слоям этого документа нужно {needed}, но занимать они могут {budget}: весь документ перерисовывается на CPU GPU Memory (MB, 0 = Automatic) Память GPU (МБ, 0 = автоматически) + {path}: the path is not valid Unicode; save under another name. {path}: путь не является допустимым Unicode; сохраните под другим именем. Replace Заменить {name} was changed by another app after it was opened or saved here. Файл {name} был изменён другим приложением после того, как его открыли или сохранили здесь. Replace it with this document, or cancel and use File › Save As to keep both. Замените его этим документом или отмените и используйте Файл › Сохранить как, чтобы сохранить оба. @@ -3314,3 +3315,5 @@ curves Smooth Сгладить Pro Light Gray Светло-серая Pro Pro Light Светлая Pro Resize Windows to Fit Подгонять размер окон + + EXR EXR diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index c1da31ad2b..c303d62954 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4364,6 +4364,7 @@ shape Appearance Izgled This document's layers: {needed} of {budget} for layers Slojevi ovog dokumenta: {needed} od {budget} za slojeve This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Slojevima ovog dokumenta treba {needed}, a smeju da zauzmu {budget}: ceo dokument se ponovo iscrtava na CPU-u GPU Memory (MB, 0 = Automatic) GPU memorija (MB, 0 = Automatski) + {path}: the path is not valid Unicode; save under another name. {path}: putanja nije ispravan Unicode; sačuvajte pod drugim imenom. {name} was changed by another app after it was opened or saved here. Datoteku {name} je izmenila druga aplikacija nakon što je ovde otvorena ili sačuvana. Replace it with this document, or cancel and use File › Save As to keep both. Zameni je ovim dokumentom ili otkaži i koristi Datoteka › Sačuvaj kao da zadržiš obe. Decrease Brush Size Smanji veličinu četkice @@ -4407,3 +4408,5 @@ shape Appearance Izgled Pro Light Gray Pro svetlosiva Pro Light Pro svetla Resize Windows to Fit Prilagodi veličinu prozora + + EXR EXR diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 9571a5a500..7f34282232 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4022,6 +4022,7 @@ cameraData Lens Lens This document's layers: {needed} of {budget} for layers Bu belgenin katmanları: katmanlar için {budget} içinden {needed} This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Bu belgenin katmanlarının {needed} belleğe ihtiyacı var ama en fazla {budget} kullanabilirler: tüm belgenin yeniden çizimi CPU'yu kullanır GPU Memory (MB, 0 = Automatic) GPU Belleği (MB, 0 = Otomatik) + {path}: the path is not valid Unicode; save under another name. {path}: yol geçerli bir Unicode değil; başka bir adla kaydedin. {name} was changed by another app after it was opened or saved here. {name}, burada açıldıktan veya kaydedildikten sonra başka bir uygulama tarafından değiştirildi. Replace it with this document, or cancel and use File › Save As to keep both. Bu belgeyle değiştirin ya da iptal edip ikisini de korumak için Dosya › Farklı Kaydet'i kullanın. Decrease Brush Size Fırça boyutunu küçült @@ -4065,3 +4066,5 @@ cameraData Lens Lens Pro Light Gray Pro Açık Gri Pro Light Profesyonel Işık Resize Windows to Fit Pencereleri sığacak şekilde boyutlandır + + EXR EXR diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 8121592015..734a4b394f 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3991,6 +3991,7 @@ curves Smooth Згладити This document's layers: {needed} of {budget} for layers Шари цього документа: {needed} з {budget} для шарів This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Шарам цього документа потрібно {needed}, але займати вони можуть {budget}: увесь документ перемальовується на CPU GPU Memory (MB, 0 = Automatic) Пам'ять GPU (МБ, 0 = автоматично) + {path}: the path is not valid Unicode; save under another name. {path}: шлях не є коректним Unicode; збережіть під іншою назвою. Replace Замінити {name} was changed by another app after it was opened or saved here. Файл {name} змінила інша програма після того, як його тут відкрили або зберегли. Replace it with this document, or cancel and use File › Save As to keep both. Замініть його цим документом або скасуйте й скористайтеся Файл › Зберегти як, щоб зберегти обидва. @@ -4044,3 +4045,5 @@ curves Smooth Згладити Pro Light Gray Світло-сіра «Профі» Pro Light Світла «Профі» Resize Windows to Fit Підганяти розмір вікон + + EXR EXR diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index b929ff14b2..eba481b276 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3781,6 +3781,7 @@ curves Smooth Làm mượt This document's layers: {needed} of {budget} for layers Các lớp của tài liệu này: {needed} trên {budget} dành cho lớp This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Các lớp của tài liệu này cần {needed} nhưng chỉ được dùng {budget}: vẽ lại toàn bộ tài liệu dùng CPU GPU Memory (MB, 0 = Automatic) Bộ nhớ GPU (MB, 0 = Tự động) + {path}: the path is not valid Unicode; save under another name. {path}: đường dẫn không phải Unicode hợp lệ; hãy lưu với tên khác. Replace Thay thế {name} was changed by another app after it was opened or saved here. {name} đã bị một ứng dụng khác thay đổi sau khi được mở hoặc lưu ở đây. Replace it with this document, or cancel and use File › Save As to keep both. Thay thế bằng tài liệu này, hoặc hủy và dùng Tệp › Lưu thành để giữ cả hai. @@ -3826,3 +3827,5 @@ curves Smooth Làm mượt Pro Light Gray Pro xám nhạt Pro Light Pro sáng Resize Windows to Fit Đổi cỡ cửa sổ cho vừa ảnh + + EXR EXR diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 682fa62719..cf31e5c0b5 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2941,6 +2941,7 @@ curves Smooth 平滑 This document's layers: {needed} of {budget} for layers 此文档的图层:图层可用 {budget} 中的 {needed} This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU 此文档的图层需要 {needed},但只能占用 {budget}:整个文档的重绘使用 CPU GPU Memory (MB, 0 = Automatic) GPU 内存(MB,0 = 自动) + {path}: the path is not valid Unicode; save under another name. {path}:路径不是有效的 Unicode;请以其他名称存储。 Replace 替换 {name} was changed by another app after it was opened or saved here. {name} 在此处打开或存储后已被其他应用更改。 Replace it with this document, or cancel and use File › Save As to keep both. 用此文档替换它,或取消并使用“文件 › 另存为”以保留两者。 @@ -2987,3 +2988,5 @@ curves Smooth 平滑 Pro Light Gray 专业浅灰 Pro Light 专业浅色 Resize Windows to Fit 调整窗口大小以适合 + + EXR EXR diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 66f79f6ed1..1bc730684b 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2910,6 +2910,7 @@ curves Smooth 平滑 This document's layers: {needed} of {budget} for layers 此文件的圖層:圖層可用 {budget} 中的 {needed} This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU 此文件的圖層需要 {needed},但只能佔用 {budget}:整份文件的重繪使用 CPU GPU Memory (MB, 0 = Automatic) GPU 記憶體(MB,0 = 自動) + {path}: the path is not valid Unicode; save under another name. {path}:路徑不是有效的 Unicode;請以其他名稱儲存。 Replace 取代 {name} was changed by another app after it was opened or saved here. {name} 在此處開啟或儲存後已被其他 App 更改。 Replace it with this document, or cancel and use File › Save As to keep both. 以此文件取代它,或取消並使用「檔案 › 另存新檔」以保留兩者。 @@ -2956,3 +2957,5 @@ curves Smooth 平滑 Pro Light Gray 專業淺灰 Pro Light 專業淺色 Resize Windows to Fit 調整視窗大小以符合 + + EXR EXR diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3f66a4f213..bdb1e6915f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1911,6 +1911,50 @@ impl PhotocraftApp { Ok(Some((path, warnings))) } + /// After `saver` was written to `path`, any other open document bound to that file no longer + /// matches it: it loses the file and is marked unsaved, so it can't stay "saved" while its file + /// holds something else, closing it asks first, and its own Save asks for a name instead of + /// writing over the new file (#6597). Paths are compared as files, so another letter case on a + /// case-insensitive volume or a hard link counts. A notice names the documents affected. + pub(crate) fn detach_others_from_file(&mut self, path: &str, saver: DocId) { + let others: Vec<(DocId, String)> = self + .session + .documents() + .iter() + .filter(|st| st.doc.id != saver && st.path.as_deref().is_some_and(|p| photocraft_engine::file_cmds::same_file(p, path))) + .map(|st| (st.doc.id, st.doc.name.clone())) + .collect(); + let mut detached = Vec::new(); + for (id, name) in others { + let done = self.with_document(id, |app| { + let st = app.session.active_mut().ok_or("no document")?; + st.path = None; + // No revision ever equals this, so the document reads as unsaved until saved again. + st.saved_revision = u64::MAX; + Ok(()) + }); + if done.is_ok() { + detached.push(name); + } + } + if detached.is_empty() { + return; + } + let name = file_open::display_name(path); + let title = { + let template = "{name} was saved over from another tab"; + crate::i18n::fmt(tl!(template), &[("name", name.as_str())]) + }; + let lines = detached + .iter() + .map(|doc| { + let template = "\"{doc}\" no longer has a file and is marked unsaved: save it under a new name to keep it"; + crate::i18n::fmt(tl!(template), &[("doc", doc.as_str())]) + }) + .collect(); + notices::post(self, title, lines, false, None); + } + /// Record a written save of document `doc` as it was at `revision`: its name, path and saved /// state (unless a copy), the status, script events and the warnings. pub(crate) fn saved(&mut self, doc: DocId, revision: u64, path: &str, warnings: &[String], copy: bool) { @@ -1933,6 +1977,8 @@ impl PhotocraftApp { } }; self.ui.status = crate::i18n::fmt(tl!("Saved {name}"), &[("name", path)]); + // The file was written, copy or not: another tab bound to it no longer matches it. + self.detach_others_from_file(path, doc); if !copy { // A background save may finish while another document is active (or after its // document was closed, when there is nothing left to record). diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b7161ef74a..b9b4210fdb 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1006,6 +1006,7 @@ pub fn menu_items(app: &PhotocraftApp) -> Vec { QuickExportFormat::Jpg => "JPG", QuickExportFormat::Gif => "GIF", QuickExportFormat::Webp => "WebP", + QuickExportFormat::Exr => "EXR", } }; item.label = format!("Quick Export as {format}"); @@ -2177,7 +2178,7 @@ mod quick_export_label_tests { #[test] fn file_quick_export_menu_reflects_selected_format_without_renaming_layer_export() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); - for (value, expected) in [("png", "PNG"), ("jpg", "JPG"), ("gif", "GIF"), ("webp", "WebP")] { + for (value, expected) in [("png", "PNG"), ("jpg", "JPG"), ("gif", "GIF"), ("webp", "WebP"), ("exr", "EXR")] { app.run("prefs.set", json!({"values": {"export.quickExportFormat": value}})).unwrap(); let items = menu_items(&app); let quick: Vec<_> = items.iter().filter(|i| i.id == "file.export.quickExportAsPng").collect(); diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 96f1c130ea..ed6d3d8282 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -1255,6 +1255,7 @@ fn humanize(key: &str) -> String { fn choice_label(v: &str) -> String { match v { + "exr" => "EXR".into(), "cm" => "Centimeters".into(), "mm" => "Millimeters".into(), "75" | "80" | "85" | "90" | "95" | "100" | "125" | "150" | "175" | "200" | "250" | "300" => format!("{v}%"), @@ -1512,6 +1513,7 @@ fn has_visible_fields(values: &Value, section: &str) -> bool { fn export_field_visible(obj: &Map, key: &str) -> bool { let format = obj.get("quickExportFormat").and_then(Value::as_str).unwrap_or("png"); match key { + "convertToSrgb" | "metadata" => format != "exr", "jpegQuality" => format == "jpg", "webpLossless" => format == "webp", "webpQuality" => format == "webp" && obj.get("webpLossless").and_then(Value::as_bool) != Some(true), @@ -2719,6 +2721,10 @@ mod tests { assert!(export_field_visible(&obj, "webpQuality")); assert_eq!(humanize("webpLossless"), "Lossless"); assert_eq!(humanize("webpQuality"), "Quality"); + obj.insert("quickExportFormat".into(), json!("exr")); + for key in ["jpegQuality", "webpLossless", "webpQuality", "convertToSrgb", "metadata"] { + assert!(!export_field_visible(&obj, key), "EXR must not offer ignored web settings: {key}"); + } } #[test] diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index f0e0cc592d..e0ab91b601 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -602,6 +602,10 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { if crate::guide_select::keys(app, ctx) { return; } + // A slice selected with Slice Select: ⌫/Delete deletes the slice, not the layer (#7862). + if crate::slice_ui::keys(app, ctx) { + return; + } // Space+F (⇧Space+F backwards) cycles the pasteboard colour, as in Photoshop: F while the // Hand key is held, before F's own Cycle Screen Mode. if !matches!(focus, Focus::Text) && app.ui.text_edit.is_none() && crate::hold_keys::reposition_held(app, ctx) { diff --git a/crates/ui-egui/src/slice_ui.rs b/crates/ui-egui/src/slice_ui.rs index 32945c81b1..78e19f4be3 100644 --- a/crates/ui-egui/src/slice_ui.rs +++ b/crates/ui-egui/src/slice_ui.rs @@ -292,6 +292,39 @@ fn selected_id(app: &mut PhotocraftApp, promote: bool) -> Option { } } +/// Delete or Backspace with no modifiers, with the Slice Select tool and a slice selected, +/// deletes that slice, as in Photoshop (#7862). It never reaches Layer › Delete or Edit › Clear. +/// An auto slice has nothing to delete, and View › Lock Slices refuses with a message. +pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { + use crate::shortcut_dispatch::Focus; + // Text fields and widgets that delete with these keys themselves keep them. + if app.ui.tool != Tool::SliceSelect + || app.ui.slices.selected.is_none() + || app.ui.text_edit.is_some() + || matches!(Focus::of(ctx), Focus::Text | Focus::DeletingWidget) + { + return false; + } + let mods = ctx.input(|i| i.modifiers); + if !mods.is_none() || !ctx.input_mut(|i| i.consume_key(mods, egui::Key::Delete) || i.consume_key(mods, egui::Key::Backspace)) { + return false; + } + let result = if app.session.file_menu.slices_locked { + Err(tl!("Slices are locked (View › Lock Slices)").to_string()) + } else { + match selected_id(app, false) { + Some(id) => app.run("slice.delete", json!({"slice": id})).map(|_| app.ui.slices.selected = None), + // An auto slice is made from the user slices around it: it goes when they do. + None => Ok(()), + } + }; + if let Err(e) = result { + app.ui.status = e; + app.ui.status_error = true; + } + true +} + /// Slice Options dialog for the selected slice (auto slices are promoted on OK). pub fn open_options(app: &mut PhotocraftApp) -> Result { let n = app.ui.slices.selected.ok_or("select a slice with the Slice Select tool")?; @@ -503,6 +536,60 @@ mod tests { assert_eq!(rect(&app), photocraft_geom::Rect::new(6, 11, 52, 46)); } + /// Delete through the app's shortcut handling, as the canvas gets it. + fn press(app: &mut PhotocraftApp, key: egui::Key) { + let ctx = egui::Context::default(); + let m = egui::Modifiers::NONE; + let raw = egui::RawInput { + events: vec![egui::Event::ModifiersChanged(m), egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: m }], + ..Default::default() + }; + ctx.begin_pass(raw); + crate::shortcuts::handle(app, &ctx); + ctx.end_pass().textures_delta.clear(); + } + + /// #7862: with Slice Select, Delete (or Backspace) deletes the selected slice, never the + /// layer: in a one-layer document that used to fail with "a document must keep at least one + /// layer". Locked slices refuse; an auto slice has nothing to delete. + #[test] + fn delete_deletes_the_selected_slice_not_the_layer() { + let m = egui::Modifiers::NONE; + for key in [egui::Key::Delete, egui::Key::Backspace] { + let mut app = app(); + let layers = app.session.active().unwrap().doc.layers.len(); + app.ui.tool = Tool::Slice; + pointer(&mut app, ToolEvent::Down { x: 10.0, y: 10.0, pressure: 1.0 }, m); + pointer(&mut app, ToolEvent::Up { x: 40.0, y: 30.0 }, m); + app.ui.tool = Tool::SliceSelect; + pointer(&mut app, ToolEvent::Down { x: 20.0, y: 20.0, pressure: 1.0 }, m); + pointer(&mut app, ToolEvent::Up { x: 20.0, y: 20.0 }, m); + assert!(app.ui.slices.selected.is_some()); + + // Locked: the slice stays, and says why. + app.run("view.lockSlices", json!({"on": true})).unwrap(); + press(&mut app, key); + assert_eq!(app.session.active().unwrap().doc.slices.list.len(), 1, "{key:?}: locked"); + assert!(app.ui.status_error && app.ui.status.contains("locked"), "{key:?}: {}", app.ui.status); + app.run("view.lockSlices", json!({"on": false})).unwrap(); + app.ui.status_error = false; + + press(&mut app, key); + let doc = app.session.active().unwrap().doc.clone(); + assert!(doc.slices.list.is_empty(), "{key:?}: the slice is deleted"); + assert_eq!(doc.layers.len(), layers, "{key:?}: the layer stays"); + assert!(!app.ui.status_error, "{key:?}: {}", app.ui.status); + assert_eq!(app.ui.slices.selected, None); + + // An auto slice: nothing to delete, and still not the layer. + pointer(&mut app, ToolEvent::Down { x: 90.0, y: 75.0, pressure: 1.0 }, m); + pointer(&mut app, ToolEvent::Up { x: 90.0, y: 75.0 }, m); + press(&mut app, key); + assert_eq!(app.session.active().unwrap().doc.layers.len(), layers, "{key:?}: auto slice"); + assert!(!app.ui.status_error, "{key:?}: {}", app.ui.status); + } + } + #[test] fn locked_slices_ignore_gestures() { let mut app = app(); diff --git a/crates/ui-egui/src/view_cmds.rs b/crates/ui-egui/src/view_cmds.rs index 77f8d78248..26208d2a43 100644 --- a/crates/ui-egui/src/view_cmds.rs +++ b/crates/ui-egui/src/view_cmds.rs @@ -1525,6 +1525,7 @@ pub(crate) fn write_copy(app: &mut PhotocraftApp, doc: &photocraft_doc::Document let (bytes, warnings) = export(doc, path, &settings)?; let write = app.services.write.as_mut().ok_or("no writer configured")?; write(path, &bytes)?; + app.detach_others_from_file(path, doc.id); let template = "Saved a copy as {path}"; app.ui.status = crate::i18n::fmt(tl!(template), &[("path", path)]); app.ui.status_error = false;