Merge PR #6035: Image Size: Indexed Color resamples with Nearest Neighbor only (fixes #6019)

This commit is contained in:
aiongg committed 2026-10-18 14:18:58 +00:00
commit 17e3490142
2 files changed
+66 -1

No files matched your search

+27
View File
@@ -171,6 +171,12 @@ fn image_size(s: &mut Session, p: &Value) -> Result<Value> {
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();
+39 -1
View File
@@ -70,7 +70,7 @@ pub fn reset(f: &mut Map<String, Value>) {
pub fn open(app: &mut PhotocraftApp, command: &str) -> Option<u64> {
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<u64> {
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<String, Value>) {
("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());