Merge PR #1079: Color Theme: extract a placed image palette and add it to Swatches

crates/ui-egui/src/dialogs.rs: the branch's dialog body is laid out differently; the Color Theme body goes into its match beside Text Frame Options, and its OK label into the branch's translated labels.
crates/ui-egui/src/menus.rs: keep the branch's Effects dialog opener and add the Color Theme opener.
crates/ui-egui/src/panels/swatches.rs: keep both menu rows (Swatch Options… from #416, Color Theme…).
crates/tools/src/tests.rs: the PR's Graphic literal names clip, wrap and fill.
docs/agents.md: keep both sections.
ROADMAP.md: the PR's line moves under "Newest first."; its copy of an existing 2026-10-13 line is dropped.
This commit is contained in:
aiongg
2026-10-16 04:12:13 +00:00
11 changed files with 360 additions and 0 deletions
+1
View File
@@ -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.
+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;
@@ -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());
+1
View File
@@ -494,6 +494,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()),
+27
View File
@@ -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());
+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
@@ -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<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
@@ -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, &params),
UiRequest::Pick { purpose, params } => {
+4
View File
@@ -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;
+4
View File
@@ -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);
+9
View File
@@ -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,