From 67dd4852caf4812553729bdd5c1cd48d012cb02b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 15:49:27 -0400 Subject: [PATCH] =?UTF-8?q?F:=20Load/Save=20Swatches=20(.ase)=20=E2=80=94?= =?UTF-8?q?=20swatch.load,=20swatch.save,=20Swatches=20panel=20menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + apps/designcraft-web/src/web.rs | 4 +- apps/designcraft/src/main.rs | 4 +- crates/color/src/ase.rs | 193 ++++++++++++++++++++++++++ crates/color/src/lib.rs | 1 + crates/engine/src/cmd/style.rs | 78 +++++++++++ crates/ui-egui/src/lib.rs | 4 +- crates/ui-egui/src/menus.rs | 30 ++++ crates/ui-egui/src/panels/swatches.rs | 10 ++ docs/parity.md | 8 +- 10 files changed, 326 insertions(+), 7 deletions(-) create mode 100644 crates/color/src/ase.rs diff --git a/ROADMAP.md b/ROADMAP.md index fdcf85943..cd8c4ae47 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -30,6 +30,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool). - Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`. - Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow adds pages and threaded frames until the text fits; Remove Styles option (`designcraft-textimport`). +- Load Swatches / Save Swatches for Exchange (.ase: CMYK, RGB, Lab, Gray; spot or process) from the Swatches panel menu; `swatch.load` / `swatch.save`. - Pathfinder (Object › Pathfinder and a Pathfinder panel): Add, Subtract, Intersect, Exclude Overlap, Minus Back; curves stay curves (flo_curves); `object.pathfinder`. - Make / Release Compound Path (⌘8; nested paths become holes), Create Outlines (⇧⌘O; one path per text colour), Scissors tool (C) with `path.split`. - Transform Again / Individually / Sequence Again (⌥⌘3; a moved copy repeats like Step and Repeat), Clear Transformations; Layers: Merge, Delete Unused, Hide/Lock Others, Show/Unlock All; Break Link to Style (paragraph and character). diff --git a/apps/designcraft-web/src/web.rs b/apps/designcraft-web/src/web.rs index 135b88a15..90b54bcaa 100644 --- a/apps/designcraft-web/src/web.rs +++ b/apps/designcraft-web/src/web.rs @@ -98,7 +98,9 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { open_async: Some(Box::new(move |purpose: &str| { let inbox = open_inbox.clone(); let ctx = ctx.clone(); - let dialog = if purpose == "place" { + let dialog = if purpose == "swatches" { + rfd::AsyncFileDialog::new().add_filter("Swatch Exchange (ASE)", &["ase"]) + } else if purpose == "place" { rfd::AsyncFileDialog::new().add_filter("Graphics", IMAGE_EXTS) } else { rfd::AsyncFileDialog::new().add_filter("DesignCraft", DOC_EXTS) diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index ea076d5db..6c4582981 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -81,7 +81,9 @@ fn services() -> Services { Services { pick_open: Some(Box::new(|purpose: &str| { let d = rfd::FileDialog::new(); - let d = if purpose == "place" { + let d = if purpose == "swatches" { + d.add_filter("Swatch Exchange (ASE)", &["ase"]) + } else if purpose == "place" { d.add_filter( "Graphics and text", &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "psd", "svg", "pdf", "ai", "txt", "docx", "rtf", "md"], diff --git a/crates/color/src/ase.rs b/crates/color/src/ase.rs new file mode 100644 index 000000000..853588b82 --- /dev/null +++ b/crates/color/src/ase.rs @@ -0,0 +1,193 @@ +//! Swatch exchange files (`.ase`): read and write colour swatches so they can move between +//! documents and applications. Big-endian: a signature, version 1.0, a block count, then blocks — +//! group start/end and colour entries (UTF-16 name, colour model, f32 values, colour type). + +use crate::swatch::{ColorType, Swatch, SwatchValue}; +use crate::{Color, cms}; + +#[derive(Debug, thiserror::Error, PartialEq)] +pub enum AseError { + #[error("not a swatch exchange file")] + Signature, + #[error("the file ends early")] + Truncated, +} + +const COLOR: u16 = 0x0001; +#[cfg(test)] +const GROUP_START: u16 = 0xC001; +#[cfg(test)] +const GROUP_END: u16 = 0xC002; + +struct R<'a> { + b: &'a [u8], + i: usize, +} + +impl R<'_> { + fn take(&mut self, n: usize) -> Result<&[u8], AseError> { + let s = self.b.get(self.i..self.i + n).ok_or(AseError::Truncated)?; + self.i += n; + Ok(s) + } + fn u16(&mut self) -> Result { + let s = self.take(2)?; + Ok(u16::from_be_bytes([s[0], s[1]])) + } + fn u32(&mut self) -> Result { + let s = self.take(4)?; + Ok(u32::from_be_bytes([s[0], s[1], s[2], s[3]])) + } + fn f32(&mut self) -> Result { + Ok(f32::from_bits(self.u32()?)) + } + fn name(&mut self) -> Result { + let n = self.u16()? as usize; + let mut units = Vec::with_capacity(n); + for _ in 0..n { + units.push(self.u16()?); + } + while units.last() == Some(&0) { + units.pop(); + } + Ok(String::from_utf16_lossy(&units)) + } +} + +/// The colour swatches in an `.ase` file (groups are flattened). +pub fn read(bytes: &[u8]) -> Result, AseError> { + let mut r = R { b: bytes, i: 0 }; + if r.take(4)? != b"ASEF" { + return Err(AseError::Signature); + } + r.take(4)?; // version + let blocks = r.u32()?; + let mut out = Vec::new(); + for _ in 0..blocks { + let kind = r.u16()?; + let len = r.u32()? as usize; + let body = r.take(len)?; + // Group start/end blocks are skipped: groups are flattened. + if kind != COLOR { + continue; + } + let mut b = R { b: body, i: 0 }; + let name = b.name()?; + let model = b.take(4)?.to_vec(); + let color = match &model[..] { + b"CMYK" => Color::Cmyk { c: b.f32()?, m: b.f32()?, y: b.f32()?, k: b.f32()? }, + b"RGB " => Color::Rgb { r: b.f32()?, g: b.f32()?, b: b.f32()? }, + b"Gray" => Color::Gray { k: 1.0 - b.f32()? }, + b"LAB " => { + let (l, a, bb) = (b.f32()?, b.f32()?, b.f32()?); + let [r, g, bl] = cms::lab::lab_to_srgb(cms::Lab { l: l * 100.0, a, b: bb }); + Color::Rgb { r: r.clamp(0.0, 1.0), g: g.clamp(0.0, 1.0), b: bl.clamp(0.0, 1.0) } + } + _ => continue, + }; + let color_type = if b.u16().unwrap_or(2) == 1 { ColorType::Spot } else { ColorType::Process }; + out.push(Swatch { name, value: SwatchValue::Color { color, color_type }, locked: false, named: true, hidden: false }); + } + Ok(out) +} + +/// An `.ase` file of the colour swatches (special swatches, tints and gradients are left out). +pub fn write(swatches: &[Swatch]) -> Vec { + let mut blocks: Vec> = Vec::new(); + for s in swatches { + let SwatchValue::Color { color, color_type } = &s.value else { continue }; + if s.locked || s.hidden { + continue; + } + let mut b = Vec::new(); + let units: Vec = s.name.encode_utf16().chain(std::iter::once(0)).collect(); + b.extend((units.len() as u16).to_be_bytes()); + for u in units { + b.extend(u.to_be_bytes()); + } + let (model, vals): (&[u8; 4], Vec) = match *color { + Color::Cmyk { c, m, y, k } => (b"CMYK", vec![c, m, y, k]), + Color::Rgb { r, g, b } => (b"RGB ", vec![r, g, b]), + Color::Gray { k } => (b"Gray", vec![1.0 - k]), + }; + b.extend(model); + for v in vals { + b.extend(v.to_bits().to_be_bytes()); + } + b.extend((if *color_type == ColorType::Spot { 1u16 } else { 2 }).to_be_bytes()); + blocks.push(b); + } + let mut out = Vec::new(); + out.extend(b"ASEF"); + out.extend(1u16.to_be_bytes()); + out.extend(0u16.to_be_bytes()); + out.extend((blocks.len() as u32).to_be_bytes()); + for b in blocks { + out.extend(COLOR.to_be_bytes()); + out.extend((b.len() as u32).to_be_bytes()); + out.extend(b); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip_and_lab() { + let sw = vec![ + Swatch { + name: "Sky".into(), + value: SwatchValue::Color { color: Color::Rgb { r: 0.2, g: 0.5, b: 1.0 }, color_type: ColorType::Process }, + locked: false, + named: true, + hidden: false, + }, + Swatch { + name: "PANTONE-ish 1".into(), + value: SwatchValue::Color { color: Color::Cmyk { c: 0.0, m: 0.5, y: 1.0, k: 0.0 }, color_type: ColorType::Spot }, + locked: false, + named: true, + hidden: false, + }, + Swatch { name: "[Paper]".into(), value: SwatchValue::Paper { color: Color::WHITE }, locked: true, named: true, hidden: false }, + ]; + let bytes = write(&sw); + let back = read(&bytes).unwrap(); + assert_eq!(back.len(), 2); + assert_eq!(back[0].name, "Sky"); + assert_eq!(back[0].value, sw[0].value); + assert_eq!(back[1].value, sw[1].value); + assert_eq!(read(b"nope"), Err(AseError::Signature)); + assert_eq!(read(&bytes[..bytes.len() - 3]), Err(AseError::Truncated)); + // A group around a Lab colour. + let mut f = Vec::new(); + f.extend(b"ASEF\x00\x01\x00\x00"); + f.extend(3u32.to_be_bytes()); + let mut g = Vec::new(); + g.extend(2u16.to_be_bytes()); + g.extend([0, b'G', 0, 0]); + f.extend(GROUP_START.to_be_bytes()); + f.extend((g.len() as u32).to_be_bytes()); + f.extend(&g); + let mut c = Vec::new(); + c.extend(2u16.to_be_bytes()); + c.extend([0, b'W', 0, 0]); + c.extend(b"LAB "); + for v in [1.0f32, 0.0, 0.0] { + c.extend(v.to_bits().to_be_bytes()); + } + c.extend(2u16.to_be_bytes()); + f.extend(COLOR.to_be_bytes()); + f.extend((c.len() as u32).to_be_bytes()); + f.extend(&c); + f.extend(GROUP_END.to_be_bytes()); + f.extend(0u32.to_be_bytes()); + let w = read(&f).unwrap(); + assert_eq!(w.len(), 1); + assert_eq!(w[0].name, "W"); + let SwatchValue::Color { color, .. } = &w[0].value else { panic!() }; + assert!(color.to_rgb().iter().all(|v| *v > 0.98), "Lab white: {color:?}"); + } +} diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index ce2200124..f861beb3a 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -4,6 +4,7 @@ //! documents don't drift when converting back and forth. Rendering asks for [`Color::to_rgba`]. #![forbid(unsafe_code)] +pub mod ase; pub mod blend; pub mod cms; pub mod gradient; diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index f1cb901f4..f22a5f563 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -60,6 +60,24 @@ pub fn specs() -> Vec { super::has_selection, apply_gradient ), + cmd!( + "swatch.load", + "Load Swatches…", + [], + None, + "{path?|base64?, replace?: bool} — colour swatches from a swatch exchange (.ase) file; names already in the document are kept unless `replace` → {added, replaced}", + has_doc, + load_swatches + ), + cmd!( + noundo "swatch.save", + "Save Swatches for Exchange…", + [], + None, + "{path?, names?: [swatch names] (default: all colour swatches)} → {base64} or writes `path` (.ase)", + has_doc, + save_swatches + ), cmd!( "object.color", "Apply Color", @@ -389,6 +407,51 @@ fn value_name(color: designcraft_color::Color) -> String { } } +fn load_swatches(s: &mut Session, p: &Value) -> Result { + let bytes = match (str_param(p, "path"), str_param(p, "base64")) { + (_, Some(b)) => super::file::base64_decode(b), + (Some(path), None) => std::fs::read(path).map_err(|e| bad("swatch.load", format!("{path}: {e}")))?, + _ => return Err(bad("swatch.load", "give `path` or `base64`")), + }; + let incoming = designcraft_color::ase::read(&bytes).map_err(|e| bad("swatch.load", e.to_string()))?; + let replace = p.get("replace").and_then(Value::as_bool).unwrap_or(false); + s.edit(|d, _| { + let (mut added, mut replaced) = (0, 0); + for w in incoming { + match d.swatches.iter_mut().find(|x| x.name == w.name) { + Some(x) if x.locked => {} + Some(x) => { + if replace { + x.value = w.value; + x.hidden = false; + replaced += 1; + } + } + None => { + d.swatches.push(w); + added += 1; + } + } + } + Ok(json!({"added": added, "replaced": replaced})) + }) +} + +fn save_swatches(s: &mut Session, p: &Value) -> Result { + let d = &s.doc()?.doc; + let names: Option> = + p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()); + let chosen: Vec = d.swatches.iter().filter(|w| names.as_ref().is_none_or(|n| n.contains(&w.name))).cloned().collect(); + let bytes = designcraft_color::ase::write(&chosen); + match str_param(p, "path") { + Some(path) => { + std::fs::write(path, &bytes).map_err(|e| bad("swatch.save", format!("{path}: {e}")))?; + Ok(json!({"path": path, "bytes": bytes.len()})) + } + None => Ok(json!({"base64": super::file::base64_encode(&bytes)})), + } +} + fn gradient_json(name: &str, g: &designcraft_color::Gradient) -> Value { let stops: Vec = g .stops @@ -580,6 +643,21 @@ fn rename_in(it: &mut designcraft_doc::Item, from: &str, to: &str) { mod color_tests { use super::*; + #[test] + fn swatches_save_and_load_ase() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("swatch.create", &json!({"name": "Brand Teal", "color": "#108080"})).unwrap(); + let b64 = s.execute("swatch.save", &json!({"names": ["Brand Teal"]})).unwrap()["base64"].as_str().unwrap().to_string(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("swatch.load", &json!({"base64": b64})).unwrap(); + assert_eq!(r["added"], 1); + assert!(s.doc().unwrap().doc.swatch("Brand Teal").is_some()); + let r = s.execute("swatch.load", &json!({"base64": b64})).unwrap(); + assert_eq!((r["added"].as_u64(), r["replaced"].as_u64()), (Some(0), Some(0))); + assert!(s.execute("swatch.load", &json!({"base64": "AAAA"})).is_err()); + } + #[test] fn unnamed_colors_then_add_to_swatches() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bd55926d5..6b5122ee4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -359,7 +359,9 @@ impl DesignApp { for (name, bytes) in files { let b64 = designcraft_engine::cmd::base64_encode(&bytes); let lower = name.to_ascii_lowercase(); - let r = if lower.ends_with(".designcraft") || lower.ends_with(".idml") { + let r = if lower.ends_with(".ase") { + self.run("swatch.load", json!({"base64": b64})) + } else if lower.ends_with(".designcraft") || lower.ends_with(".idml") { let title = name.rsplit_once('.').map_or(name.as_str(), |(stem, _)| stem); self.run("file.openBytes", json!({"name": title, "base64": b64})) } else { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b3574b62c..208d294f6 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -12,6 +12,8 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.saveDialog", "Save As…", Some("Cmd+Shift+S"), "{}"), ("app.save", "Save", Some("Cmd+S"), "{}"), ("app.saveCopyDialog", "Save a Copy…", Some("Cmd+Alt+S"), "{} — choose where to write a copy (the document stays as it is)"), + ("app.loadSwatches", "Load Swatches…", None, "{} — pick a swatch exchange (.ase) file"), + ("app.saveSwatches", "Save Swatches for Exchange…", None, "{path?} — write the colour swatches as .ase"), ("app.exportPng", "Export Page as PNG…", Some("Cmd+E"), "{}"), ("app.exportIdml", "Export IDML…", None, "{path?} — InDesign Markup (IDML) package"), ("app.exportPdf", "Export PDF…", None, "{path?, …file.exportPdf options} — asks for a path when none is given"), @@ -472,6 +474,34 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option export_png(app, p), "app.exportIdml" => export_idml(app, p), + "app.loadSwatches" => { + if let Some(pick) = app.services.pick_open.as_mut() { + return Some(match pick("swatches") { + Some(path) => app.run("swatch.load", json!({"path": path})), + None => Ok(Value::Null), + }); + } + if let Some(open) = app.services.open_async.as_mut() { + open("swatches"); + } + Ok(Value::Null) + } + "app.saveSwatches" => { + let path = match p.get("path").and_then(Value::as_str) { + Some(s) => Some(s.to_string()), + None => app.services.pick_save.as_mut().and_then(|f| f("Swatches.ase")), + }; + let Some(path) = path else { return Some(Ok(Value::Null)) }; + let r = match app.run("swatch.save", json!({})) { + Ok(r) => r, + Err(e) => return Some(Err(e)), + }; + let bytes = designcraft_engine::cmd::base64_decode(r["base64"].as_str().unwrap_or_default()); + match app.services.write.as_mut() { + Some(w) => w(&path, &bytes).map(|_| json!({"path": path, "bytes": bytes.len()})), + None => Err("no writer".into()), + } + } "app.storyEditor" => { let sid = p.get("story").and_then(Value::as_u64).map(designcraft_doc::StoryId).or_else(|| { let st = app.session.active()?; diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 4e5465944..454bb5075 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -66,6 +66,16 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { if icons::button(ui, "plus", 20.0, false, "New Swatch").clicked() { let _ = app.run("swatch.create", json!({"color": {"c": 0, "m": 50, "y": 100, "k": 0}})); } + ui.menu_button("☰", |ui| { + if ui.button("Load Swatches…").clicked() { + let _ = app.run("app.loadSwatches", json!({})); + ui.close(); + } + if ui.button("Save Swatches for Exchange…").clicked() { + let _ = app.run("app.saveSwatches", json!({})); + ui.close(); + } + }); }); }); } diff --git a/docs/parity.md b/docs/parity.md index efddf8425..d8b81c5a0 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -5,12 +5,12 @@ The summary is generated by `cargo xtask parity` from the table at the bottom of Each row of the InDesign feature catalogue is scored **D** (done), **P** (partial: works but lacks options or UI) or **M** (missing), weighted by priority (**P0** core = 3, **P1** = 2, **P2** = 1). Out-of-scope rows (Adobe cloud services, plug-ins, Typekit, Flash) are excluded. -**Weighted breadth parity: 73%** over 275 features. +**Weighted breadth parity: 74%** over 275 features. | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| | P0 | 102 | 99 | 3 | 0 | 99% | -| P1 | 97 | 43 | 23 | 31 | 56% | +| P1 | 97 | 44 | 23 | 30 | 57% | | P2 | 76 | 8 | 7 | 61 | 15% | | Area | Parity | @@ -20,7 +20,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Layers | 95% | | Frames, shapes & paths | 85% | | Transform | 93% | -| Fill, stroke, colour | 69% | +| Fill, stroke, colour | 74% | | Effects & transparency | 50% | | Placing & links | 66% | | Type & text frames | 80% | @@ -148,7 +148,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t | Fill, stroke, colour | Special swatches | P0 | D | | Fill, stroke, colour | Spot colours | P1 | D | | Fill, stroke, colour | Ink Manager | P2 | M | -| Fill, stroke, colour | Load/Save Swatches (.ase) | P1 | M | +| Fill, stroke, colour | Load/Save Swatches (.ase) | P1 | D | | Fill, stroke, colour | Color groups | P2 | M | | Fill, stroke, colour | Unnamed colour to swatch | P1 | D | | Fill, stroke, colour | Stroke panel | P0 | D |