feat(eyedropper): Sample a placed image's colour into the selection

The Eyedropper on a placed image now does what InDesign does: with text
selected, clicking the photo colours the text with the image's colour under
the pointer; with objects selected, it fills them. The colour is an unnamed
colour (reused when the same colour is sampled again). Images on locked
layers can be sampled, and unfilled frames over the image let it show through.

- color.sample {at, spread?, target?: fill|stroke, ids?} samples the frontmost
  visible raster image at a spread point; one undo step; a caret or a point
  with no image is refused without an edit.
- object.color shares its swatch lookup with it.
- The Eyedropper tool sends color.sample over an image, matchAttributes
  elsewhere as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
moppeeer 2026-10-12 10:48:39 +02:00 committed by paaspaas00
parent d020b4e85d
commit d7d6f27069
10 changed files with 320 additions and 33 deletions

View File

@ -132,6 +132,7 @@ Newest first.
- **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change.
- **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change.
- **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change.
- **2026-10-12** · The Eyedropper samples a placed image: clicking the photo with text (or objects) selected fills them with the image's colour under the pointer, as an unnamed colour (`color.sample`), as in InDesign. Images on locked layers can be sampled. No readiness percentage change.
- **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change.
- **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change.
- **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change.

View File

@ -184,6 +184,15 @@ pub fn specs() -> Vec<CommandSpec> {
super::has_selection,
apply_color
),
cmd!(
"color.sample",
"Sample Color (Eyedropper)",
[],
None,
"{at: [x,y] (spread coords), spread?, target?: fill|stroke, ids?} → {color: \"#rrggbb\", swatch, from: image id} — the Eyedropper on a placed image: the image's colour at the point (an unnamed colour) fills the selected text, else the selected objects",
has_text_or_selection,
sample_color
),
cmd!(
"swatch.addToSwatches",
"Add to Swatches",
@ -957,41 +966,149 @@ fn apply_color(s: &mut Session, p: &Value) -> Result<Value> {
let stroke = str_param(p, "target") == Some("stroke");
let ids = super::targets(s, p)?;
s.edit(|d, _| {
// Reuse a swatch holding exactly this colour, else add an unnamed one.
let existing = d
.swatches
.iter()
.find(|w| matches!(&w.value, SwatchValue::Color { color: c, color_type: designcraft_color::ColorType::Process } if *c == color))
.map(|w| w.name.clone());
let name = match existing {
Some(n) => n,
None => {
let name = Styles::unique_name(|n| d.swatch(n).is_some(), &value_name(color));
d.swatches.push(Swatch {
name: name.clone(),
value: SwatchValue::Color { color, color_type: designcraft_color::ColorType::Process },
locked: false,
named: false,
hidden: true,
});
name
}
};
for id in &ids {
if let Some(it) = d.item_mut(*id) {
if stroke {
it.stroke.swatch = name.clone();
it.stroke.tint = 1.0;
} else {
it.fill.swatch = name.clone();
it.fill.tint = 1.0;
}
}
}
let name = color_swatch(d, color);
color_items(d, &ids, &name, stroke);
Ok(json!({"swatch": name}))
})
}
/// A process swatch holding exactly `color`, else a new unnamed one (hidden from the Swatches
/// panel until Add to Swatches).
fn color_swatch(d: &mut designcraft_doc::Document, color: designcraft_color::Color) -> String {
let existing = d
.swatches
.iter()
.find(|w| matches!(&w.value, SwatchValue::Color { color: c, color_type: designcraft_color::ColorType::Process } if *c == color))
.map(|w| w.name.clone());
if let Some(n) = existing {
return n;
}
let name = Styles::unique_name(|n| d.swatch(n).is_some(), &value_name(color));
d.swatches.push(Swatch {
name: name.clone(),
value: SwatchValue::Color { color, color_type: designcraft_color::ColorType::Process },
locked: false,
named: false,
hidden: true,
});
name
}
fn color_items(d: &mut designcraft_doc::Document, ids: &[designcraft_doc::ItemId], name: &str, stroke: bool) {
for id in ids {
if let Some(it) = d.item_mut(*id) {
if stroke {
it.stroke.swatch = name.to_string();
it.stroke.tint = 1.0;
} else {
it.fill.swatch = name.to_string();
it.fill.tint = 1.0;
}
}
}
}
fn has_text_or_selection(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
if s.active().is_some_and(|d| d.selection.text.is_some() || d.selection.cells.is_some()) { Ok(()) } else { super::has_selection(s) }
}
/// The Eyedropper on a placed image (InDesign samples the pixel under the pointer): that colour,
/// as an unnamed colour, fills the selected text (characters), or the selected objects.
fn sample_color(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "color.sample";
let at = super::point_param(p, "at").ok_or_else(|| bad(ID, "missing at"))?;
let sr = super::spread_param(p, "spread");
let stroke = str_param(p, "target") == Some("stroke");
let named = p.get("ids").and_then(Value::as_array).is_some_and(|a| !a.is_empty()) || p.get("id").is_some();
let st = s.doc()?;
let (from, color) = image_color_at(&st.doc, sr, at).ok_or_else(|| bad(ID, "no placed image under the point"))?;
let hex = color.to_hex();
if named || (st.selection.text.is_none() && st.selection.cells.is_none()) {
let ids = super::targets(s, p)?;
return s.edit(|d, _| {
let name = color_swatch(d, color);
color_items(d, &ids, &name, stroke);
Ok(json!({"color": hex, "swatch": name, "from": from.0}))
});
}
// A caret colours nothing (as with a swatch click): say so instead of an empty undo step.
let targets: Vec<_> = super::text::format_targets(s).into_iter().filter(|t| !t.range.is_empty()).collect();
if targets.is_empty() {
return Err(bad(ID, "select the text to colour"));
}
s.edit(|d, _| {
let name = color_swatch(d, color);
for t in &targets {
let Some(story) = d.text_story_mut(t.story, t.cell) else { continue };
story.format_chars(t.range.clone(), |f| {
if stroke {
f.over.stroke = Some(name.clone());
f.over.stroke_tint = Some(1.0);
} else {
f.over.fill = Some(name.clone());
f.over.fill_tint = Some(1.0);
}
});
}
Ok(json!({"color": hex, "swatch": name, "from": from.0}))
})
}
/// The colour of the placed raster image at `at` on spread `sr`: the frontmost visible image whose
/// frame shows that point (locked ones too, as sampling changes nothing). (image frame, colour);
/// None over no image, a transparent pixel, or a graphic that is not a raster (PDF, EPS).
pub(crate) fn image_color_at(
d: &designcraft_doc::Document,
sr: designcraft_doc::SpreadRef,
at: designcraft_geom::Point,
) -> Option<(designcraft_doc::ItemId, designcraft_color::Color)> {
let sp = d.spread(sr)?;
let mut items: Vec<&designcraft_doc::Item> = sp.items.iter().map(|i| &**i).collect();
items.sort_by_key(|i| d.layer_rank(i.layer));
items
.into_iter()
.rev()
.filter(|it| d.layer(it.layer).is_none_or(|l| l.visible))
.find_map(|it| sample_item(d, it, designcraft_geom::Affine::IDENTITY, at, 0))
}
fn sample_item(
d: &designcraft_doc::Document,
it: &designcraft_doc::Item,
outer: designcraft_geom::Affine,
at: designcraft_geom::Point,
depth: usize,
) -> Option<(designcraft_doc::ItemId, designcraft_color::Color)> {
use designcraft_doc::Content;
if it.hidden || depth > 64 {
return None;
}
let xf = outer * it.xf;
match &it.content {
Content::Group { items } => items.iter().rev().find_map(|c| sample_item(d, c, xf, at, depth + 1)),
Content::Graphic(g) => {
let inner = xf.inverse() * at;
if !designcraft_geom::hit::fill_contains(&it.path.to_bezpath(), designcraft_geom::FillRule::NonZero, inner) {
return None;
}
let gp = g.xf.inverse() * inner;
let (w, h) = g.size;
// Written so that NaN (a degenerate transform) is outside too.
if !(w > 0.0 && h > 0.0 && gp.x >= 0.0 && gp.y >= 0.0 && gp.x < w && gp.y < h) {
return None;
}
let img = designcraft_images::decode_rgba(&d.assets.get(&g.asset)?.data)?;
let (pw, ph) = img.dimensions();
let px = ((gp.x / w * f64::from(pw)) as u32).min(pw.checked_sub(1)?);
let py = ((gp.y / h * f64::from(ph)) as u32).min(ph.checked_sub(1)?);
let [r, gr, b, a] = img.get_pixel_checked(px, py)?.0;
(a > 0).then(|| (it.id, designcraft_color::Color::rgb8(r, gr, b)))
}
_ => None,
}
}
fn add_to_swatches(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let name = match str_param(p, "swatch") {
@ -1493,3 +1610,85 @@ mod cjk_tests {
assert!(s.execute("style.compositeFont.set", &definition(-1.0)).is_err());
}
}
#[cfg(test)]
mod sample_color_tests {
use designcraft_color::{Color, SwatchValue};
use designcraft_doc::ItemId;
use serde_json::json;
use crate::Session;
/// A 2×1 image, red then blue, placed 144 pt wide at (72, 72): each pixel is 72 pt.
fn session_with_image() -> (Session, u64) {
let png = designcraft_render::Rendered { width: 2, height: 1, pixels: vec![255, 0, 0, 255, 0, 0, 255, 255] }.to_png();
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let b64 = super::super::file::base64_encode(&png);
let r = s.execute("file.place", &json!({"base64": b64, "name": "rb.png", "x": 72, "y": 72, "width": 144})).unwrap();
(s, r["id"].as_u64().unwrap())
}
fn swatch_color(s: &Session, name: &str) -> Color {
match &s.doc().unwrap().doc.swatch(name).unwrap().value {
SwatchValue::Color { color, .. } => *color,
other => panic!("{other:?}"),
}
}
#[test]
fn the_eyedropper_colours_selected_text_with_the_image_under_it() {
let (mut s, image) = session_with_image();
let f = s.execute("frame.create", &json!({"rect": [300, 300, 500, 360], "content": "text", "text": "Elevate."})).unwrap();
let fid = ItemId(f["id"].as_u64().unwrap());
let story = s.doc().unwrap().doc.item(fid).unwrap().text_frame().unwrap().story;
s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 7})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
let r = s.execute("color.sample", &json!({"at": [180, 100]})).unwrap();
assert_eq!((r["color"].as_str(), r["from"].as_u64()), (Some("#0000ff"), Some(image)), "{r}");
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
let name = r["swatch"].as_str().unwrap().to_string();
assert_eq!(swatch_color(&s, &name), Color::rgb8(0, 0, 255));
assert!(s.doc().unwrap().doc.swatch(&name).unwrap().hidden, "an unnamed colour");
let st = s.doc().unwrap().doc.story(story).unwrap().clone();
assert_eq!(st.format_after(0).over.fill.as_deref(), Some(name.as_str()));
assert_eq!(st.format_after(7).over.fill, None, "the full stop keeps its colour");
// The frame itself is not coloured.
assert!(s.doc().unwrap().doc.item(fid).unwrap().fill.is_none());
}
#[test]
fn the_eyedropper_fills_selected_objects_and_samples_locked_images() {
let (mut s, _) = session_with_image();
let lid = s.doc().unwrap().doc.layers[0].id.0;
s.execute("layer.set", &json!({"id": lid, "locked": true})).unwrap();
s.execute("layer.new", &json!({"name": "TEXT"})).unwrap();
let rect = s.execute("frame.create", &json!({"rect": [300, 300, 400, 400]})).unwrap()["id"].as_u64().unwrap();
s.execute("selection.set", &json!({"ids": [rect]})).unwrap();
let r = s.execute("color.sample", &json!({"at": [100, 100], "target": "stroke"})).unwrap();
assert_eq!(r["color"], "#ff0000", "{r}");
let it = s.doc().unwrap().doc.item(ItemId(rect)).unwrap().clone();
assert_eq!(swatch_color(&s, &it.stroke.swatch), Color::rgb8(255, 0, 0));
// Sampling the same colour again reuses its swatch.
let again = s.execute("color.sample", &json!({"at": [90, 90]})).unwrap();
assert_eq!(again["swatch"], r["swatch"]);
}
#[test]
fn nothing_to_sample_or_nothing_to_colour_is_refused_without_an_edit() {
let (mut s, _) = session_with_image();
let f = s.execute("frame.create", &json!({"rect": [300, 300, 500, 360], "content": "text", "text": "Hi"})).unwrap();
let fid = ItemId(f["id"].as_u64().unwrap());
let story = s.doc().unwrap().doc.item(fid).unwrap().text_frame().unwrap().story;
s.execute("text.select", &json!({"story": story.0, "anchor": 1, "focus": 1})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
let caret = s.execute("color.sample", &json!({"at": [100, 100]})).unwrap_err().to_string();
assert!(caret.contains("select the text"), "{caret}");
s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 2})).unwrap();
let none = s.execute("color.sample", &json!({"at": [600, 600]})).unwrap_err().to_string();
assert!(none.contains("no placed image"), "{none}");
// Just past the image's right edge.
assert!(s.execute("color.sample", &json!({"at": [216.5, 100]})).is_err());
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
}
}

