mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-26 03:27:03 -06:00
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:
@@ -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());
|
||||
|
||||
@@ -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(())
|
||||
})
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user