diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index 3e870aa686..5f37a2eaa8 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -171,6 +171,12 @@ fn image_size(s: &mut Session, p: &Value) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; let (ow, oh) = (d.doc.size.width, d.doc.size.height); let resample = parse_resample(p.get("resample").and_then(Value::as_str).unwrap_or("bicubic")); + // An Indexed Color document holds only its colour table's colours, and a save quantises to + // them: interpolation showed blends that the saved file didn't have (#6019). + let resample = match resample { + Method::Filter(_) | Method::Automatic if d.doc.mode == ColorMode::Indexed => Method::Filter(Resample::Nearest), + m => m, + }; let mut pw = p.get("width").and_then(Value::as_f64); let mut ph = p.get("height").and_then(Value::as_f64); // `percent` scales both sides (what a Photoshop action records as "200%"); an explicit side wins. @@ -943,6 +949,27 @@ mod tests { /// #1544: 64 x 48 to 300000 x 300000 (90 billion pixels) started allocating resampled bands /// without a budget. It is refused before any allocation and the document is untouched; a /// change of resolution only, and an ordinary resize, still work. + #[test] + fn image_size_resamples_indexed_color_with_nearest_neighbor() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 2, "height": 1})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("select.rect", json!({"x": 1, "y": 0, "width": 1, "height": 1})).unwrap(); + s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("image.mode.indexedColor", json!({})).unwrap(); + for resample in ["bicubic", "automatic", "bilinear", "lanczos", "preserveDetails"] { + s.execute("image.imageSize", json!({"width": 8, "resample": resample})).unwrap(); + let st = s.active().unwrap(); + let surf = st.doc.layers[0].surface().unwrap(); + for x in 0..8 { + let px = surf.pixel(x, 0); + assert!(px[..3] == [1.0, 0.0, 0.0] || px[..3] == [0.0, 0.0, 1.0], "{resample}: no blended colour at x {x}: {px:?}"); + } + s.execute("history.undo", json!({})).unwrap(); + } + } + #[test] fn image_size_refuses_a_raster_past_the_budget() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/sizing.rs b/crates/ui-egui/src/sizing.rs index 3c48e041f4..c3ec7a199b 100644 --- a/crates/ui-egui/src/sizing.rs +++ b/crates/ui-egui/src/sizing.rs @@ -70,7 +70,7 @@ pub fn reset(f: &mut Map) { pub fn open(app: &mut PhotocraftApp, command: &str) -> Option { let unit = pref_unit(app.session.prefs().units_and_rulers.rulers); - let resample = pref_resample(app.session.prefs().general.image_interpolation); + let mut resample = pref_resample(app.session.prefs().general.image_interpolation); let pt_per_in = app.session.prefs().units_and_rulers.point_size.per_inch(); let d = &app.session.active()?.doc; let (w, h, res) = (d.size.width as f64, d.size.height as f64, d.resolution_dpi as f64); @@ -88,6 +88,12 @@ pub fn open(app: &mut PhotocraftApp, command: &str) -> Option { f.insert("width".into(), json!(w)); f.insert("height".into(), json!(h)); if command == "image.imageSize" { + // Indexed Color resamples with Nearest Neighbor only, as the command does (#6019). + let indexed = d.mode == photocraft_color::ColorMode::Indexed; + if indexed && resample != "automatic" { + resample = "nearest"; + } + f.insert("__indexed".into(), json!(indexed)); f.insert("__constrain".into(), json!(true)); f.insert("resolution".into(), json!(res)); f.insert("resample".into(), json!(resample)); @@ -321,6 +327,10 @@ fn image_size(ui: &mut egui::Ui, f: &mut Map) { ("bilinear".to_string(), tl!("Bilinear")), ("lanczos".to_string(), tl!("Lanczos (sharp)")), ]; + // Indexed Color: every other method blends colours the colour table doesn't have, + // which the command doesn't use and a save quantises away (#6019). + let indexed = f.get("__indexed").and_then(Value::as_bool) == Some(true); + let opts: Vec<_> = opts.into_iter().filter(|(k, _)| !indexed || k == "automatic" || k == "nearest").collect(); if crate::widgets::dropdown(ui, "is-resample", &mut cur, &opts, 200.0) { f.insert("resample".into(), json!(cur)); } @@ -517,6 +527,34 @@ mod tests { /// The dialog starts on Preferences › General › Image Interpolation: Bicubic Automatic opens /// as Automatic and the others as themselves (Bicubic Sharper used to open as Lanczos). + #[test] + fn indexed_color_resamples_with_nearest_neighbor_only() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.session.execute("image.mode.indexedColor", json!({})).unwrap(); + for (pref, method) in [("bicubic", "nearest"), ("bicubicAutomatic", "automatic"), ("nearestNeighbor", "nearest")] { + app.session.execute("prefs.set", json!({"path": "general.imageInterpolation", "value": pref})).unwrap(); + let id = open(&mut app, "image.imageSize").unwrap(); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!(f.get("resample"), Some(&json!(method)), "{pref}"); + assert_eq!(f.get("__indexed"), Some(&json!(true))); + app.ui.close_dialog(id); + } + // The open list offers only Automatic and Nearest Neighbor. + use egui_kittest::{Harness, kittest::Queryable}; + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + open(h.state_mut(), "image.imageSize").unwrap(); + h.run_steps(3); + let combo = h.query_all_by_role(egui::accesskit::Role::ComboBox).find(|n| n.value().as_deref() == Some("Nearest Neighbor (hard edges)")); + combo.expect("the Resample list shows Nearest Neighbor").click(); + h.run_steps(3); + assert!(h.query_by_label_contains("Automatic").is_some(), "Automatic is offered"); + for gone in ["Bicubic", "Bilinear", "Lanczos", "Preserve Details"] { + assert!(h.query_by_label_contains(gone).is_none(), "{gone} is not offered for Indexed Color"); + } + } + #[test] fn resample_follows_the_interpolation_preference() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());