Merge PR #547 from NikitkaRa: Pictures: Recolor gallery and Set Transparent Color

# Conflicts:
#	crates/engine/src/cmd/mod.rs
#	crates/engine/src/tests.rs
#	crates/ui-egui/src/ribbon.rs
This commit is contained in:
Brandon Thomas
2026-10-14 13:23:51 -04:00
5 changed files with 250 additions and 3 deletions
+2
View File
@@ -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());
+136
View File
@@ -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<CommandSpec> {
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::<Vec<_>>()
))),
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<Value> {
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<Value> {
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(())
})
}
+48
View File
@@ -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());
}
+63 -2
View File
@@ -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<String> = 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<deckcraft_engine::
}
}
/// A Recolor gallery tile: a dark-to-light ramp as the preset would draw it.
fn paint_recolor(p: &egui::Painter, r: Rect, id: &str, sc: &deckcraft_color::ColorScheme) {
let mut adj = deckcraft_model::style::PictureAdjust::default();
deckcraft_engine::cmd::recolor::apply(&mut adj, id);
let r = r.shrink(3.0);
let n = 8;
for k in 0..n {
let t = k as f64 / (n - 1) as f64;
// A colourful ramp, so "No Recolor" shows colour and the presets show their mapping.
let src = [t * 255.0, (0.4 + 0.6 * t) * 200.0, (1.0 - t) * 220.0];
let y = 0.299 * src[0] + 0.587 * src[1] + 0.114 * src[2];
let mut rgb = if adj.grayscale { [y; 3] } else { src };
if adj.brightness != 0.0 || adj.contrast != 0.0 {
rgb = rgb.map(|v| (v - 127.5) * (1.0 + adj.contrast) + 127.5 + adj.brightness * 255.0);
}
if let Some((d, l)) = &adj.duotone {
let (d, l) = (d.resolve(sc, None), l.resolve(sc, None));
let u = y / 255.0;
rgb =
[d.r as f64 + (l.r as f64 - d.r as f64) * u, d.g as f64 + (l.g as f64 - d.g as f64) * u, d.b as f64 + (l.b as f64 - d.b as f64) * u];
}
if let Some(th) = adj.bilevel {
rgb = [if y >= 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,
+1 -1
View File
@@ -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 |