diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 9e6caa21..aeda4190 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -14,6 +14,7 @@ pub mod media; pub mod merge; pub mod points; pub mod picture_style; +pub mod recolor; pub mod review; pub mod selection; pub mod shape; @@ -136,6 +137,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(shape::specs()); v.extend(background::specs()); v.extend(picture_style::specs()); + v.extend(recolor::specs()); v.extend(arrange::specs()); v.extend(selection::specs()); v.extend(merge::specs()); diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs new file mode 100644 index 00000000..3ee049eb --- /dev/null +++ b/crates/engine/src/cmd/recolor.rs @@ -0,0 +1,136 @@ +//! Picture Format ▸ Color: Recolor presets and Set Transparent Color. + +use deckcraft_color::{Rgba, SchemeSlot}; +use deckcraft_model::style::PictureAdjust; +use deckcraft_model::{ColorRef, ShapeKind}; +use serde_json::{Value, json}; + +use super::*; +use crate::{Result, Session}; + +pub fn specs() -> Vec { + vec![ + cmd!( + "picture.recolor", + "Recolor", + ["Picture Format", "Adjust", "Color"], + None, + "{preset: none|grayscale|sepia|washout|bw25|bw50|bw75|accent1Dark…accent6Dark|accent1Light…accent6Light|tx2Dark|tx2Light (see picture.recolors), ids?}", + has_selection, + recolor + ), + cmd!(query "picture.recolors", "Recolor Gallery", [], None, "{} → [{id, label}]", always, |_s, _p| Ok(json!( + presets().into_iter().map(|(id, label)| json!({"id": id, "label": label})).collect::>() + ))), + cmd!( + "picture.transparentColor", + "Set Transparent Color", + ["Picture Format", "Adjust", "Color"], + None, + "{color: #RRGGBB | none: true, ids?} (pixels of that colour become transparent)", + has_selection, + transparent_color + ), + ] +} + +const ACCENTS: [(&str, &str, SchemeSlot); 7] = [ + ("tx2", "Text color 2", SchemeSlot::Dk2), + ("accent1", "Accent color 1", SchemeSlot::Accent1), + ("accent2", "Accent color 2", SchemeSlot::Accent2), + ("accent3", "Accent color 3", SchemeSlot::Accent3), + ("accent4", "Accent color 4", SchemeSlot::Accent4), + ("accent5", "Accent color 5", SchemeSlot::Accent5), + ("accent6", "Accent color 6", SchemeSlot::Accent6), +]; + +/// (id, label), in PowerPoint's gallery order: colour modes, then dark and light variations. +pub fn presets() -> Vec<(String, String)> { + let mut v: Vec<(String, String)> = [ + ("none", "No Recolor"), + ("grayscale", "Grayscale"), + ("sepia", "Sepia"), + ("washout", "Washout"), + ("bw25", "Black and White: 25%"), + ("bw50", "Black and White: 50%"), + ("bw75", "Black and White: 75%"), + ] + .iter() + .map(|(a, b)| (a.to_string(), b.to_string())) + .collect(); + for (id, label, _) in ACCENTS { + v.push((format!("{id}Dark"), format!("{label} Dark"))); + } + for (id, label, _) in ACCENTS { + v.push((format!("{id}Light"), format!("{label} Light"))); + } + v +} + +/// Set `a`'s recolour to `preset`, clearing any earlier one. `false` for an unknown preset. +pub fn apply(a: &mut PictureAdjust, preset: &str) -> bool { + let washed = a.brightness == 0.7 && a.contrast == -0.7; + let mut n = PictureAdjust { grayscale: false, duotone: None, bilevel: None, ..a.clone() }; + if washed { + n.brightness = 0.0; + n.contrast = 0.0; + } + let rgb = |r, g, b| ColorRef::rgb(Rgba::rgb(r, g, b)); + match preset { + "none" => {} + "grayscale" => n.grayscale = true, + // PowerPoint's sepia is a duotone from dark brown to light cream. + "sepia" => n.duotone = Some((rgb(0x40, 0x2A, 0x10), rgb(0xF2, 0xE3, 0xC6))), + // Washout is PowerPoint's +70% brightness, −70% contrast. + "washout" => { + n.brightness = 0.7; + n.contrast = -0.7; + } + "bw25" => n.bilevel = Some(0.25), + "bw50" => n.bilevel = Some(0.5), + "bw75" => n.bilevel = Some(0.75), + other => { + let Some((slot, dark)) = ACCENTS.iter().find_map(|(id, _, slot)| { + other.strip_prefix(id).and_then(|rest| match rest { + "Dark" => Some((*slot, true)), + "Light" => Some((*slot, false)), + _ => None, + }) + }) else { + return false; + }; + let c = ColorRef::scheme(slot); + n.duotone = Some(if dark { (rgb(0, 0, 0), c) } else { (c, rgb(0xFF, 0xFF, 0xFF)) }); + } + } + *a = n; + true +} + +fn recolor(s: &mut Session, p: &Value) -> Result { + let preset = str_param(p, "preset").ok_or_else(|| bad("picture.recolor", "missing `preset`"))?.to_string(); + if !presets().iter().any(|(id, _)| *id == preset) { + return Err(bad("picture.recolor", format!("unknown preset `{preset}`; see picture.recolors"))); + } + edit_shapes(s, p, "picture.recolor", |sh| { + if let ShapeKind::Picture { fill } = &mut sh.kind { + apply(&mut fill.adjust, &preset); + } + Ok(()) + }) +} + +fn transparent_color(s: &mut Session, p: &Value) -> Result { + let c = if bool_or(p, "none", false) { + None + } else { + let hex = str_param(p, "color").ok_or_else(|| bad("picture.transparentColor", "give `color` (#RRGGBB) or `none: true`"))?; + Some(Rgba::from_hex(hex.trim_start_matches('#')).ok_or_else(|| bad("picture.transparentColor", format!("bad colour `{hex}`")))?) + }; + edit_shapes(s, p, "picture.transparentColor", |sh| { + if let ShapeKind::Picture { fill } = &mut sh.kind { + fill.adjust.clear_color = c; + } + Ok(()) + }) +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 0efb3113..0010d493 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1762,3 +1762,51 @@ fn picture_styles_replace_each_other_in_one_step() { assert!(pic(&s).effects.is_none() && pic(&s).line.is_none()); assert!(s.execute("picture.style", &json!({"ids": [id], "style": "sparkly"})).is_err()); } + +#[test] +fn picture_recolor_and_transparent_color() { + const PNG: &[u8] = &[ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, + 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, 0xcf, + 0xc0, 0xf0, 0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, + 0x82, + ]; + let mut s = session(); + let id = s.execute("insert.picture", &json!({"data": cmd::base64_encode(PNG), "name": "a.png"})).unwrap()["id"].clone(); + let adj = |s: &Session| match &s.doc().unwrap().shapes().iter().find(|sh| json!(sh.id) == id).unwrap().kind { + deckcraft_model::ShapeKind::Picture { fill } => fill.adjust.clone(), + _ => panic!("not a picture"), + }; + assert_eq!(s.execute("picture.recolors", &json!({})).unwrap().as_array().unwrap().len(), 21); + s.execute("picture.recolor", &json!({"ids": [id], "preset": "bw50"})).unwrap(); + assert_eq!(adj(&s).bilevel, Some(0.5)); + // A new preset replaces the old one. + s.execute("picture.recolor", &json!({"ids": [id], "preset": "accent2Light"})).unwrap(); + let a = adj(&s); + assert!(a.bilevel.is_none()); + assert!(matches!(&a.duotone, Some((d, _)) if *d == deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent2))); + s.execute("picture.recolor", &json!({"ids": [id], "preset": "washout"})).unwrap(); + let a = adj(&s); + assert!(a.duotone.is_none() && a.brightness == 0.7 && a.contrast == -0.7); + s.execute("picture.recolor", &json!({"ids": [id], "preset": "grayscale"})).unwrap(); + let a = adj(&s); + assert!(a.grayscale && a.brightness == 0.0, "washout undone by the next preset"); + assert!(s.execute("picture.recolor", &json!({"ids": [id], "preset": "plaid"})).is_err()); + + s.execute("picture.transparentColor", &json!({"ids": [id], "color": "#FFFFFF"})).unwrap(); + assert_eq!(adj(&s).clear_color, Some(deckcraft_color::Rgba::rgb(255, 255, 255))); + // Both survive PPTX. + let doc = s.doc().unwrap().doc.as_ref().clone(); + let back = deckcraft_pptx::import(&deckcraft_pptx::export(&doc).unwrap()).unwrap(); + let ba = back.slides.iter().flat_map(|sl| &sl.shapes).find_map(|sh| match &sh.kind { + deckcraft_model::ShapeKind::Picture { fill } => Some(fill.adjust.clone()), + _ => None, + }); + let ba = ba.unwrap(); + assert!(ba.grayscale); + assert_eq!(ba.clear_color, Some(deckcraft_color::Rgba::rgb(255, 255, 255))); + s.execute("picture.transparentColor", &json!({"ids": [id], "none": true})).unwrap(); + assert!(adj(&s).clear_color.is_none()); + s.execute("picture.recolor", &json!({"ids": [id], "preset": "none"})).unwrap(); + assert_eq!(adj(&s), deckcraft_model::style::PictureAdjust::default()); +} diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 9ea5cd1d..3e240a8a 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2333,9 +2333,39 @@ fn picture_format(app: &mut SlideApp, ui: &mut Ui) { run(app, "picture.adjust", json!({"saturation": s})); } } - if ui.button("Grayscale").clicked() { - run(app, "picture.adjust", json!({"grayscale": true})); + ui.separator(); + ui.label("Recolor"); + let sc = scheme(app); + let presets = deckcraft_engine::cmd::recolor::presets(); + let names: Vec = presets.iter().map(|(_, l)| l.clone()).collect(); + if let Some(i) = widgets::gallery( + ui, + presets.len(), + vec2(40.0, 30.0), + None, + |p, r, i| { + if let Some((id, _)) = presets.get(i) { + paint_recolor(p, r, id, &sc); + } + }, + |i| names.get(i).cloned().unwrap_or_default(), + ) && let Some((id, _)) = presets.get(i) + { + run(app, "picture.recolor", json!({"preset": id})); } + ui.separator(); + ui.menu_button("Set Transparent Color", |ui| { + ui.label("Pixels of this colour become transparent:"); + if let Some(c) = widgets::color_grid(ui, &sc, Some("None")) { + match c { + Some(c) => { + let rgb = c.resolve(&sc, None); + run(app, "picture.transparentColor", json!({"color": format!("#{}", rgb.hex())})); + } + None => run(app, "picture.transparentColor", json!({"none": true})), + } + } + }); }); let r = big_button(ui, Icon::Transparency, "Transparency", true); egui::Popup::menu(&r).show(|ui| { @@ -2557,6 +2587,37 @@ fn paint_picture_style(p: &egui::Painter, r: Rect, lk: Option= th * 255.0 { 255.0 } else { 0.0 }; 3]; + } + let c = Color32::from_rgb(rgb[0].clamp(0.0, 255.0) as u8, rgb[1].clamp(0.0, 255.0) as u8, rgb[2].clamp(0.0, 255.0) as u8); + let w = r.width() / n as f32; + p.rect_filled(Rect::from_min_size(pos2(r.left() + k as f32 * w, r.top()), vec2(w + 0.5, r.height())), CornerRadius::ZERO, c); + } + p.rect_stroke(r, CornerRadius::ZERO, Stroke::new(0.5, Color32::from_gray(160)), egui::StrokeKind::Outside); +} + pub fn paint_table_style(p: &egui::Painter, r: Rect, id: &str, sc: &deckcraft_color::ColorScheme) { let slot = match id.rsplit('-').next().unwrap_or("accent1") { "accent2" => deckcraft_color::SchemeSlot::Accent2, diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index f41476d1..6ff7b230 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -183,7 +183,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Pictures | Insert picture (PNG, JPEG, GIF, WebP, BMP, TIFF) | P0 | D | | Pictures | SVG pictures | P1 | M | | Pictures | Crop (and crop to shape, aspect ratio) | P0 | P | -| Pictures | Corrections, colour, transparency | P1 | P | +| Pictures | Corrections, colour, transparency (recolor and Set Transparent Color done; sharpen/soften and colour temperature pending) | P1 | P | | Pictures | Artistic effects | P2 | M | | Pictures | Remove background | P2 | M | | Pictures | Picture styles and borders | P1 | D |