From a52f458702f94b0e681422cbf91aedbbac033627 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 17 Oct 2026 19:06:54 -0700 Subject: [PATCH 01/25] Layer Export As: the dialog opens with the layer's size, not the document's open seeds width and height from the document, and open_layer replaced only the internal __w/__h, so the dialog showed and edited the document's size while the export used the layer's trimmed pixels: a 10x6 layer in a 40x30 document opened at 40x30, and editing one side started from the wrong value. In scale mode the fields now follow the layer's trimmed size; a remembered exact pixel size stays, because that is a size the user asked for. Test: a 10x6 layer in a 40x30 document opens at 10x6, a remembered 50 percent at 5x3, and remembered pixels are untouched. --- crates/ui-egui/src/export_dialog.rs | 44 ++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/export_dialog.rs b/crates/ui-egui/src/export_dialog.rs index 275221e050..05ed0d38f5 100644 --- a/crates/ui-egui/src/export_dialog.rs +++ b/crates/ui-egui/src/export_dialog.rs @@ -112,10 +112,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) } @@ -985,6 +994,33 @@ mod tests { assert_eq!(f["metadata"], "none"); } + /// 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] From 781a842d890ee13a8ae79ee5a7e258f69b941ee6 Mon Sep 17 00:00:00 2001 From: ahmed0magdy <110637215+ahmed0magdy@users.noreply.github.com> Date: Sun, 18 Oct 2026 05:23:35 +0000 Subject: [PATCH 02/25] test(io): the SVG text-transform test shapes with the bundled Inter (#4806) transformed_text_lands_where_resvg_draws_it returned early, and passed without checking anything, when the import found no font: it relied on system fonts, and photocraft_text::CRAFT_FONTS is empty unless the build sets CRAFT_FONTS_DIR. With the system fonts hidden it passed in 0.00 s. It is now a unit test in src/svg.rs that parses through parse_with with a font database holding only the bundled Inter Regular (always compiled in), and renders its resvg reference from the same parse, so both sides use the same face. An empty import is now a failure. It fails with the #5334 fix reverted, and passes with the system fonts hidden. Raised in review of #5334 by tanish2k09. Co-Authored-By: Claude Opus 5.5 --- crates/io/src/svg.rs | 58 ++++++++++++++++++++++++++++++++++++++++++ crates/io/tests/svg.rs | 51 ------------------------------------- 2 files changed, 58 insertions(+), 51 deletions(-) diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index 72b2318675..d5b0f62e91 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -736,6 +736,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/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!("")); From be9f0c250a19891496ccd71942bc200964627c52 Mon Sep 17 00:00:00 2001 From: Ashsajal Date: Mon, 19 Oct 2026 12:35:45 +0600 Subject: [PATCH 03/25] File Info: print a slow shutter speed as the fraction a camera writes (#6958) The `1/n s` test in `exposure_text` was relative to the reciprocal, so the tolerance shrank as the exposure slowed. Under 1/50 s the reciprocal drops below 50, which makes 1 % of it smaller than half a rounding step, and nearly every fraction fell through to the decimal form: a stored 0.031714 s printed as "0.03 s" (5.4 % off) and 0.0215 s as "0.02 s" (7 % off). 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. At or above 1/10 s the existing near-whole rule is kept, so 0.5 s is still "1/2 s" and 0.4 s still "0.4 s". The decimal fallback moves from two decimals to three significant digits ("0.105 s", not "0.11 s"). The two fixtures from the report now read "1/32 s" and "1/47 s" through `photocraft-cli run ... --cmd file.fileInfo`. --- crates/engine/src/file_cmds.rs | 26 +++++++++++++-- crates/engine/src/file_cmds/tests.rs | 48 ++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 1fb3fcf300..e86e4252a7 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -758,6 +758,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 { @@ -765,10 +777,18 @@ fn exposure_text(t: f64) -> Option { return None; } let r = 1.0 / t; - if t < 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 && (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 3ab50c4a77..bd8ccc9925 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -494,6 +494,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#" Date: Mon, 19 Oct 2026 01:52:03 -0600 Subject: [PATCH 04/25] Export As: show the size the file will have (#6595) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The estimate encoded a ≤512 px proxy and scaled its bytes by the pixel ratio, which reads low for detailed images (0.36–0.81× here). Photoshop's Export As shows the encoded size. Up to 40 MP the full export is now encoded off the UI thread with the exporter's own settings; the proxy figure shows until then. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/export_dialog.rs | 129 +++++++++++++++++++++++++++- 1 file changed, 127 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/export_dialog.rs b/crates/ui-egui/src/export_dialog.rs index 275221e050..eecb7bdf44 100644 --- a/crates/ui-egui/src/export_dialog.rs +++ b/crates/ui-egui/src/export_dialog.rs @@ -526,6 +526,76 @@ 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 { + let scale = n(f, "scale", 100.0) / 100.0; + let px = match (f.get("width").and_then(Value::as_f64), f.get("height").and_then(Value::as_f64)) { + (Some(w), Some(h)) if w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0 => w * h, + _ => doc.size.width as f64 * scale * doc.size.height as f64 * scale, + }; + 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)); @@ -814,7 +884,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)); }); }); @@ -1048,6 +1123,56 @@ 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")), ("width".into(), json!(10_000)), ("height".into(), json!(10_000))]); + assert_eq!(exact_estimate(&doc, &huge), None); + } + #[test] fn export_document_scales_and_flattens() { let doc = Document::with_background( From 9aa9634a22765b7532fbc149d2bc3560cf30c060 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Mon, 19 Oct 2026 02:01:39 -0600 Subject: [PATCH 05/25] Export As estimate: size the 40 MP guard as the export does Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/export_dialog.rs | 40 ++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/export_dialog.rs b/crates/ui-egui/src/export_dialog.rs index eecb7bdf44..145fe60049 100644 --- a/crates/ui-egui/src/export_dialog.rs +++ b/crates/ui-egui/src/export_dialog.rs @@ -536,11 +536,9 @@ const EXACT_ESTIMATE_PX: f64 = 40_000_000.0; /// large to encode for an estimate or can't be encoded. #[cfg(not(target_arch = "wasm32"))] fn exact_estimate(doc: &Document, f: &Map) -> Option { - let scale = n(f, "scale", 100.0) / 100.0; - let px = match (f.get("width").and_then(Value::as_f64), f.get("height").and_then(Value::as_f64)) { - (Some(w), Some(h)) if w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0 => w * h, - _ => doc.size.width as f64 * scale * doc.size.height as f64 * scale, - }; + // 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; } @@ -1169,8 +1167,38 @@ mod tests { }; 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")), ("width".into(), json!(10_000)), ("height".into(), json!(10_000))]); + 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"); } #[test] From 06896086191dfcf1c41ed251b498c730b63caec1 Mon Sep 17 00:00:00 2001 From: Daniel Campos Ramos Date: Mon, 19 Oct 2026 14:08:44 -0300 Subject: [PATCH 06/25] PNG: name sTER modes by their specified eye order Both sTER modes describe side-by-side subimages: mode 0 is cross-fuse (right eye first), and mode 1 is diverging-fuse (left eye first). Correct the enum and document comments without changing their serialized byte values. Add a labelled-pixel check against the literal mode bytes in PNG Extensions 1.4.0 section 3.6. Co-Authored-By: OpenAI Codex --- crates/codecs/src/codecs/png.rs | 32 +++++++++++++++++++++++++------- crates/codecs/src/options.rs | 16 ++++++++-------- crates/doc/src/lib.rs | 3 ++- crates/io/tests/flat.rs | 2 +- 4 files changed, 36 insertions(+), 17 deletions(-) diff --git a/crates/codecs/src/codecs/png.rs b/crates/codecs/src/codecs/png.rs index ede70d296d..3fadc4eac8 100644 --- a/crates/codecs/src/codecs/png.rs +++ b/crates/codecs/src/codecs/png.rs @@ -679,8 +679,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); @@ -690,6 +690,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]); @@ -702,7 +720,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)); @@ -719,12 +737,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/options.rs b/crates/codecs/src/options.rs index e6ca3cb94f..430b412a91 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/io/tests/flat.rs b/crates/io/tests/flat.rs index e591fbd5c1..9dc27a45c3 100644 --- a/crates/io/tests/flat.rs +++ b/crates/io/tests/flat.rs @@ -1119,7 +1119,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())); From e499fef328b6ff329e0602d484a4f04e33306ba0 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Mon, 19 Oct 2026 11:34:03 -0600 Subject: [PATCH 07/25] Re-run CI Co-Authored-By: Claude Opus 5.5 From 7983ea24c36e4522a19087620bc0298bebea1917 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Mon, 19 Oct 2026 16:28:25 -0600 Subject: [PATCH 08/25] Re-run CI Co-Authored-By: Claude Opus 5.5 From f0501d9721a82d62435f2617732a094c6a435f96 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Mon, 19 Oct 2026 16:55:42 -0600 Subject: [PATCH 09/25] Re-run CI (flaky automation bridge test) Co-Authored-By: Claude Opus 5.5 From 2c030a300c4a3e085287aa823fee971e0e40d70d Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Tue, 20 Oct 2026 06:44:11 +0300 Subject: [PATCH 10/25] fix(ui): Delete with Slice Select deletes the selected slice, not the layer (#7862) --- crates/ui-egui/src/shortcuts.rs | 4 ++++ crates/ui-egui/src/slice_ui.rs | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 1ad8d6d92a..d16caa9e29 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -555,6 +555,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 1e75bbd714..0ffc0f59c4 100644 --- a/crates/ui-egui/src/slice_ui.rs +++ b/crates/ui-egui/src/slice_ui.rs @@ -206,6 +206,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")?; From aede66995d91ace98dfba8f159c841f0ef13fe84 Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Tue, 20 Oct 2026 06:44:12 +0300 Subject: [PATCH 11/25] test(ui): Delete and Backspace delete the selected slice; locked and auto slices keep the layer (#7862) --- crates/ui-egui/src/slice_ui.rs | 54 ++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/crates/ui-egui/src/slice_ui.rs b/crates/ui-egui/src/slice_ui.rs index 0ffc0f59c4..7847ef28e9 100644 --- a/crates/ui-egui/src/slice_ui.rs +++ b/crates/ui-egui/src/slice_ui.rs @@ -356,6 +356,60 @@ mod tests { assert!(doc.slices.list.iter().any(|s| s.name == "footer")); } + /// 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(); From afeea2f643f015d8010b5b67b06ccb46cd5f55c1 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:42:22 +0000 Subject: [PATCH 12/25] Decode numeric character references in File Info XMP text Found while testing crates/engine/src/file_cmds.rs:690 (xml_unescape), used by li_values, find_attr and read_file_info Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/file_cmds.rs | 37 +++++++++++++++++++++++++++++- crates/engine/tests/xmp_charref.rs | 10 ++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 crates/engine/tests/xmp_charref.rs diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 2299aa3e49..248e017689 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -657,7 +657,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.find(';').filter(|&e| e <= 12).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). diff --git a/crates/engine/tests/xmp_charref.rs b/crates/engine/tests/xmp_charref.rs new file mode 100644 index 0000000000..5f167e8c4f --- /dev/null +++ b/crates/engine/tests/xmp_charref.rs @@ -0,0 +1,10 @@ +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 &"); +} From dc5006e79e00e31af9e43de3f98449a3b8242c22 Mon Sep 17 00:00:00 2001 From: zuujiscom Date: Fri, 16 Oct 2026 07:48:09 -0500 Subject: [PATCH 13/25] Save: a tab whose file another save writes over is detached and marked unsaved (#6597) When document B was saved (Save As, Save a Copy, a replayed action) onto the file document A in another tab was bound to, both tabs pointed at that file and both read as saved, though A's content existed only in memory: closing A lost it without a prompt, and A's own Save wrote over B's file. Every completed save and Save a Copy now detaches any other open document bound to the written file (compared with file_cmds::same_file): it loses the path and reads as unsaved, and a notice names it. Detaching rather than refusing keeps replaying a recorded fixed-path Save As working (actions::tests::recorded_save_*). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/file_dialog/tests.rs | 39 +++++++++++++++++++++ crates/ui-egui/src/lib.rs | 46 +++++++++++++++++++++++++ crates/ui-egui/src/view_cmds.rs | 1 + 3 files changed, 86 insertions(+) diff --git a/crates/ui-egui/src/file_dialog/tests.rs b/crates/ui-egui/src/file_dialog/tests.rs index bb827274ff..7c761dcb6c 100644 --- a/crates/ui-egui/src/file_dialog/tests.rs +++ b/crates/ui-egui/src/file_dialog/tests.rs @@ -869,6 +869,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/lib.rs b/crates/ui-egui/src/lib.rs index 8bf2be90d7..ed46d7934c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1761,6 +1761,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) { @@ -1783,6 +1827,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/view_cmds.rs b/crates/ui-egui/src/view_cmds.rs index 1cb51a6eab..53a687149b 100644 --- a/crates/ui-egui/src/view_cmds.rs +++ b/crates/ui-egui/src/view_cmds.rs @@ -1501,6 +1501,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; From 3ad7333a16317469c6cbe52318912b5d538dab70 Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:50:50 +0800 Subject: [PATCH 14/25] Add linear floating-point EXR Quick Export --- crates/engine/src/prefs.rs | 2 +- crates/engine/src/web_cmds.rs | 9 ++++++++ crates/engine/src/web_cmds/tests.rs | 34 +++++++++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 3 ++- crates/ui-egui/src/prefs_ui.rs | 5 +++++ 5 files changed, 51 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index ed8b7f148f..28bde21c07 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/web_cmds.rs b/crates/engine/src/web_cmds.rs index 755ba07320..333b2b0733 100644 --- a/crates/engine/src/web_cmds.rs +++ b/crates/engine/src/web_cmds.rs @@ -1099,6 +1099,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, "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( diff --git a/crates/engine/src/web_cmds/tests.rs b/crates/engine/src/web_cmds/tests.rs index 5791e5f27c..6352293ee6 100644 --- a/crates/engine/src/web_cmds/tests.rs +++ b/crates/engine/src/web_cmds/tests.rs @@ -977,3 +977,37 @@ 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); + 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/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 63b46f4429..cfe28ef02e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1000,6 +1000,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}"); @@ -2168,7 +2169,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 5cd2e547d8..5d2b011157 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -1435,6 +1435,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), @@ -2640,6 +2641,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] From 32a2faa7c38d45c51f35f01e15cae18abb7c8b31 Mon Sep 17 00:00:00 2001 From: zuujiscom Date: Wed, 21 Oct 2026 07:30:42 +0530 Subject: [PATCH 15/25] Open/Save: refuse file names that aren't valid Unicode instead of rewriting them (#6601) --- apps/photocraft/src/services.rs | 55 +++++++++++++++++++++++---- crates/ui-egui/src/file_dialog.rs | 5 ++- crates/ui-egui/src/file_open.rs | 19 ++++++++- crates/ui-egui/src/file_open/tests.rs | 31 +++++++++++++++ crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sr.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + 24 files changed, 118 insertions(+), 12 deletions(-) diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 999952f186..f0e0a55fff 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -197,10 +197,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 } => { @@ -208,7 +208,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); @@ -243,10 +243,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)) } } }) @@ -255,7 +255,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)) }) } } }; @@ -269,8 +269,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. @@ -713,6 +727,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/crates/ui-egui/src/file_dialog.rs b/crates/ui-egui/src/file_dialog.rs index 7cdff50af9..8904708cc0 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_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 00db3ef96e..4d7a9b80bc 100644 --- a/crates/ui-egui/src/file_open/tests.rs +++ b/crates/ui-egui/src/file_open/tests.rs @@ -958,6 +958,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 15879f60d2..240b4fdd29 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3842,6 +3842,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 diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index eb3ca77944..b4c8b9872d 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3239,6 +3239,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. Memory Usage Speichernutzung diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index bac53b53d6..028b9f115b 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3934,6 +3934,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 Μείωση μεγέθους πινέλου diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 8629fc3fda..4285ca2d4b 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4196,6 +4196,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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index fad669a4b2..1d66a57a33 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3241,6 +3241,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. diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c066f42711..bd614c8817 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4153,6 +4153,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 diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 9e0012bd64..f8b88835b3 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3228,6 +3228,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. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index fe8d4cf8dd..9094e0fdcf 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4355,6 +4355,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. Memory Usage Utilizzo memoria diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 9b121c4c90..3c76ed270d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3910,6 +3910,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. この書類で置き換えるか、キャンセルしてファイル › 名前を付けて保存で両方を残してください。 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index dc4bf58c95..68f9f615f9 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3415,6 +3415,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. 이 문서로 대치하거나, 취소하고 파일 › 다른 이름으로 저장을 사용해 둘 다 보관하세요. diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 7104d69eda..b3c57dc3b1 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3994,6 +3994,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 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 8abed62baa..fec2a66998 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3832,6 +3832,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. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index b21564e683..2fe6579d59 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4024,6 +4024,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 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index ff6fc08de8..7e31882331 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3206,6 +3206,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. Замените его этим документом или отмените и используйте Файл › Сохранить как, чтобы сохранить оба. diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 9a1ceff635..3668277c63 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4307,6 +4307,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 diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index def688abcd..ef2de968d8 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3964,6 +3964,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 diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index e28ea0008c..bbc4ea5ef6 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3933,6 +3933,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. Замініть його цим документом або скасуйте й скористайтеся Файл › Зберегти як, щоб зберегти обидва. diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 503cc1006f..78d9a20fd7 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3720,6 +3720,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. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index a030a8885f..0b558dcf65 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2857,6 +2857,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. 用此文档替换它,或取消并使用“文件 › 另存为”以保留两者。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 25a05a1826..6be248a547 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2842,6 +2842,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. 以此文件取代它,或取消並使用「檔案 › 另存新檔」以保留兩者。 From bdd7595b022416843326a4283777463c46574791 Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:55:05 +0800 Subject: [PATCH 16/25] Label EXR consistently in translated export preferences --- crates/ui-egui/src/i18n/cs.tsv | 2 ++ crates/ui-egui/src/i18n/de.tsv | 2 ++ crates/ui-egui/src/i18n/el.tsv | 2 ++ crates/ui-egui/src/i18n/es.tsv | 2 ++ crates/ui-egui/src/i18n/fr.tsv | 2 ++ crates/ui-egui/src/i18n/hu.tsv | 2 ++ crates/ui-egui/src/i18n/id.tsv | 2 ++ crates/ui-egui/src/i18n/it.tsv | 2 ++ crates/ui-egui/src/i18n/ja.tsv | 2 ++ crates/ui-egui/src/i18n/ko.tsv | 2 ++ crates/ui-egui/src/i18n/nl.tsv | 2 ++ crates/ui-egui/src/i18n/pl.tsv | 2 ++ crates/ui-egui/src/i18n/pt-br.tsv | 2 ++ crates/ui-egui/src/i18n/ru.tsv | 2 ++ crates/ui-egui/src/i18n/sr.tsv | 2 ++ crates/ui-egui/src/i18n/tr.tsv | 2 ++ crates/ui-egui/src/i18n/uk.tsv | 2 ++ crates/ui-egui/src/i18n/vi.tsv | 2 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 2 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 2 ++ crates/ui-egui/src/prefs_ui.rs | 1 + 21 files changed, 41 insertions(+) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 15879f60d2..fa21226fa7 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3864,3 +3864,5 @@ navigator Zoom Zvětšení # Graphics Adapter preference (#1885) GPU adapter Adaptér GPU + + EXR EXR diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index eb3ca77944..0b2b5678e9 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3259,3 +3259,5 @@ curves Smooth Glätten # Graphics Adapter preference (#1885) GPU adapter GPU-Adapter + + EXR EXR diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index bac53b53d6..1ac1049919 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3956,3 +3956,5 @@ curves Smooth Εξομάλυνση # Graphics Adapter preference (#1885) GPU adapter Προσαρμογέας GPU + + EXR EXR diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 8629fc3fda..a3caff5965 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4218,3 +4218,5 @@ navigator Zoom Zoom # Graphics Adapter preference (#1885) GPU adapter Adaptador de GPU + + EXR EXR diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index fad669a4b2..d801d0cfa3 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3265,3 +3265,5 @@ curves Smooth Lisser # Graphics Adapter preference (#1885) GPU adapter Adaptateur GPU + + EXR EXR diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c066f42711..8f7daf183c 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4175,3 +4175,5 @@ cameraRaw Yellow Sárga # Graphics Adapter preference (#1885) GPU adapter GPU-adapter + + EXR EXR diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 9e0012bd64..71e1613456 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3252,3 +3252,5 @@ curves Smooth Haluskan # Graphics Adapter preference (#1885) GPU adapter Adaptor GPU + + EXR EXR diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index fe8d4cf8dd..196bf50795 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4373,3 +4373,5 @@ shape Appearance Aspetto # Graphics Adapter preference (#1885) GPU adapter Adattatore GPU + + EXR EXR diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 9b121c4c90..ce75425329 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3934,3 +3934,5 @@ curves Smooth スムーズ # Graphics Adapter preference (#1885) GPU adapter GPU アダプター + + EXR EXR diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index dc4bf58c95..1748f0bb8d 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3440,3 +3440,5 @@ curves Smooth 매끄럽게 # Graphics Adapter preference (#1885) GPU adapter GPU 어댑터 + + EXR EXR diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 7104d69eda..dc0a7744bc 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4016,3 +4016,5 @@ curves Smooth Vloeiend maken # Graphics Adapter preference (#1885) GPU adapter GPU-adapter + + EXR EXR diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 8abed62baa..6fbf8714c3 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3856,3 +3856,5 @@ curves Smooth Wygładź # Graphics Adapter preference (#1885) GPU adapter Karta GPU + + EXR EXR diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index b21564e683..c3819e3804 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4046,3 +4046,5 @@ layerLabel No Color Sem cor # Graphics Adapter preference (#1885) GPU adapter Adaptador de GPU + + EXR EXR diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index ff6fc08de8..4262eff322 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3231,3 +3231,5 @@ curves Smooth Сгладить # Graphics Adapter preference (#1885) GPU adapter Адаптер GPU + + EXR EXR diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 9a1ceff635..a9a2df8db9 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4329,3 +4329,5 @@ shape Appearance Izgled # Graphics Adapter preference (#1885) GPU adapter GPU adapter + + EXR EXR diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index def688abcd..056c92083c 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3986,3 +3986,5 @@ cameraData Lens Lens # Graphics Adapter preference (#1885) GPU adapter GPU bağdaştırıcısı + + EXR EXR diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index e28ea0008c..a9b89eea03 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3965,3 +3965,5 @@ curves Smooth Згладити # Graphics Adapter preference (#1885) GPU adapter Адаптер GPU + + EXR EXR diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 503cc1006f..979383e426 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3744,3 +3744,5 @@ curves Smooth Làm mượt # Graphics Adapter preference (#1885) GPU adapter Bộ điều hợp GPU + + EXR EXR diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index a030a8885f..f404c47554 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2882,3 +2882,5 @@ curves Smooth 平滑 # Graphics Adapter preference (#1885) GPU adapter GPU 适配器 + + EXR EXR diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 25a05a1826..6c6377a72e 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2867,3 +2867,5 @@ curves Smooth 平滑 # Graphics Adapter preference (#1885) GPU adapter GPU 介面卡 + + EXR EXR diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 5d2b011157..d6779f3557 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -1180,6 +1180,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}%"), From 071ab42c709aeffaea2d7ca4aef7f3ff1b182d81 Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:03:09 +0800 Subject: [PATCH 17/25] Document EXR in Quick Export command parameters --- crates/engine/src/web_cmds.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/web_cmds.rs b/crates/engine/src/web_cmds.rs index 333b2b0733..a5fe7539ca 100644 --- a/crates/engine/src/web_cmds.rs +++ b/crates/engine/src/web_cmds.rs @@ -1557,7 +1557,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 ), From 7c0d3072938765297f2c49c1d1d8e24dac8e27af Mon Sep 17 00:00:00 2001 From: Eddi Andree Salazar Matos Date: Wed, 21 Oct 2026 01:02:05 -0500 Subject: [PATCH 18/25] =?UTF-8?q?File=20names:=20keep=20Windows=20device?= =?UTF-8?q?=20names=20(nul,=20con,=20com1=E2=80=A6)=20off=20the=20disk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File › Package named its folder and document after the document with `file_cmds::sanitize`, which let Windows device names through. On Windows 11 a document named `nul` failed to package with "path not found (os error 3)"; on Windows 10 `con`, `prn`, `aux`, `comN` and `lptN` are devices too. `sanitize` now adds `_` before the first dot of a device name (`nul` -> `nul_`, `com1.v2` -> `com1_.v2`) on every platform, so a package written anywhere can be copied to Windows. The device check moves from web_cmds.rs to `file_cmds::is_windows_device_name`, and web_cmds uses it unchanged. The new package test fails on Windows without the fix. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/file_cmds.rs | 23 +++++++++++++++++++++-- crates/engine/src/file_cmds/tests.rs | 15 +++++++++++++++ crates/engine/src/print_cmds/tests.rs | 13 +++++++++++++ crates/engine/src/web_cmds.rs | 13 +++---------- 4 files changed, 52 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 3cec3e4492..d312100a2c 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -268,11 +268,30 @@ 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() { + return "layer".into(); + } + if !is_windows_device_name(&s) { + return s; + } + match s.split_once('.') { + Some((device, rest)) => format!("{device}_.{rest}"), + None => format!("{s}_"), + } +} + +/// 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. +pub(crate) fn is_windows_device_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")) } pub(crate) fn import(name: &str, bytes: &[u8]) -> Result { diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index 9b5ec43a78..eba3a771f0 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -1428,3 +1428,18 @@ fn batch_naming_rejects_outputs_that_replace_inputs_before_processing() { assert_eq!(std::fs::read(&first).unwrap(), before[0]); assert_eq!(std::fs::read(&second).unwrap(), before[1]); } + +#[test] +fn sanitize_keeps_windows_device_names_off_the_disk() { + assert_eq!(sanitize("nul"), "nul_"); + assert_eq!(sanitize("CON"), "CON_"); + assert_eq!(sanitize("com1.v2"), "com1_.v2"); + assert_eq!(sanitize("lpt9 .final"), "lpt9 _.final"); + for name in ["nul", "CON", "com1.v2", "lpt9 .final", "aux", "prn", "conin$", "clock$"] { + assert!(!is_windows_device_name(&sanitize(name)), "{name} -> {}", sanitize(name)); + } + // Names that only start like a device are files like any other. + for name in ["console", "normal", "com10", "lpt0", "nullify", "con_"] { + assert_eq!(sanitize(name), name); + } +} diff --git a/crates/engine/src/print_cmds/tests.rs b/crates/engine/src/print_cmds/tests.rs index 38567ffb6d..c8e8dde444 100644 --- a/crates/engine/src/print_cmds/tests.rs +++ b/crates/engine/src/print_cmds/tests.rs @@ -869,3 +869,16 @@ fn windows_pdf_component_is_detected_by_loading_its_dll() { fn the_windows_pdf_check_is_a_no_op_elsewhere() { assert!(require_windows_pdf().is_ok()); } + +#[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 755ba07320..60db3a3b5f 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, 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 } + if is_windows_device_name(&stem) { format!("{stem}_") } else { stem } } fn html_escape(s: &str) -> String { @@ -1307,13 +1307,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 @@ -1327,7 +1320,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:?}"))); } From 4062ac3211a1d069c8f104c0e28ab5124f2d1426 Mon Sep 17 00:00:00 2001 From: Eddi Andree Salazar Matos Date: Wed, 21 Oct 2026 01:04:33 -0500 Subject: [PATCH 19/25] Contributors: add my name (AndreeSalazar) Co-Authored-By: Claude Opus 5.5 --- contributors/people.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index b3cab8e04f..2efb55e02d 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -191,3 +191,7 @@ sync_github_name = true [people.ha1des] sync_github_name = true + +[people.AndreeSalazar] +real_name = "Andreé Salazar" +sync_github_name = true From 196238fe95f2fe49d538880742193af26480c3a6 Mon Sep 17 00:00:00 2001 From: Eddi Andree Salazar Matos Date: Wed, 21 Oct 2026 01:29:31 -0500 Subject: [PATCH 20/25] File names: superscript COM/LPT ports, and `_` before the dot in web exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups: - Windows also reserves COM¹–COM³ and LPT¹–LPT³ (the ISO 8859-1 superscript digits), with any extension. `is_windows_device_name` takes them too; `sanitize` keeps those characters, so `COM¹` became a device file name before. - Save for Web's `portable_generated_stem` added `_` at the end, so `path: nul.v2.png` gave `nul.v2_.html`, still the NUL device, and the export failed as an unsafe component. Both it and `sanitize` now share `portable_device_name`, which puts `_` before the first dot. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/file_cmds.rs | 25 +++++++++++++++---------- crates/engine/src/file_cmds/tests.rs | 6 ++++-- crates/engine/src/web_cmds.rs | 4 ++-- crates/engine/src/web_cmds/tests.rs | 13 +++++++++++++ 4 files changed, 34 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index d312100a2c..3abe519967 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -273,25 +273,30 @@ pub(crate) fn join(dir: &str, name: &str) -> String { 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() { - return "layer".into(); + 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; } - if !is_windows_device_name(&s) { - return s; - } - match s.split_once('.') { + match name.split_once('.') { Some((device, rest)) => format!("{device}_.{rest}"), - None => format!("{s}_"), + 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. +/// 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(|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")) + || 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 { diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index eba3a771f0..3f7633f1d9 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -1435,11 +1435,13 @@ fn sanitize_keeps_windows_device_names_off_the_disk() { assert_eq!(sanitize("CON"), "CON_"); assert_eq!(sanitize("com1.v2"), "com1_.v2"); assert_eq!(sanitize("lpt9 .final"), "lpt9 _.final"); - for name in ["nul", "CON", "com1.v2", "lpt9 .final", "aux", "prn", "conin$", "clock$"] { + assert_eq!(sanitize("COM¹"), "COM¹_"); + assert_eq!(sanitize("LPT².txt"), "LPT²_.txt"); + for name in ["nul", "CON", "com1.v2", "lpt9 .final", "aux", "prn", "conin$", "clock$", "COM¹", "lpt³.x"] { assert!(!is_windows_device_name(&sanitize(name)), "{name} -> {}", sanitize(name)); } // Names that only start like a device are files like any other. - for name in ["console", "normal", "com10", "lpt0", "nullify", "con_"] { + for name in ["console", "normal", "com10", "lpt0", "nullify", "con_", "com⁴", "com¹¹"] { assert_eq!(sanitize(name), name); } } diff --git a/crates/engine/src/web_cmds.rs b/crates/engine/src/web_cmds.rs index 60db3a3b5f..5f7433fb44 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::{is_windows_device_name, 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_name(&stem) { format!("{stem}_") } else { stem } + portable_device_name(stem) } fn html_escape(s: &str) -> String { diff --git a/crates/engine/src/web_cmds/tests.rs b/crates/engine/src/web_cmds/tests.rs index 5791e5f27c..abc849e1b1 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); From 50acc30111b0503da25d2dbd09addfaf17fec464 Mon Sep 17 00:00:00 2001 From: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:05:33 +0800 Subject: [PATCH 21/25] Return exported byte count from EXR Quick Export --- crates/engine/src/web_cmds.rs | 2 +- crates/engine/src/web_cmds/tests.rs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/web_cmds.rs b/crates/engine/src/web_cmds.rs index a5fe7539ca..db5482eeb3 100644 --- a/crates/engine/src/web_cmds.rs +++ b/crates/engine/src/web_cmds.rs @@ -1106,7 +1106,7 @@ fn quick_export(s: &mut Session, p: &Value) -> Result { 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, "warnings": out.warnings})); + 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(); diff --git a/crates/engine/src/web_cmds/tests.rs b/crates/engine/src/web_cmds/tests.rs index 6352293ee6..b384db3d0c 100644 --- a/crates/engine/src/web_cmds/tests.rs +++ b/crates/engine/src/web_cmds/tests.rs @@ -994,6 +994,7 @@ fn quick_export_exr_preserves_linear_hdr_and_alpha() { 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(); From 3e014505ad5e68d2124163613baf5018c91cb3e7 Mon Sep 17 00:00:00 2001 From: Aditya Vishwakarma <72960955+adi-1108@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:51:28 +0530 Subject: [PATCH 22/25] test(codecs): cover TGA packet boundaries Co-Authored-By: Claude Opus 5.5 --- crates/codecs/src/codecs/tga.rs | 85 +++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) 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"); + } + } + } +} From e2a238737dc7c9205fcf4426c53cfe4c52212cb4 Mon Sep 17 00:00:00 2001 From: bommerts <44821497+bommerts@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:15:22 -0400 Subject: [PATCH 23/25] Scale shape strokes when resampling documents (#8341) --- crates/engine/src/image_cmds.rs | 11 +- crates/engine/tests/export_shape_strokes.rs | 105 ++++++++++++++++++++ crates/ui-egui/src/export_dialog.rs | 30 ++++++ 3 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 crates/engine/tests/export_shape_strokes.rs diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index ab2a5b960f..8fc73d52b8 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -288,7 +288,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/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/ui-egui/src/export_dialog.rs b/crates/ui-egui/src/export_dialog.rs index 103b36a06e..907c2cb0b2 100644 --- a/crates/ui-egui/src/export_dialog.rs +++ b/crates/ui-egui/src/export_dialog.rs @@ -1229,6 +1229,36 @@ mod tests { } } + /// #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( From 2121cfecd037fe93897ba1b32781cbbeb02e4762 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 02:22:26 +0000 Subject: [PATCH 24/25] Integration: #7665 the near-one-second exposure test expects three significant digits Main's test expected 0.996 s to print as "1 s" (two decimals); the PR prints three significant digits, so it reads "0.996 s". Still never "1/1 s". --- crates/engine/src/file_cmds/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index 03b984be45..2eb30cee59 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -1509,7 +1509,7 @@ fn batch_naming_rejects_outputs_that_replace_inputs_before_processing() { #[test] fn exposure_text_near_one_second_is_not_one_over_one() { - assert_eq!(exposure_text(0.996).as_deref(), Some("1 s")); + assert_eq!(exposure_text(0.996).as_deref(), Some("0.996 s"), "not \"1/1 s\"; three significant digits"); assert_eq!(exposure_text(0.99).as_deref(), Some("0.99 s")); assert_eq!(exposure_text(1.0 / 250.0).as_deref(), Some("1/250 s")); } From d8acd0579a2521f61b5f7cc1f68dbd691c20729c Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 02:23:17 +0000 Subject: [PATCH 25/25] Integration: #7976 bound the ';' search in xml_unescape to 13 bytes A long run of '&' without a nearby ';' scanned to the end of the string for every '&' (quadratic). Adds a test with a 200k-ampersand description. --- crates/engine/src/file_cmds.rs | 2 +- crates/engine/tests/xmp_charref.rs | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 661fbba50a..db8a66b549 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -707,7 +707,7 @@ fn xml_unescape(s: &str) -> String { while let Some(i) = rest.find('&') { out.push_str(&rest[..i]); rest = &rest[i..]; - let decoded = rest.find(';').filter(|&e| e <= 12).and_then(|e| { + let decoded = rest.as_bytes().iter().take(13).position(|&b| b == b';').and_then(|e| { let c = match &rest[1..e] { "lt" => '<', "gt" => '>', diff --git a/crates/engine/tests/xmp_charref.rs b/crates/engine/tests/xmp_charref.rs index 5f167e8c4f..a4b98da4e5 100644 --- a/crates/engine/tests/xmp_charref.rs +++ b/crates/engine/tests/xmp_charref.rs @@ -8,3 +8,17 @@ fn numeric_character_references_are_decoded() { 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()); +}