View File

@ -705,3 +705,44 @@ fn pen_click_snaps_to_a_guide() {
let x = p["anchors"][0]["p"][0].as_f64().unwrap();
assert!((x - 100.0).abs() < 1e-6, "anchor x {x}");
}
#[test]
fn eyedropper_samples_a_placed_image_and_matches_other_objects() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let photo = ItemId(d.alloc());
let mut it = Item::new(photo, lid, Shape::Rectangle, shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)));
it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic {
asset: designcraft_doc::AssetId(1),
size: (100.0, 100.0),
xf: designcraft_geom::Affine::translate((0.0, 0.0)),
auto_fit: Default::default(),
fit_align: 4,
crop: [0.0; 4],
});
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
// An unfilled text frame over part of the photo lets it show through.
d.add_text_frame(SpreadRef::Doc(0), Rect::new(150.0, 150.0, 250.0, 250.0), lid, "x", ParaFormat::default()).unwrap();
let swatch = add_rect(&mut d, Rect::new(300.0, 100.0, 350.0, 150.0));
d.item_mut(swatch).unwrap().fill.swatch = "[Black]".into();
let target = add_rect(&mut d, Rect::new(400.0, 100.0, 450.0, 150.0));
// The photo's layer is locked: sampling it still works.
d.layers.iter_mut().for_each(|l| l.locked = true);
let s = Selection { items: vec![target], ..Default::default() };
let (c, l) = (Cache::new(), CanvasLayout::new(&d, false));
let off = l.xf(SpreadRef::Doc(0)).translation();
let cx = ctx(&d, &s, &c, &l);
let mut t = create("eyedropper");
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 120.0 + off.x, 130.0 + off.y));
let sample = |x: f64, y: f64| Action::Exec("color.sample".into(), serde_json::json!({"at": [x, y], "spread": {"kind": "doc", "index": 0}}));
assert_eq!(a, vec![sample(120.0, 130.0)]);
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 180.0 + off.x, 180.0 + off.y));
assert_eq!(a, vec![sample(180.0, 180.0)], "through the text frame");
// A filled object is matched as before (here on a locked layer: not reachable, so nothing).
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 320.0 + off.x, 120.0 + off.y)).is_empty());
let mut d2 = d.clone();
d2.layers.iter_mut().for_each(|l| l.locked = false);
let cx = ctx(&d2, &s, &c, &l);
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 320.0 + off.x, 120.0 + off.y));
assert_eq!(a, vec![Action::Exec("object.matchAttributes".into(), serde_json::json!({"from": swatch.0}))]);
}

