M2.2: extract image color themes into swatch groups

This commit is contained in:
moppeeer
2026-10-15 21:57:26 +02:00
parent a930276fd7
commit c4772c3745
11 changed files with 358 additions and 0 deletions
+2
View File
@@ -343,6 +343,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
## Progress log
- **2026-10-15** · Color Theme: the toolbar tool previews up to five colours from a placed raster and adds them as one undoable swatch group. Local deterministic extraction; transparent padding is ignored. No readiness percentage change.
- **2026-10-13** · Switching between document and parent pages refreshes the canvas immediately, preventing black parent pages and stale document objects when the zoom stays the same. Ordinary edits retain partial repainting. No readiness percentage change.
Newest first.
+217
View File
@@ -0,0 +1,217 @@
//! Local, deterministic palettes from placed raster images. No network service is involved.
use designcraft_color::{Color, ColorType, Swatch, SwatchValue};
use designcraft_doc::{ColorGroup, ItemId, Styles};
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_doc, str_param};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "color.extractTheme", "Extract Color Theme", [], None,
"{id?: selected image frame} → {from, colors: [up to five RGB hex colours]} — samples the source raster, ignoring transparent pixels; at most 16 million pixels",
has_doc, extract),
cmd!(
"swatch.addColorTheme",
"Color Theme…",
[],
None,
"{colors: [one to five RGB hex colours], name?: group name} → {name, swatches} — adds the previewed palette as one undoable swatch group",
has_doc,
add
),
]
}
fn extract(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "color.extractTheme";
let st = s.doc()?;
let id = match p.get("id") {
Some(v) => ItemId(v.as_u64().ok_or_else(|| bad(ID, "id must be an image frame id"))?),
None => {
if st.selection.items.len() != 1 {
return Err(bad(ID, "select one placed raster image"));
}
st.selection.items.first().copied().ok_or_else(|| bad(ID, "select one placed raster image"))?
}
};
let graphic = st.doc.item(id).and_then(|it| it.graphic()).ok_or_else(|| bad(ID, "select one placed raster image"))?;
let asset = st.doc.assets.get(&graphic.asset).filter(|a| a.is_raster()).ok_or_else(|| bad(ID, "a raster image is required"))?;
let (width, height) = designcraft_images::pixel_size(&asset.data).ok_or_else(|| bad(ID, "cannot read image dimensions"))?;
if width == 0 || height == 0 || u64::from(width) * u64::from(height) > 16_000_000 {
return Err(bad(ID, "the image must contain between 1 and 16 million pixels"));
}
let img = designcraft_images::decode_rgba(&asset.data).ok_or_else(|| bad(ID, "cannot decode this image"))?;
// A regular 128 × 128 grid bounds work and includes both axes even for very thin images.
let (nx, ny) = (width.min(128), height.min(128));
let mut samples = Vec::with_capacity((nx * ny) as usize);
for y in 0..ny {
for x in 0..nx {
let px = ((u64::from(x) * 2 + 1) * u64::from(width) / (u64::from(nx) * 2)) as u32;
let py = ((u64::from(y) * 2 + 1) * u64::from(height) / (u64::from(ny) * 2)) as u32;
if let Some(pixel) = img.get_pixel_checked(px, py) {
let [r, g, b, a] = pixel.0;
if a > 0 {
samples.push(([r, g, b], u64::from(a)));
}
}
}
}
let colors = palette(samples);
if colors.is_empty() {
return Err(bad(ID, "the image has no visible sampled pixels"));
}
Ok(json!({"from": id.0, "colors": colors}))
}
type Sample = ([u8; 3], u64);
/// Weighted median cut: transparent padding never becomes a colour; partial alpha weights it.
fn palette(samples: Vec<Sample>) -> Vec<String> {
if samples.is_empty() {
return Vec::new();
}
let extent = |bucket: &[Sample]| {
(0..3)
.map(|axis| {
let lo = bucket.iter().filter_map(|(rgb, _)| rgb.get(axis)).min().copied().unwrap_or(0);
let hi = bucket.iter().filter_map(|(rgb, _)| rgb.get(axis)).max().copied().unwrap_or(0);
(hi.saturating_sub(lo), axis)
})
.max()
.unwrap_or((0, 0))
};
let mut buckets = vec![samples];
while buckets.len() < 5 {
let Some((index, (_, axis))) = buckets
.iter()
.enumerate()
.map(|(i, b)| (i, extent(b)))
.filter(|(_, (range, _))| *range > 0)
.max_by_key(|(i, (range, _))| (u64::from(*range) * buckets.get(*i).map_or(0, |b| b.iter().map(|(_, w)| w).sum::<u64>()), *i))
else {
break;
};
let mut bucket = buckets.remove(index);
bucket.sort_by_key(|(rgb, _)| rgb.get(axis).copied().unwrap_or(0));
let half = bucket.iter().map(|(_, w)| w).sum::<u64>() / 2;
let mut accumulated = 0;
let mut split = 1;
for (i, (_, weight)) in bucket.iter().enumerate().take(bucket.len().saturating_sub(1)) {
accumulated += weight;
split = i + 1;
if accumulated >= half {
break;
}
}
let other = bucket.split_off(split);
buckets.push(bucket);
buckets.push(other);
}
let mut colors = Vec::new();
for bucket in buckets {
let weight = bucket.iter().map(|(_, w)| w).sum::<u64>();
if weight == 0 {
continue;
}
let average = |axis| (bucket.iter().map(|(rgb, w)| u64::from(rgb.get(axis).copied().unwrap_or(0)) * w).sum::<u64>() / weight) as u8;
let color = Color::rgb8(average(0), average(1), average(2)).to_hex();
if !colors.contains(&color) {
colors.push(color);
}
}
colors.sort();
colors
}
fn add(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "swatch.addColorTheme";
let input = p
.get("colors")
.and_then(Value::as_array)
.filter(|a| !a.is_empty() && a.len() <= 5)
.ok_or_else(|| bad(ID, "colors must contain one to five RGB hex colours"))?;
let mut colors = Vec::with_capacity(input.len());
for value in input {
let hex = value
.as_str()
.filter(|s| s.len() == 7 && s.starts_with('#') && s.get(1..).is_some_and(|v| v.bytes().all(|b| b.is_ascii_hexdigit())))
.ok_or_else(|| bad(ID, "each colour must be #rrggbb"))?;
colors.push(super::style::parse_color(&json!(hex)).ok_or_else(|| bad(ID, "invalid colour"))?);
}
let base = str_param(p, "name").unwrap_or("Color Theme").trim();
if base.is_empty() || base.len() > 256 {
return Err(bad(ID, "the group name must contain 1 to 256 bytes"));
}
s.edit(|doc, _| {
let name = Styles::unique_name(|n| doc.color_groups.iter().any(|g| g.name == n), base);
let mut names = Vec::new();
for (i, color) in colors.iter().enumerate() {
let label = Styles::unique_name(|n| doc.swatch(n).is_some(), &format!("{name} {}", i + 1));
doc.swatches.push(Swatch {
name: label.clone(),
value: SwatchValue::Color { color: *color, color_type: ColorType::Process },
locked: false,
named: true,
hidden: false,
});
names.push(label);
}
doc.color_groups.push(ColorGroup { name: name.clone(), swatches: names.clone() });
Ok(json!({"name": name, "swatches": names}))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_source_pixels_without_mutating_and_ignores_transparent_padding() {
let png = designcraft_render::Rendered { width: 3, height: 1, pixels: vec![255, 0, 0, 255, 0, 0, 255, 255, 0, 255, 0, 0] }.to_png();
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let placed = s
.execute("file.place", &json!({"base64": super::super::file::base64_encode(&png), "name":"palette.png", "x":36, "y":36, "width":144}))
.unwrap();
let before = s.doc().unwrap().doc.clone();
let result = s.execute("color.extractTheme", &json!({"id":placed["id"]})).unwrap();
assert_eq!(result["colors"], json!(["#0000ff", "#ff0000"]));
assert_eq!(s.doc().unwrap().doc, before);
assert!(s.execute("color.extractTheme", &json!({"id":-1})).is_err());
assert!(s.execute("color.extractTheme", &json!({"id":999999})).is_err());
}
#[test]
fn palette_is_bounded_deterministic_and_keeps_solid_colors() {
assert_eq!(palette(vec![([12, 45, 78], 255); 100]), vec!["#0c2d4e"]);
let samples: Vec<_> = (0..=255).map(|n| ([n, 255 - n, 45], 255)).collect();
let result = palette(samples.clone());
assert_eq!(result.len(), 5);
assert_eq!(result, palette(samples));
assert!(palette(vec![]).is_empty());
}
#[test]
fn adding_theme_is_atomic_undoable_and_names_do_not_collide() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let before = s.doc().unwrap().doc.clone();
let p = json!({"colors":["#123456", "#abcdef"], "name":"Fashion"});
let first = s.execute("swatch.addColorTheme", &p).unwrap();
assert_eq!(first["swatches"].as_array().unwrap().len(), 2);
let added = s.doc().unwrap().doc.clone();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc, before);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc, added);
let second = s.execute("swatch.addColorTheme", &p).unwrap();
assert_ne!(first["name"], second["name"]);
let before = s.doc().unwrap().doc.clone();
for p in [json!({"colors":[]}), json!({"colors":["#123456", "bad"]}), json!({"colors":["#123456"],"name":" "})] {
assert!(s.execute("swatch.addColorTheme", &p).is_err());
assert_eq!(s.doc().unwrap().doc, before);
}
assert!(s.execute("color.extractTheme", &json!({})).is_err());
}
}
+2
View File
@@ -7,6 +7,7 @@ pub mod book;
mod buttons;
pub(crate) mod captions;
mod changes;
mod color_theme;
mod colorsettings;
mod conditions;
mod datamerge;
@@ -168,6 +169,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(buttons::specs());
v.extend(captions::specs());
v.extend(colorsettings::specs());
v.extend(color_theme::specs());
v.extend(changes::specs());
v.extend(conditions::specs());
v.extend(library::specs());
+1
View File
@@ -442,6 +442,7 @@ pub fn create(id: &str) -> Box<dyn Tool> {
"scale" => Box::new(xform::XformTool::new("scale")),
"shear" => Box::new(xform::XformTool::new("shear")),
"eyedropper" => Box::new(xform::EyedropperTool),
"colorTheme" => Box::new(xform::ColorThemeTool),
"hand" => Box::new(nav::HandTool::default()),
"placeGun" => Box::new(nav::PlaceGun::default()),
"zoom" => Box::new(nav::ZoomTool::default()),
+24
View File
@@ -22,6 +22,30 @@ fn ctx<'a>(d: &'a Document, s: &'a Selection, c: &'a Cache, l: &'a CanvasLayout)
}
}
#[test]
fn color_theme_tool_requests_a_palette_without_editing_the_image() {
let mut d = Document::new(&NewDocument::default());
let id = add_rect(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0));
d.item_mut(id).unwrap().content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic {
asset: designcraft_doc::AssetId(1),
size: (100.0, 100.0),
xf: designcraft_geom::Affine::IDENTITY,
auto_fit: Default::default(),
fit_align: 4,
crop: [0.0; 4],
});
let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false));
let off = l.xf(SpreadRef::Doc(0)).translation();
let cx = ctx(&d, &s, &c, &l);
let mut tool = create("colorTheme");
assert_eq!(tool.id(), "colorTheme");
assert_eq!(
tool.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0 + off.x, 150.0 + off.y)),
vec![Action::Dialog("colorTheme".into(), serde_json::json!({"id":id.0}))]
);
assert!(tool.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0 + off.x, 150.0 + off.y)).is_empty());
}
#[test]
fn frame_tool_drag_emits_begin_preview_commit() {
let d = Document::new(&NewDocument::default());
+22
View File
@@ -290,6 +290,28 @@ fn image_under(cx: &ToolContext, p: Point) -> Option<(SpreadRef, Point)> {
#[derive(Default)]
pub struct EyedropperTool;
/// The palette tool only requests a preview. Adding the swatches is a separate command.
pub struct ColorThemeTool;
impl Tool for ColorThemeTool {
fn id(&self) -> &'static str {
"colorTheme"
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
if ev.kind != PointerKind::Down {
return vec![];
}
let Some((_, id)) = cx.hit(ev.pos) else { return vec![] };
if cx.doc.item(id).and_then(|it| it.graphic()).is_none() {
return vec![];
}
vec![Action::Dialog("colorTheme".into(), json!({"id": id.0}))]
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Eyedropper
}
}
impl Tool for EyedropperTool {
fn id(&self) -> &'static str {
"eyedropper"
+72
View File
@@ -133,6 +133,66 @@ fn text_field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, w: f32) {
}
}
/// Extract once when opened. Cancel is read-only; confirmation adds exactly these colours.
pub(crate) fn open_color_theme(app: &mut DesignApp, params: Value) {
match app.run("color.extractTheme", params) {
Ok(mut theme) => {
theme["name"] = json!("Color Theme");
app.ui.dialog = Some(Dialog::new("colorTheme", theme));
}
Err(message) => app.ui.dialog = Some(Dialog::new("alert", json!({"message": message}))),
}
}
fn color_theme(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Name:"));
text_field(ui, d, "name", 280.0);
ui.add_space(8.0);
if let Some(colors) = d.fields.get("colors").and_then(Value::as_array) {
ui.horizontal(|ui| {
for hex in colors.iter().filter_map(Value::as_str).take(5) {
ui.vertical(|ui| {
if let Ok(color) = egui::Color32::from_hex(hex) {
let (rect, _) = ui.allocate_exact_size(egui::vec2(54.0, 40.0), egui::Sense::hover());
ui.painter().rect_filled(rect, 2.0, color);
}
ui.label(hex);
});
}
});
}
if let Some(status) = d.fields.get("status").and_then(Value::as_str) {
crate::rtl::label(ui, status);
}
}
#[cfg(test)]
mod color_theme_tests {
use super::*;
#[test]
fn color_theme_cancel_is_read_only_and_confirm_adds_the_preview() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
let before = app.session.active().unwrap().doc.clone();
let theme = json!({"name":"Fashion", "colors":["#102030", "#f0a020"]});
app.ui.dialog = Some(Dialog::new("colorTheme", theme.clone()));
cancel(&mut app);
assert_eq!(app.session.active().unwrap().doc, before);
app.ui.dialog = Some(Dialog::new("colorTheme", theme));
confirm(&mut app).unwrap();
let doc = &app.session.active().unwrap().doc;
assert_eq!(doc.color_groups.last().unwrap().name, "Fashion");
assert_eq!(doc.color_groups.last().unwrap().swatches.len(), 2);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.active().unwrap().doc, before);
app.ui.dialog = Some(Dialog::new("colorTheme", json!({"name":" ","colors":["#102030"]})));
assert!(confirm(&mut app).is_err());
assert_eq!(app.ui.dialog.as_ref().unwrap().id, "colorTheme");
assert_eq!(app.session.active().unwrap().doc, before);
}
}
/// Edit › Keyboard Shortcuts: every command and its shortcut; click a shortcut, then press the
/// new keys (Esc cancels, Backspace clears).
/// Edit › Spelling › User Dictionary: added words and hyphenation exceptions (applied at once).
@@ -1498,6 +1558,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
"createGuides" => crate::i18n::tr(&app.ui.language, "Create Guides"),
"marginsAndColumns" => crate::i18n::tr(&app.ui.language, "Margins and Columns"),
"textFrameOptions" => crate::i18n::tr(&app.ui.language, "Text Frame Options"),
"colorTheme" => crate::i18n::tr(&app.ui.language, "Color Theme"),
"documentSetup" => crate::i18n::tr(&app.ui.language, "Document Setup"),
"findChange" => crate::i18n::tr(&app.ui.language, "Find/Change"),
"paragraphStyleOptions" => crate::i18n::tr(&app.ui.language, "Paragraph Style Options"),
@@ -2189,6 +2250,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
seed_text_frame_options(app, &mut d);
text_frame_options_dialog(app, ui, &mut d);
}
"colorTheme" => color_theme(app, ui, &mut d),
"documentSetup" => document_setup(app, ui, &mut d),
"pdfExport" => pdf_export(app, ui, &mut d),
"alert" => {
@@ -2203,6 +2265,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
});
ui.add_space(12.0);
let ok_label = match d.id.as_str() {
"colorTheme" => "Add to Swatches",
"closeDocument" => " Save ",
"updateLink" => " Update ",
_ => " OK ",
@@ -2274,6 +2337,15 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
app.cancel_pdf_import();
}
match d.id.as_str() {
"colorTheme" => {
let result = app.run("swatch.addColorTheme", json!({"name": d.s("name"), "colors": d.fields.get("colors")}));
if let Err(error) = &result {
let mut d = d;
d.fields.insert("status".into(), json!(error));
app.ui.dialog = Some(d);
}
result
}
"createGuides" | "marginsAndColumns" => {
// The preview goes; the change is made once, as one undo step.
if d.fields.contains_key(PREVIEWED) {
+1
View File
@@ -772,6 +772,7 @@ impl DesignApp {
for r in reqs {
let was_find = self.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange");
match r {
UiRequest::Dialog { id, params } if id == "colorTheme" => dialogs::open_color_theme(self, params),
UiRequest::Dialog { id, params } => self.ui.dialog = Some(dialogs::Dialog::new(&id, params)),
UiRequest::View { params } => canvas::apply_view_request(self, &params),
UiRequest::Pick { purpose, params } => {
+4
View File
@@ -2047,6 +2047,10 @@ pub fn activate(app: &mut DesignApp, id: &str, params: &Value) {
}
fn activate_inner(app: &mut DesignApp, id: &str, params: &Value) {
if params.is_null() && id == "swatch.addColorTheme" {
crate::dialogs::open_color_theme(app, json!({}));
return;
}
if params.is_null() && id == "file.close" {
close_document(app, None);
return;
+4
View File
@@ -78,6 +78,10 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
let _ = app.run("swatch.newColorGroup", json!({}));
}
crate::menus::menu_button(ui, "☰", |ui| {
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Color Theme…"))).clicked() {
crate::menus::activate(app, "swatch.addColorTheme", &serde_json::Value::Null);
ui.close();
}
crate::menus::menu_button(ui, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Ink Manager")), |ui| {
let Ok(l) = app.session.execute("ink.list", &json!({})) else { return };
let mut all = l["allToProcess"].as_bool().unwrap_or(false);
+9
View File
@@ -50,6 +50,15 @@ same references, and MCP's `batch` tool takes `commands` or `script` text with t
`--all-pages` that follow them, so one run can write several sizes
(`run --sample --scale 0.5 --export small.png --scale 2 --export large.png`); one with no export after it is an error.
## Color themes
`color.extractTheme {id?}` previews up to five RGB colours from a placed raster image (or the selected image).
`swatch.addColorTheme {colors, name?}` adds that palette as a colour group in one undo step.
Extraction samples the whole source image on a bounded grid, weights partial transparency, and ignores fully transparent pixels.
The image must contain at most 16 million pixels. PDF/SVG graphics are unsupported. This is local median-cut extraction,
not Adobe's palette algorithm. In the UI, click an image with the Color Theme Tool (Shift+I), or choose Color Theme…
in the Swatches panel menu with one image selected. Cancel keeps the document unchanged.
## Data merge
`data.source.select {path, sheet?}` links a CSV/TSV/`.xlsx` file to the document (`data.source.update` re-reads it,