Pictures: Set Transparent Color picks the colour by clicking the picture, as in PowerPoint (fixes #1217)

This commit is contained in:
Marco Arnulfo
2026-10-23 17:06:58 +02:00
parent b47195feec
commit 9eacd994bb
5 changed files with 203 additions and 29 deletions
+80 -3
View File
@@ -1,8 +1,9 @@
//! 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 deckcraft_geom::{Affine, Point, Rect, Xfrm};
use deckcraft_model::style::{PictureAdjust, PictureMode};
use deckcraft_model::{ColorRef, Shape, ShapeId, ShapeKind};
use serde_json::{Value, json};
use super::*;
@@ -27,7 +28,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Set Transparent Color",
["Picture Format", "Adjust", "Color"],
None,
"{color: #RRGGBB | none: true, ids?} (pixels of that colour become transparent)",
"{color: #RRGGBB | at: [x, y] | none: true, ids?} (pixels of that colour become transparent; `at` takes the colour of the picture's own pixel under that slide point, as a click does in PowerPoint)",
has_selection,
transparent_color
),
@@ -121,6 +122,15 @@ fn recolor(s: &mut Session, p: &Value) -> Result<Value> {
}
fn transparent_color(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(at) = p.get("at") {
let (id, c) = picture_pixel_at(s, p, at)?;
return edit_shapes(s, &json!({"ids": [id]}), "picture.transparentColor", |sh| {
if let ShapeKind::Picture { fill } = &mut sh.kind {
fill.adjust.clear_color = Some(c);
}
Ok(())
});
}
let c = if bool_or(p, "none", false) {
None
} else {
@@ -134,3 +144,70 @@ fn transparent_color(s: &mut Session, p: &Value) -> Result<Value> {
Ok(())
})
}
/// The first target picture under slide point `at` and the colour of its own pixel there: the
/// picture as stored, before corrections and recolouring, so the colour matches the pixels that
/// `clear_color` is compared with.
fn picture_pixel_at(s: &Session, p: &Value, at: &Value) -> Result<(ShapeId, Rgba)> {
const CMD: &str = "picture.transparentColor";
let xy = |i: usize| at.get(i).and_then(Value::as_f64).filter(|v| v.is_finite());
let (Some(x), Some(y)) = (xy(0), xy(1)) else {
return Err(bad(CMD, "`at` is [x, y] in slide units"));
};
let ids = targets(s, p)?;
let st = s.doc()?;
let shapes = current_shapes(&st.doc, &st.selection).ok_or_else(|| bad(CMD, "no slide"))?;
for id in ids {
let Some((sh, xf, to_slide)) = placed(&st.doc, &st.selection, shapes, id, Affine::IDENTITY, 0) else { continue };
let ShapeKind::Picture { fill } = &sh.kind else { continue };
let local = to_slide.inverse() * Point::new(x, y);
if !(local.is_finite() && (0.0..=xf.w).contains(&local.x) && (0.0..=xf.h).contains(&local.y)) {
continue;
}
let PictureMode::Stretch { fill_rect: [l, t, r, b] } = fill.mode else {
return Err(bad(CMD, "a tiled picture has no single pixel under a point; give `color`"));
};
let inner = Rect::new(l * xf.w, t * xf.h, xf.w - r * xf.w, xf.h - b * xf.h);
let dest = deckcraft_render::crop_dest(inner, fill.crop);
let img =
st.doc.media(fill.media).and_then(|m| deckcraft_render::decode_image(&m.data)).ok_or_else(|| bad(CMD, "the picture can't be read"))?;
let (u, v) = ((local.x - dest.x0) / dest.width(), (local.y - dest.y0) / dest.height());
if !((0.0..=1.0).contains(&u) && (0.0..=1.0).contains(&v)) {
continue;
}
let (w, h) = (img.width(), img.height());
let px = ((u * f64::from(w)) as u32).min(w.saturating_sub(1));
let py = ((v * f64::from(h)) as u32).min(h.saturating_sub(1));
let Some(c) = img.get_pixel_checked(px, py) else { continue };
return Ok((id, Rgba::rgb(c[0], c[1], c[2])));
}
Err(bad(CMD, "the point is not on a selected picture"))
}
/// Shape `id` among `shapes` (searched into groups), its box, and the map from that box to slide
/// space.
fn placed<'a>(
doc: &deckcraft_model::Presentation,
sel: &crate::Selection,
shapes: &'a [Shape],
id: ShapeId,
to_slide: Affine,
depth: usize,
) -> Option<(&'a Shape, Xfrm, Affine)> {
if depth > 64 {
return None;
}
for sh in shapes {
let Some(x) = (if depth == 0 { Some(xfrm_of(doc, sel, sh)) } else { sh.xfrm }) else { continue };
if sh.id == id {
return Some((sh, x, to_slide * x.affine()));
}
if let ShapeKind::Group { children, child } = &sh.kind {
let ch = Rect::new(child.x, child.y, child.x + child.w, child.y + child.h);
if let Some(found) = placed(doc, sel, children, id, to_slide * deckcraft_geom::group_child_affine(&x, ch), depth + 1) {
return Some(found);
}
}
}
None
}
+30
View File
@@ -2352,6 +2352,36 @@ fn picture_recolor_and_transparent_color() {
assert_eq!(adj(&s), deckcraft_model::style::PictureAdjust::default());
}
#[test]
fn transparent_color_at_a_point_takes_the_pictures_own_pixel_there() {
// Red on the left half, blue on the right.
let img = image::RgbaImage::from_fn(4, 2, |x, _| if x < 2 { image::Rgba([255, 0, 0, 255]) } else { image::Rgba([0, 0, 255, 255]) });
let mut png = vec![];
image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let mut s = session();
let id =
s.execute("insert.picture", &json!({"data": cmd::base64_encode(&png), "name": "a.png", "rect": [100, 100, 400, 200]})).unwrap()["id"].clone();
let clear = |s: &Session| match &s.doc().unwrap().shapes().iter().find(|sh| json!(sh.id) == id).unwrap().kind {
deckcraft_model::ShapeKind::Picture { fill } => fill.adjust.clear_color,
_ => panic!("not a picture"),
};
let red = deckcraft_color::Rgba::rgb(255, 0, 0);
let blue = deckcraft_color::Rgba::rgb(0, 0, 255);
s.execute("picture.transparentColor", &json!({"ids": [id], "at": [450, 200]})).unwrap();
assert_eq!(clear(&s), Some(blue));
// The picture's own colour, not the one drawn after a correction brightened it.
s.execute("picture.adjust", &json!({"ids": [id], "brightness": 0.4})).unwrap();
s.execute("picture.transparentColor", &json!({"ids": [id], "at": [150, 200]})).unwrap();
assert_eq!(clear(&s), Some(red));
// Flipped, the blue half is drawn on the left.
s.execute("shape.flip", &json!({"ids": [id], "axis": "horizontal"})).unwrap();
s.execute("picture.transparentColor", &json!({"ids": [id], "at": [150, 200]})).unwrap();
assert_eq!(clear(&s), Some(blue));
// Beside the picture there is no colour to take, and nothing changes.
assert!(s.execute("picture.transparentColor", &json!({"ids": [id], "at": [50, 50]})).is_err());
assert_eq!(clear(&s), Some(blue));
}
#[test]
fn select_all_slides_scope() {
let mut s = session();
+48 -14
View File
@@ -5,6 +5,9 @@
//! therefore hands its colour back through the same menu: the menu closes while the pointer picks,
//! then opens again for one frame and `color_grid` returns the colour as if its swatch had been
//! clicked, so every caller applies it its own way.
//!
//! Picture Format ▸ Color ▸ Set Transparent Color uses the same pointer, as PowerPoint does: the
//! click names the colour of the selected picture under it, which turns transparent.
use egui::{Color32, Context, CursorIcon, Id, Pos2, Rect, Stroke, StrokeKind, Ui, pos2, vec2};
@@ -13,15 +16,25 @@ use crate::canvas::Xf;
use crate::theme::{self, Tokens};
const REQUEST: &str = "deckcraft.eyedropper.request";
const TRANSPARENT: &str = "deckcraft.eyedropper.transparent";
const MENU: &str = "deckcraft.eyedropper.menu";
const RESULT: &str = "deckcraft.eyedropper.result";
/// Pixels per slide unit of the picture the Eyedropper looks at.
const SCALE: f64 = 2.0;
/// An Eyedropper waiting for a click: the slide as it is drawn and the menu button it came from.
/// What a click with the pointer does.
#[derive(Clone, Copy)]
enum Pick {
/// Hands the colour to the colour menu on this button.
Menu(Id),
/// Makes the colour of the selected picture under the pointer transparent.
TransparentColor,
}
/// An Eyedropper waiting for a click: the slide as it is drawn and what the click is for.
pub struct State {
menu: Id,
pick: Pick,
slide: usize,
image: deckcraft_render::Image,
}
@@ -50,6 +63,11 @@ pub fn request(ctx: &Context) {
ctx.data_mut(|d| d.insert_temp(key(REQUEST), true));
}
/// Picture Format ▸ Color ▸ "Set Transparent Color" was chosen.
pub fn request_transparent_color(ctx: &Context) {
ctx.data_mut(|d| d.insert_temp(key(TRANSPARENT), true));
}
/// Has the Eyedropper taken a colour for the menu on `button`?
pub fn result_waits_for(ctx: &Context, button: Id) -> bool {
ctx.data(|d| d.get_temp::<Option<(Id, String)>>(key(RESULT))).flatten().is_some_and(|(b, _)| b == button)
@@ -67,8 +85,13 @@ pub fn take_result(ctx: &Context) -> Option<String> {
/// Once a frame: start the Eyedropper when a menu asked for it, stop it when something else is
/// clicked.
pub fn tick(app: &mut SlideApp, ctx: &Context) {
if ctx.data_mut(|d| d.remove_temp::<bool>(key(REQUEST))).unwrap_or(false) {
begin(app, ctx);
if ctx.data_mut(|d| d.remove_temp::<bool>(key(REQUEST))).unwrap_or(false)
&& let Some(menu) = open_menu(ctx)
{
begin(app, Pick::Menu(menu));
}
if ctx.data_mut(|d| d.remove_temp::<bool>(key(TRANSPARENT))).unwrap_or(false) {
begin(app, Pick::TransparentColor);
}
if app.eyedropper.is_some() {
let outside =
@@ -79,8 +102,7 @@ pub fn tick(app: &mut SlideApp, ctx: &Context) {
}
}
fn begin(app: &mut SlideApp, ctx: &Context) {
let Some(menu) = open_menu(ctx) else { return };
fn begin(app: &mut SlideApp, pick: Pick) {
let Some(d) = app.session.active() else { return };
if d.selection.target != deckcraft_engine::Target::Slides {
app.set_status("The Eyedropper takes colours from a slide");
@@ -88,8 +110,11 @@ fn begin(app: &mut SlideApp, ctx: &Context) {
}
let slide = d.selection.slide;
let image = deckcraft_render::render_slide(&d.doc, slide, &deckcraft_render::RenderOpts { scale: SCALE, ..Default::default() });
app.eyedropper = Some(State { menu, slide, image });
app.set_status("Eyedropper: click the slide (Esc cancels)");
app.eyedropper = Some(State { pick, slide, image });
app.set_status(match pick {
Pick::Menu(_) => "Eyedropper: click the slide (Esc cancels)",
Pick::TransparentColor => "Set Transparent Color: click the colour in the picture (Esc cancels)",
});
}
fn hex(c: [u8; 4]) -> String {
@@ -121,15 +146,24 @@ pub fn canvas(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) -> boo
loupe(ui, pos, c);
}
if resp.clicked() {
let (menu, slide) = (state.menu, state.slide);
let (pick, slide) = (state.pick, state.slide);
app.eyedropper = None;
match app.session.execute("slide.colorAt", &serde_json::json!({"slide": slide, "x": p.x, "y": p.y, "scale": SCALE})) {
Ok(v) => {
if let Some(color) = v["color"].as_str() {
store_result(ui.ctx(), menu, color);
match pick {
Pick::Menu(menu) => {
match app.session.execute("slide.colorAt", &serde_json::json!({"slide": slide, "x": p.x, "y": p.y, "scale": SCALE})) {
Ok(v) => {
if let Some(color) = v["color"].as_str() {
store_result(ui.ctx(), menu, color);
}
}
Err(e) => app.set_status(e.to_string()),
}
}
Pick::TransparentColor => {
if let Err(e) = app.session.execute("picture.transparentColor", &serde_json::json!({"at": [p.x, p.y]})) {
app.set_status(e.to_string());
}
}
Err(e) => app.set_status(e.to_string()),
}
ui.ctx().request_repaint();
}
+39
View File
@@ -4,6 +4,7 @@
use deckcraft_engine::Session;
use egui::{Event, Id, Key, Modifiers, PointerButton, Pos2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
use crate::eyedropper::{note_open_menu, request, result_waits_for, take_result};
@@ -111,3 +112,41 @@ fn escape_and_a_click_off_the_slide_take_nothing() {
other.run_steps(3);
assert!(other.state().eyedropper.is_none());
}
#[test]
fn set_transparent_color_takes_the_colour_clicked_in_the_picture() {
// Red on the left half, blue on the right.
let pixels = (0..8).flat_map(|i| if i % 4 < 2 { [255, 0, 0, 255] } else { [0, 0, 255, 255] }).collect();
let png = deckcraft_render::Image { width: 4, height: 2, pixels }.to_png();
let mut session = Session::with_new();
session.execute("slide.new", &json!({"layout": "blank"})).unwrap();
let data = deckcraft_engine::cmd::base64_encode(&png);
session.execute("insert.picture", &json!({"data": data, "name": "a.png", "rect": [100, 100, 400, 200]})).unwrap();
let mut app = SlideApp::new(session, Services::default());
app.ui.tab = "Picture Format".into();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1200.0)).build_ui_state(
|ui, app: &mut SlideApp| {
app.logic(ui.ctx());
app.ui(ui);
},
app,
);
h.run_steps(4);
// A colour menu opened earlier (Shape Fill, say) must not receive the colour.
note_open_menu(&h.ctx, menu());
h.get_by_label("Color").click();
h.run_steps(3);
h.get_by_label("Set Transparent Color").click();
h.run_steps(3);
assert!(h.state().eyedropper.is_some(), "the command arms the pointer");
let blue_half = at(&h, 450.0, 200.0);
click(&mut h, blue_half);
assert!(h.state().eyedropper.is_none(), "one click ends it");
assert!(!result_waits_for(&h.ctx, menu()), "the colour went to the picture, not to another menu");
let doc = h.state().session.doc().unwrap();
let clear = doc.shapes().iter().find_map(|sh| match &sh.kind {
deckcraft_model::ShapeKind::Picture { fill } => Some(fill.adjust.clear_color),
_ => None,
});
assert_eq!(clear, Some(Some(deckcraft_color::Rgba::rgb(0, 0, 255))));
}
+6 -12
View File
@@ -2636,18 +2636,12 @@ fn picture_format(app: &mut SlideApp, ui: &mut Ui) {
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})),
}
}
});
// As in PowerPoint, the pointer picks the colour in the picture itself: a colour
// chosen from a grid almost never matches a photo's pixels.
if ui.button("Set Transparent Color").on_hover_text("Click a colour in the picture to make it transparent").clicked() {
crate::eyedropper::request_transparent_color(ui.ctx());
ui.close();
}
});
let r = big_button(ui, Icon::ArtisticEffects, "Artistic\nEffects", enabled(app, "picture.artisticEffect"));
egui::Popup::menu(&r).show(|ui| {