From efb563673f5c5d075b84d80ab8e0ce86593818c7 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 23:47:58 -0400 Subject: [PATCH] =?UTF-8?q?C:=20Ink=20Manager=20=E2=80=94=20spots=20to=20p?= =?UTF-8?q?rocess,=20ink=20aliases=20(PDF,=20IDML)?= 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 + crates/doc/src/build.rs | 1 + crates/doc/src/lib.rs | 34 +++++++++ crates/engine/src/cmd/style.rs | 100 ++++++++++++++++++++++++++ crates/idml/src/export.rs | 10 ++- crates/idml/src/import.rs | 18 +++++ crates/pdf/src/export.rs | 12 +++- crates/ui-egui/src/panels/swatches.rs | 21 ++++++ docs/parity.md | 6 +- 9 files changed, 195 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a6ba17d..6a6f4ac 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -53,6 +53,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - High Contrast interface theme (fifth Interface Color Theme: black chrome, white text, yellow selection). - Place Excel workbooks (.xlsx): the first worksheet's used range becomes a table (shared/inline strings, numbers, booleans). - Object Library (`library.new/open/add/place/remove/list/json/close`, Library panel): `.dclib` files of snippets that keep stories, styles, swatches and images. +- Ink Manager (`ink.list`, `ink.options`; Swatches panel menu): All Spots to Process, per-ink conversion and ink aliases in PDF output; IDML ConvertToProcess/AliasInkName. - Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`). - Image Import Options: place any page of a multi-page PDF (`file.place {pdfPage}`; the app asks which page), shown on screen and embedded as that page in PDF export. - Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override. diff --git a/crates/doc/src/build.rs b/crates/doc/src/build.rs index 6923868..912e543 100644 --- a/crates/doc/src/build.rs +++ b/crates/doc/src/build.rs @@ -129,6 +129,7 @@ impl Document { swatches: designcraft_color::default_swatches(), color_groups: vec![], conditions: vec![], + inks: Default::default(), endnote_options: Default::default(), endnote_story: None, sections: vec![Section { diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index fc5d9c9..67485db 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -160,6 +160,37 @@ impl Document { } } +/// Ink Manager settings for output. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct InkManager { + /// All Spots to Process. + pub all_to_process: bool, + /// Spot inks converted to process one by one. + pub to_process: Vec, + /// Ink Alias: (spot ink, the ink it prints on). + pub aliases: Vec<(String, String)>, +} + +impl InkManager { + fn is_default(&self) -> bool { + *self == Self::default() + } + + /// The ink a spot swatch prints on (after aliases; at most a few hops) and whether it's + /// converted to process. + pub fn resolve<'a>(&'a self, ink: &'a str) -> (&'a str, bool) { + let mut n = ink; + for _ in 0..8 { + match self.aliases.iter().find(|(a, _)| a == n) { + Some((_, to)) if to != n => n = to, + _ => break, + } + } + (n, self.all_to_process || self.to_process.iter().any(|x| x == n)) + } +} + /// A Swatches panel colour group: a named folder of swatches (by name). #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -337,6 +368,9 @@ pub struct Document { /// the top level. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub color_groups: Vec, + /// Ink Manager: spot inks printed as process, and spot inks aliased to others. + #[serde(default, skip_serializing_if = "InkManager::is_default")] + pub inks: InkManager, /// Document Endnote Options, and the story shown in the endnote frame (generated). #[serde(default)] pub endnote_options: EndnoteOptions, diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 29a9ed8..a224366 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -132,6 +132,71 @@ pub fn specs() -> Vec { ok() }) }), + cmd!(query "ink.list", "Ink Manager", [], None, "{} → {allToProcess, inks: [{name, process: bool (printed as process), alias?}]} — the spot inks", has_doc, |s, _| { + let d = &s.doc()?.doc; + let inks: Vec = d + .swatches + .iter() + .filter(|w| matches!(w.value, designcraft_color::swatch::SwatchValue::Color { color_type: designcraft_color::swatch::ColorType::Spot, .. })) + .map(|w| { + let alias = d.inks.aliases.iter().find(|(a, _)| *a == w.name).map(|(_, t)| t.clone()); + json!({"name": w.name, "process": d.inks.resolve(&w.name).1, "alias": alias}) + }) + .collect(); + Ok(json!({"allToProcess": d.inks.all_to_process, "inks": inks})) + }), + cmd!( + "ink.options", + "Ink Manager", + [], + None, + "{allToProcess?: bool, ink?: spot swatch, toProcess?: bool, alias?: spot swatch | null} — output: spots to process, ink aliases", + has_doc, + |s, p| { + let all = p.get("allToProcess").and_then(Value::as_bool); + let ink = str_param(p, "ink").map(str::to_string); + let to_process = p.get("toProcess").and_then(Value::as_bool); + let alias = p.get("alias").cloned(); + s.edit(|d, _| { + if let Some(v) = all { + d.inks.all_to_process = v; + } + if let Some(name) = &ink { + let is_spot = |n: &str| { + d.swatch(n).is_some_and(|w| { + matches!( + w.value, + designcraft_color::swatch::SwatchValue::Color { color_type: designcraft_color::swatch::ColorType::Spot, .. } + ) + }) + }; + if !is_spot(name) { + return Err(bad("ink.options", format!("`{name}` isn't a spot ink"))); + } + if let Some(Value::String(to)) = &alias + && (!is_spot(to) || to == name) + { + return Err(bad("ink.options", format!("can't alias to `{to}`"))); + } + if let Some(v) = to_process { + d.inks.to_process.retain(|x| x != name); + if v { + d.inks.to_process.push(name.clone()); + } + } + match alias { + Some(Value::String(to)) => { + d.inks.aliases.retain(|(a, _)| a != name); + d.inks.aliases.push((name.clone(), to)); + } + Some(Value::Null) => d.inks.aliases.retain(|(a, _)| a != name), + _ => {} + } + } + ok() + }) + } + ), cmd!( "swatch.newColorGroup", "New Color Group", @@ -936,3 +1001,38 @@ mod color_group_tests { assert!(s.doc().unwrap().doc.swatch(&a).is_some(), "ungrouping keeps the swatches"); } } + +#[cfg(test)] +mod ink_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn ink_manager_converts_and_aliases_spots_in_pdf() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + for (n, c) in [("Pantone A", [0, 50, 100, 0]), ("Pantone B", [100, 0, 0, 0])] { + s.execute("swatch.create", &json!({"name": n, "spot": true, "color": {"c": c[0], "m": c[1], "y": c[2], "k": c[3]}})).unwrap(); + } + let id = s.execute("frame.create", &json!({"rect": [72, 72, 200, 200]})).unwrap()["id"].clone(); + s.execute("object.fill", &json!({"swatch": "Pantone A", "ids": [id]})).unwrap(); + let pdf = |s: &mut Session| { + let b = s.execute("file.exportPdf", &json!({})).unwrap()["base64"].as_str().unwrap().to_string(); + String::from_utf8_lossy(&super::super::file::base64_decode(&b)).into_owned() + }; + assert!(pdf(&mut s).contains("Pantone"), "a separation"); + s.execute("ink.options", &json!({"ink": "Pantone A", "alias": "Pantone B"})).unwrap(); + let p = pdf(&mut s); + assert!(p.contains("Pantone#20B") || p.contains("Pantone B"), "aliased onto B"); + assert!(!p.contains("Pantone#20A") && !p.contains("/Pantone A")); + s.execute("ink.options", &json!({"allToProcess": true})).unwrap(); + assert!(!pdf(&mut s).contains("Pantone"), "all spots to process"); + let l = s.execute("ink.list", &json!({})).unwrap(); + assert_eq!(l["inks"][0]["alias"], "Pantone B"); + let back = designcraft_idml::import_idml(&designcraft_idml::export_idml(&s.doc().unwrap().doc)).unwrap(); + assert!(back.inks.resolve("Pantone A").1, "IDML ConvertToProcess"); + assert!(back.inks.aliases.iter().any(|(a, b)| a == "Pantone A" && b == "Pantone B"), "IDML AliasInkName"); + assert!(s.execute("ink.options", &json!({"ink": "[Black]", "toProcess": true})).is_err()); + } +} diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 82d9a5a..737a720 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -485,12 +485,16 @@ impl<'a> Ex<'a> { colors.push(color_el(&id, &s.name, model, color, "Normal", true, true, true)); by_value.push((*color, id.clone())); if *color_type == ColorType::Spot { + let mut ink = El::new("Ink"); + if let Some((_, to)) = d.inks.aliases.iter().find(|(a, _)| *a == s.name) { + ink.set("AliasInkName", to); + } + let convert = d.inks.all_to_process || d.inks.to_process.contains(&s.name); inks.push( - El::new("Ink") - .attr("Self", format!("Ink/{}", escape_id(&s.name))) + ink.attr("Self", format!("Ink/{}", escape_id(&s.name))) .attr("Name", &s.name) .attr("Angle", "45") - .attr("ConvertToProcess", "false") + .attr("ConvertToProcess", bool_s(convert)) .attr("Frequency", "70") .attr("NeutralDensity", "0.5") .attr("PrintInk", "true") diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 0bed29b..7d2d281 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -100,6 +100,7 @@ struct Importer<'r> { color_groups: Vec, conditions: Vec, condition_names: HashMap, + inks: designcraft_doc::InkManager, styles: Styles, para_names: HashMap, char_names: HashMap, @@ -181,6 +182,7 @@ impl<'r> Importer<'r> { color_groups: Vec::new(), conditions: Vec::new(), condition_names: HashMap::new(), + inks: Default::default(), styles, para_names: HashMap::new(), char_names: HashMap::new(), @@ -513,6 +515,21 @@ impl<'r> Importer<'r> { Swatch { name, value: SwatchValue::Tint { base, tint: (t / 100.0) as f32 }, locked: false, named: true, hidden: false }, )); } + // Ink Manager. + for e in top.iter().filter(|e| e.local() == "Ink") { + let Some(name) = e + .get("Name") + .filter(|n| !n.starts_with("$ID/") && !["Process Cyan", "Process Magenta", "Process Yellow", "Process Black"].contains(n)) + else { + continue; + }; + if e.get("ConvertToProcess") == Some("true") { + self.inks.to_process.push(name.to_string()); + } + if let Some(a) = e.get("AliasInkName").filter(|a| !a.is_empty() && *a != name && !a.starts_with("$ID/")) { + self.inks.aliases.push((name.to_string(), a.to_string())); + } + } for e in top.iter().filter(|e| e.local() == "Gradient") { let Some(id) = e.get("Self") else { continue }; let mut stops: Vec = Vec::new(); @@ -1988,6 +2005,7 @@ impl<'r> Importer<'r> { swatches: std::mem::take(&mut self.swatches), color_groups: std::mem::take(&mut self.color_groups), conditions: std::mem::take(&mut self.conditions), + inks: std::mem::take(&mut self.inks), endnote_options: Default::default(), endnote_story: None, sections: std::mem::take(&mut self.sections), diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index e16ab00..3a6dff0 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -466,8 +466,16 @@ impl Exporter<'_> { n = base; } SwatchValue::Registration => return Some(registration(t, self.rgb_only)), - SwatchValue::Color { color, color_type: ColorType::Spot } => { - let space = SeparationSpace::new(SeparationColorant::Custom(sw.name.clone()), regular(color, self.rgb_only)); + SwatchValue::Color { color: _, color_type: ColorType::Spot } => { + // Ink Manager: an alias prints on another ink; converted inks go to process. + let (ink, process) = self.doc.inks.resolve(&sw.name); + let target = + self.doc.swatch(ink).filter(|w| matches!(w.value, SwatchValue::Color { color_type: ColorType::Spot, .. })).unwrap_or(sw); + let SwatchValue::Color { color, .. } = &target.value else { break }; + if process { + return Some(device(&designcraft_color::swatch::apply_tint(*color, t), self.rgb_only)); + } + let space = SeparationSpace::new(SeparationColorant::Custom(target.name.clone()), regular(color, self.rgb_only)); return Some(SepColor::new(q(t), space).into()); } _ => break, diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index b34d6ac..67e9ab5 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -66,6 +66,27 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let _ = app.run("swatch.create", json!({"color": {"c": 0, "m": 50, "y": 100, "k": 0}})); } ui.menu_button("☰", |ui| { + ui.menu_button("Ink Manager", |ui| { + let Ok(l) = app.session.execute("ink.list", &json!({})) else { return }; + let mut all = l["allToProcess"].as_bool().unwrap_or(false); + if ui.checkbox(&mut all, "All Spots to Process").changed() { + let _ = app.run("ink.options", json!({"allToProcess": all})); + } + ui.separator(); + let inks = l["inks"].as_array().cloned().unwrap_or_default(); + if inks.is_empty() { + ui.label("No spot inks"); + } + for ink in inks { + let name = ink["name"].as_str().unwrap_or("").to_string(); + let mut process = ink["process"].as_bool().unwrap_or(false); + ui.add_enabled_ui(!all, |ui| { + if ui.checkbox(&mut process, format!("{name} → process")).changed() { + let _ = app.run("ink.options", json!({"ink": name, "toProcess": process})); + } + }); + } + }); if ui.button("New Color Group").clicked() { let _ = app.run("swatch.newColorGroup", json!({})); ui.close(); diff --git a/docs/parity.md b/docs/parity.md index 190c81e..77418dc 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -11,7 +11,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia |---|---|---|---|---|---| | P0 | 102 | 99 | 3 | 0 | 99% | | P1 | 97 | 75 | 21 | 1 | 88% | -| P2 | 76 | 22 | 13 | 41 | 38% | +| P2 | 76 | 23 | 13 | 40 | 39% | | Area | Parity | |---|---| @@ -20,7 +20,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Layers | 95% | | Frames, shapes & paths | 93% | | Transform | 100% | -| Fill, stroke, colour | 88% | +| Fill, stroke, colour | 90% | | Effects & transparency | 71% | | Placing & links | 82% | | Type & text frames | 92% | @@ -147,7 +147,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t | Fill, stroke, colour | Tints | P0 | D | | Fill, stroke, colour | Special swatches | P0 | D | | Fill, stroke, colour | Spot colours | P1 | D | -| Fill, stroke, colour | Ink Manager | P2 | M | +| Fill, stroke, colour | Ink Manager | P2 | D | | Fill, stroke, colour | Load/Save Swatches (.ase) | P1 | D | | Fill, stroke, colour | Color groups | P2 | D | | Fill, stroke, colour | Unnamed colour to swatch | P1 | D |