fix(eyedropper): Sample rasters only; colour text through object.color
A placed PDF, EPS or SVG under the Eyedropper matches attributes again,
as before, instead of failing with "no placed image under the point"
(Asset::is_raster, shared by the tool and color.sample). color.sample
hands its colour to object.color, which now colours selected text, the
text of selected frames ({text: true}), or the typing colour of an empty
story; a caret in existing text is refused before a swatch or undo step.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d7d6f27069
commit
9ab226d741
|
|
@ -452,6 +452,16 @@ pub struct Asset {
|
|||
pub page: u32,
|
||||
}
|
||||
|
||||
impl Asset {
|
||||
/// A raster image (PNG, JPEG, GIF, WebP, TIFF, BMP, PSD), as opposed to a PDF, EPS or SVG.
|
||||
pub fn is_raster(&self) -> bool {
|
||||
matches!(
|
||||
self.mime.as_str(),
|
||||
"image/png" | "image/jpeg" | "image/gif" | "image/webp" | "image/tiff" | "image/bmp" | "image/vnd.adobe.photoshop"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_zero(v: &u32) -> bool {
|
||||
*v == 0
|
||||
}
|
||||
|
|
|
|||
|
|
@ -180,8 +180,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Apply Color",
|
||||
[],
|
||||
None,
|
||||
"{color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b](0..255), target?: fill|stroke, ids?} — an unnamed colour (not in the Swatches panel until Add to Swatches) on the selection",
|
||||
super::has_selection,
|
||||
"{color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b](0..255), target?: fill|stroke, text?: bool, ids?} — an unnamed colour on the selected text or objects; `text` also targets the text of selected frames",
|
||||
has_text_or_selection,
|
||||
apply_color
|
||||
),
|
||||
cmd!(
|
||||
|
|
@ -964,6 +964,44 @@ fn apply_gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
fn apply_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let color = p.get("color").and_then(parse_color).ok_or_else(|| bad("object.color", "missing or bad color"))?;
|
||||
let stroke = str_param(p, "target") == Some("stroke");
|
||||
let named = p.get("ids").is_some() || p.get("id").is_some();
|
||||
let text = !named
|
||||
&& (p.get("text").and_then(Value::as_bool).unwrap_or(false) || s.doc()?.selection.text.is_some() || s.doc()?.selection.cells.is_some());
|
||||
if text {
|
||||
let targets = super::text::format_targets(s);
|
||||
// A caret in existing text has no editable range. Refuse it before creating a swatch
|
||||
// or history; an empty story has a format run that the next insertion inherits.
|
||||
if !targets
|
||||
.iter()
|
||||
.any(|t| !t.range.is_empty() || s.active().is_some_and(|d| d.doc.text_story(t.story, t.cell).is_some_and(|st| st.is_empty())))
|
||||
{
|
||||
return Err(bad("object.color", "select the text to colour"));
|
||||
}
|
||||
return 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 };
|
||||
let set = |f: &mut designcraft_doc::CharFormat| {
|
||||
if stroke {
|
||||
(f.over.stroke, f.over.stroke_tint) = (Some(name.clone()), Some(1.0));
|
||||
} else {
|
||||
(f.over.fill, f.over.fill_tint) = (Some(name.clone()), Some(1.0));
|
||||
}
|
||||
};
|
||||
if t.range.is_empty() {
|
||||
if story.is_empty()
|
||||
&& let Some(run) = story.chars.first_mut()
|
||||
{
|
||||
set(&mut run.format);
|
||||
story.rev = story.rev.wrapping_add(1);
|
||||
}
|
||||
} else {
|
||||
story.format_chars(t.range.clone(), set);
|
||||
}
|
||||
}
|
||||
Ok(json!({"swatch": name}))
|
||||
});
|
||||
}
|
||||
let ids = super::targets(s, p)?;
|
||||
s.edit(|d, _| {
|
||||
let name = color_swatch(d, color);
|
||||
|
|
@ -1019,40 +1057,15 @@ 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}))
|
||||
})
|
||||
let mut params = p.clone();
|
||||
params["color"] = json!(hex);
|
||||
let mut result = apply_color(s, ¶ms)?;
|
||||
result["color"] = json!(hex);
|
||||
result["from"] = json!(from.0);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// The colour of the placed raster image at `at` on spread `sr`: the frontmost visible image whose
|
||||
|
|
@ -1098,7 +1111,8 @@ fn sample_item(
|
|||
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 asset = d.assets.get(&g.asset).filter(|a| a.is_raster())?;
|
||||
let img = designcraft_images::decode_rgba(&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)?);
|
||||
|
|
@ -1691,4 +1705,30 @@ mod sample_color_tests {
|
|||
assert!(s.execute("color.sample", &json!({"at": [216.5, 100]})).is_err());
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sampling_and_explicit_colour_use_the_same_text_application_path() {
|
||||
for command in ["color.sample", "object.color"] {
|
||||
let (mut s, _) = session_with_image();
|
||||
let f = s.execute("frame.create", &json!({"rect": [300, 300, 500, 360], "content": "text"})).unwrap();
|
||||
let story = designcraft_doc::StoryId(f["story"].as_u64().unwrap());
|
||||
s.execute("text.select", &json!({"story": story.0, "anchor": 0})).unwrap();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let r = s.execute(command, &json!({"at": [100, 100], "color": "#ff0000"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{command}");
|
||||
s.execute("text.insert", &json!({"text": "Hi"})).unwrap();
|
||||
let text = s.doc().unwrap().doc.story(story).unwrap();
|
||||
assert_eq!(text.format_after(0).over.fill.as_deref(), r["swatch"].as_str(), "empty-story typing: {command}");
|
||||
// Explicit ids override text targeting, including with a text selection active.
|
||||
let fid = ItemId(f["id"].as_u64().unwrap());
|
||||
s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 2})).unwrap();
|
||||
let r = s.execute(command, &json!({"at": [180, 100], "color": "#0000ff", "id": fid.0})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.item(fid).unwrap().fill.swatch, r["swatch"].as_str().unwrap());
|
||||
let text = s.doc().unwrap().doc.story(story).unwrap();
|
||||
assert_ne!(text.format_after(0).over.fill.as_deref(), r["swatch"].as_str(), "text unchanged: {command}");
|
||||
let r = s.execute(command, &json!({"at": [180, 100], "color": "#0000ff", "target": "stroke"})).unwrap();
|
||||
let text = s.doc().unwrap().doc.story(story).unwrap();
|
||||
assert_eq!(text.format_after(0).over.stroke.as_deref(), r["swatch"].as_str(), "selected text stroke: {command}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -720,6 +720,18 @@ fn eyedropper_samples_a_placed_image_and_matches_other_objects() {
|
|||
fit_align: 4,
|
||||
crop: [0.0; 4],
|
||||
});
|
||||
d.assets.insert(
|
||||
designcraft_doc::AssetId(1),
|
||||
std::sync::Arc::new(designcraft_doc::Asset {
|
||||
id: designcraft_doc::AssetId(1),
|
||||
name: "photo.png".into(),
|
||||
mime: "image/png".into(),
|
||||
link: None,
|
||||
data: std::sync::Arc::new(vec![]),
|
||||
pixels: Some((100, 100)),
|
||||
page: 0,
|
||||
}),
|
||||
);
|
||||
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();
|
||||
|
|
@ -745,4 +757,15 @@ fn eyedropper_samples_a_placed_image_and_matches_other_objects() {
|
|||
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}))]);
|
||||
// Vector graphics and missing assets must retain the ordinary attribute-matching path.
|
||||
for mime in ["application/pdf", "application/postscript", "image/svg+xml", "application/octet-stream"] {
|
||||
std::sync::Arc::make_mut(d2.assets.get_mut(&designcraft_doc::AssetId(1)).unwrap()).mime = mime.into();
|
||||
let cx = ctx(&d2, &s, &c, &l);
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 120.0 + off.x, 130.0 + off.y));
|
||||
assert_eq!(a, vec![Action::Exec("object.matchAttributes".into(), serde_json::json!({"from": photo.0}))], "{mime}");
|
||||
}
|
||||
d2.assets.clear();
|
||||
let cx = ctx(&d2, &s, &c, &l);
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 120.0 + off.x, 130.0 + off.y));
|
||||
assert_eq!(a, vec![Action::Exec("object.matchAttributes".into(), serde_json::json!({"from": photo.0}))]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -248,24 +248,30 @@ 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.
|
||||
/// The spread and spread point under `p` when a placed raster shows there. Other graphic formats
|
||||
/// still block an image behind them, but use attribute matching instead of pixel sampling.
|
||||
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> {
|
||||
fn shows(d: &designcraft_doc::Document, 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));
|
||||
return items.iter().rev().find_map(|c| shows(d, 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)
|
||||
if !designcraft_doc::edit_hit(it, outer.inverse() * at, 0.0) {
|
||||
return None;
|
||||
}
|
||||
Some(match &it.content {
|
||||
Content::Graphic(g) => d.assets.get(&g.asset).is_some_and(|a| a.is_raster()),
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
let (sr, at) = cx.layout.spread_at(p)?;
|
||||
let sp = cx.doc.spread(sr)?;
|
||||
|
|
@ -275,7 +281,7 @@ fn image_under(cx: &ToolContext, p: Point) -> Option<(SpreadRef, Point)> {
|
|||
.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))?;
|
||||
.find_map(|it| shows(cx.doc, it, designcraft_geom::Affine::IDENTITY, at, 0))?;
|
||||
image.then_some((sr, at))
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue