C: Ink Manager — spots to process, ink aliases (PDF, IDML)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 23:47:58 -04:00
parent 8b4290f4c4
commit efb563673f
9 changed files with 195 additions and 8 deletions

View File

@ -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.

View File

@ -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 {

View File

@ -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<String>,
/// 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<ColorGroup>,
/// 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,

View File

@ -132,6 +132,71 @@ pub fn specs() -> Vec<CommandSpec> {
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<Value> = 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());
}
}

View File

@ -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")

View File

@ -100,6 +100,7 @@ struct Importer<'r> {
color_groups: Vec<designcraft_doc::ColorGroup>,
conditions: Vec<designcraft_doc::Condition>,
condition_names: HashMap<String, String>,
inks: designcraft_doc::InkManager,
styles: Styles,
para_names: HashMap<String, String>,
char_names: HashMap<String, String>,
@ -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<GradientStop> = 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),

View File

@ -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,

View File

@ -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();

View File

@ -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 |