diff --git a/ROADMAP.md b/ROADMAP.md index 36eb5d2a..f2926a62 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -347,6 +347,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **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-14** · Threading keeps generated and linked stories, text that refers to a story, local IDs and locked frames intact; a colour chosen at a caret waits for the typing, Swap Fill and Stroke on text keeps each run's colours, control-channel input never reads or writes the system clipboard, and IDML frame preferences given as properties import with bounded column counts (#84). No readiness percentage change. - **2026-10-14** · The Liquid Layout panel follows InDesign's: Liquid Page Rule with the page number, Content › Auto-Fit, and Object Constraints › Resize with Page and Pin for the selected object on an object-based page; Create Alternate Layout moved to the Layout menu. No readiness percentage change. - **2026-10-14** · On an object-based page the Page tool shows a selected object's liquid pins and resize locks as InDesign does: a click pins a side to the page edge or locks the width or height, with InDesign's tooltips. No readiness percentage change. 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 69be818d..e1287f38 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; @@ -182,6 +183,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 23a888c2..39dd005b 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -494,6 +494,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 57f94c9f..23fc2b71 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -22,6 +22,33 @@ 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, + clip: None, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + wrap: Default::default(), + fill: Default::default(), + }); + 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 aa95bbd0..b9d4804d 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 efa143b5..69d30bae 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -137,6 +137,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). @@ -1980,6 +2040,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"), "numberingSection" => crate::i18n::tr(&app.ui.language, "Numbering & Section Options…").trim_end_matches('…'), "findChange" => crate::i18n::tr(&app.ui.language, "Find/Change"), @@ -2668,6 +2729,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" => { // Adjust Layout applies at once. let adjust = document_setup(app, ui, &mut d); @@ -2693,6 +2755,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { } ui.add_space(12.0); let ok_label = match d.id.as_str() { + "colorTheme" => crate::i18n::tr(&app.ui.language, "Add to Swatches").to_string(), "closeDocument" => crate::i18n::tr(&app.ui.language, " Save ").to_string(), "updateLink" => crate::i18n::tr(&app.ui.language, " Update ").to_string(), "findFont" => crate::i18n::tr(&app.ui.language, "Replace All").to_string(), @@ -2899,6 +2962,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 11ccf608..46238431 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -880,6 +880,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 64d7256e..f40a8b92 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -2383,6 +2383,10 @@ fn activate_inner(app: &mut DesignApp, id: &str, params: &Value) { crate::effects_dialog::open(app, page); return; } + 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 35c26465..3e7030e8 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -98,6 +98,10 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let _ = app.run("app.swatchOptionsDialog", json!({"name": current})); ui.close(); } + 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 9913a939..ed0d3bbd 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -54,6 +54,15 @@ same references, and MCP's `batch` tool takes `commands` or `script` text with t `.epub`, `.html`, and `.rtf` or `.txt` (the story being edited or of the selected frame). A name with no extension is a PNG of the page; another extension is an error, and nothing runs. +## 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,