View File

@ -1,6 +1,7 @@
//! Rotate (R), Scale (S) and Shear (O) tools: drag around the selection centre (Alt-click sets
//! the reference point elsewhere in a later iteration); Eyedropper (I): click an object to apply
//! its attributes to the selection.
//! its attributes to the selection, or a placed image to colour the selection (text or objects)
//! with the image's colour under the pointer.
use designcraft_doc::SpreadRef;
use designcraft_geom::{Point, Rect};
@ -247,6 +248,37 @@ fn positive_pad(v: f64) -> f64 {
if v.is_finite() && v > 0.0 { v } else { 0.0 }
}
/// The spread and spread point under `p` when a placed graphic shows there: the frontmost visible
/// filled or graphic item at the point (locked included) is a graphic frame.
fn image_under(cx: &ToolContext, p: Point) -> Option<(SpreadRef, Point)> {
use designcraft_doc::{Content, Item};
fn shows(it: &Item, outer: designcraft_geom::Affine, at: Point, depth: usize) -> Option<bool> {
if it.hidden || depth > 64 {
return None;
}
let xf = outer * it.xf;
if let Content::Group { items } = &it.content {
return items.iter().rev().find_map(|c| shows(c, xf, at, depth + 1));
}
let graphic = matches!(it.content, Content::Graphic(_));
// Unfilled frames (a text frame over the photo) let the image show through.
if !graphic && it.fill.is_none() {
return None;
}
designcraft_doc::edit_hit(it, outer.inverse() * at, 0.0).then_some(graphic)
}
let (sr, at) = cx.layout.spread_at(p)?;
let sp = cx.doc.spread(sr)?;
let mut items: Vec<&Item> = sp.items.iter().map(|i| &**i).collect();
items.sort_by_key(|i| cx.doc.layer_rank(i.layer));
let image = items
.into_iter()
.rev()
.filter(|it| cx.doc.layer(it.layer).is_none_or(|l| l.visible))
.find_map(|it| shows(it, designcraft_geom::Affine::IDENTITY, at, 0))?;
image.then_some((sr, at))
}
#[derive(Default)]
pub struct EyedropperTool;
@ -258,8 +290,13 @@ impl Tool for EyedropperTool {
if ev.kind != PointerKind::Down {
return vec![];
}
let selected = !cx.selection.items.is_empty() || cx.selection.text.is_some() || cx.selection.cells.is_some();
// A placed image under the pointer is sampled (its layer may be locked: sampling changes nothing).
if selected && let Some((sr, at)) = image_under(cx, ev.pos) {
return vec![Action::Exec("color.sample".into(), json!({"at": [at.x, at.y], "spread": sr}))];
}
let Some((_, id)) = cx.hit(ev.pos) else { return vec![] };
if cx.selection.items.is_empty() && cx.selection.text.is_none() {
if !selected {
return vec![Action::Exec("selection.set".into(), json!({"ids": [id.0]}))];
}
vec![Action::Exec("object.matchAttributes".into(), json!({"from": id.0}))]

View File

@ -739,6 +739,10 @@ const TABLE: &[(&str, [&str; 5])] = &[
),
("Apply Cell Style", ["Apply Cell Style", "Apply Cell Style", "Apply Cell Style", "Apply Cell Style", "应用单元格样式"]),
("Apply Color", ["Apply Color", "Apply Color", "Apply Color", "Apply Color", "应用颜色"]),
(
"Sample Color (Eyedropper)",
["Farbe aufnehmen (Pipette)", "Prélever la couleur (Pipette)", "Tomar color (Cuentagotas)", "カラーを抽出 (スポイト)", "取样颜色 (吸管)"],
),
("Apply Condition", ["Apply Condition", "Apply Condition", "Apply Condition", "Apply Condition", "应用条件"]),
(
"Apply Parent to Pages…",

View File

@ -1041,6 +1041,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Formatting affects container (J)", "تطبيق التنسيق على الحاوية (J)"),
("Formatting affects text (J)", "تطبيق التنسيق على النص (J)"),
("Apply Color", "تطبيق اللون"),
("Sample Color (Eyedropper)", "أخذ عينة من اللون (القطارة)"),
("Apply None", "إزالة اللون"),
("Height Only", "الارتفاع فقط"),
("Width Only", "العرض فقط"),

View File

@ -412,6 +412,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Apply Attributes (Eyedropper)", "Applica attributi (Contagocce)"),
("Apply Cell Style", "Applica stile di cella"),
("Apply Color", "Applica colore"),
("Sample Color (Eyedropper)", "Campiona colore (Contagocce)"),
("Apply Condition", "Applica condizione"),
("Apply Parent to Pages…", "Applica pagina mastro alle pagine…"),
("ArtCraft App Page…", "Pagina dell'app ArtCraft…"),

View File

@ -414,6 +414,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Apply Attributes (Eyedropper)", "属性を適用 (スポイト)"),
("Apply Cell Style", "セルスタイルを適用"),
("Apply Color", "カラーを適用"),
("Sample Color (Eyedropper)", "カラーを抽出 (スポイト)"),
("Apply Condition", "条件を適用"),
("Apply Parent to Pages…", "ページに親ページを適用…"),
("ArtCraft App Page…", "ArtCraft アプリページ…"),

View File

@ -1045,6 +1045,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Formatting affects container (J)", "Formatação afeta o contêiner (J)"),
("Formatting affects text (J)", "Formatação afeta o texto (J)"),
("Apply Color", "Aplicar cor"),
("Sample Color (Eyedropper)", "Amostrar cor (Conta-gotas)"),
("Apply None", "Aplicar nenhum"),
("Height Only", "Somente altura"),
("Width Only", "Somente largura"),

View File

@ -415,6 +415,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Apply Attributes (Eyedropper)", "Застосувати атрибути (піпетка)"),
("Apply Cell Style", "Застосувати стиль клітинки"),
("Apply Color", "Застосувати колір"),
("Sample Color (Eyedropper)", "Узяти колір (піпетка)"),
("Apply Condition", "Застосувати умову"),
("Apply Parent to Pages…", "Застосувати шаблон до сторінок…"),
("ArtCraft App Page…", "Сторінка програми ArtCraft…"),