From c4772c37450cd75b8a6da15ce73672ff1716e1d5 Mon Sep 17 00:00:00 2001 From: moppeeer <339764117+moppeeer@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:57:26 +0200 Subject: [PATCH] M2.2: extract image color themes into swatch groups --- ROADMAP.md | 2 + crates/engine/src/cmd/color_theme.rs | 217 ++++++++++++++++++++++++++ crates/engine/src/cmd/mod.rs | 2 + crates/tools/src/lib.rs | 1 + crates/tools/src/tests.rs | 24 +++ crates/tools/src/xform.rs | 22 +++ crates/ui-egui/src/dialogs.rs | 72 +++++++++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/menus.rs | 4 + crates/ui-egui/src/panels/swatches.rs | 4 + docs/agents.md | 9 ++ 11 files changed, 358 insertions(+) create mode 100644 crates/engine/src/cmd/color_theme.rs diff --git a/ROADMAP.md b/ROADMAP.md index d60a6bf4..89dbb0fa 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/crates/engine/src/cmd/color_theme.rs b/crates/engine/src/cmd/color_theme.rs new file mode 100644 index 00000000..c406c72d --- /dev/null +++ b/crates/engine/src/cmd/color_theme.rs @@ -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 { + 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 { + 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) -> Vec { + 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::()), *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::() / 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::(); + if weight == 0 { + continue; + } + let average = |axis| (bucket.iter().map(|(rgb, w)| u64::from(rgb.get(axis).copied().unwrap_or(0)) * w).sum::() / 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 { + 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()); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 0bfb9727..822a226b 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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()); diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 3f042d4a..071eb78d 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -442,6 +442,7 @@ pub fn create(id: &str) -> Box { "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()), diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index aacfb8d3..5eaad703 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -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()); diff --git a/crates/tools/src/xform.rs b/crates/tools/src/xform.rs index d57596cf..87a8f86f 100644 --- a/crates/tools/src/xform.rs +++ b/crates/tools/src/xform.rs @@ -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 { + 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" diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7d02776a..0bcca900 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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 { 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) { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 59c7ebb5..41d417dd 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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, ¶ms), UiRequest::Pick { purpose, params } => { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a35261d5..c51e323f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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; diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index a21ffdb9..252009dd 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -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); diff --git a/docs/agents.md b/docs/agents.md index b737ebb8..5ab6e804 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -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,