From c6d980b25ffad28a9d26e83ce28a81aefeec2d25 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 12:53:54 +0000 Subject: [PATCH 1/5] Print color: inks on craft-prepress's InkSet The Ink Manager settings stay saved by swatch name; PDF export and the ink commands build a craft_prepress::InkSet from the document's spot swatches and those settings, and ink.options refuses alias loops. The hayro crates come from craft-libs at the craft-pdf revision, which craft-prepress's plate mode needs. --- Cargo.lock | 36 ++++++++++--- Cargo.toml | 11 ++-- crates/doc/src/lib.rs | 16 +----- crates/engine/Cargo.toml | 1 + crates/engine/src/cmd/style.rs | 13 ++++- crates/pdf/Cargo.toml | 1 + crates/pdf/src/export.rs | 7 ++- crates/pdf/src/inks.rs | 97 ++++++++++++++++++++++++++++++++++ crates/pdf/src/lib.rs | 1 + 9 files changed, 155 insertions(+), 28 deletions(-) create mode 100644 crates/pdf/src/inks.rs diff --git a/Cargo.lock b/Cargo.lock index 8c9ed8c8c..eaae5efd3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -816,6 +816,14 @@ dependencies = [ "rustix 1.1.5", ] +[[package]] +name = "craft-cms" +version = "0.1.0" +source = "git+https://github.com/aiongg/craft-libs?rev=0fe6156614de2e8780f34612fa6a577c745f35bc#0fe6156614de2e8780f34612fa6a577c745f35bc" +dependencies = [ + "rayon", +] + [[package]] name = "craft-cms" version = "0.1.0" @@ -834,10 +842,24 @@ name = "craft-pdf" version = "0.1.0" source = "git+https://github.com/aiongg/craft-libs?rev=0fe6156614de2e8780f34612fa6a577c745f35bc#0fe6156614de2e8780f34612fa6a577c745f35bc" dependencies = [ + "bytemuck", + "hayro", + "hayro-interpret", + "hayro-syntax", "krilla", "thiserror 2.0.21", ] +[[package]] +name = "craft-prepress" +version = "0.1.0" +source = "git+https://github.com/aiongg/craft-libs?rev=0fe6156614de2e8780f34612fa6a577c745f35bc#0fe6156614de2e8780f34612fa6a577c745f35bc" +dependencies = [ + "craft-cms 0.1.0 (git+https://github.com/aiongg/craft-libs?rev=0fe6156614de2e8780f34612fa6a577c745f35bc)", + "craft-pdf", + "thiserror 2.0.21", +] + [[package]] name = "craft-print" version = "0.1.0" @@ -969,7 +991,7 @@ dependencies = [ name = "designcraft-color" version = "0.9.0" dependencies = [ - "craft-cms", + "craft-cms 0.1.0 (git+https://github.com/storytold/craft-libs?rev=e9546e191dd62f2bc1d61eca45dbdc2f1b3c1d8e)", "kurbo", "proptest", "serde", @@ -1016,6 +1038,7 @@ dependencies = [ "caseless", "craft-atomic", "craft-docpath", + "craft-prepress", "craft-print", "designcraft-color", "designcraft-compose", @@ -1114,7 +1137,7 @@ dependencies = [ name = "designcraft-images" version = "0.9.0" dependencies = [ - "craft-cms", + "craft-cms 0.1.0 (git+https://github.com/storytold/craft-libs?rev=e9546e191dd62f2bc1d61eca45dbdc2f1b3c1d8e)", "designcraft-color", "designcraft-fonts", "image", @@ -1143,6 +1166,7 @@ name = "designcraft-pdf" version = "0.9.0" dependencies = [ "craft-pdf", + "craft-prepress", "designcraft-color", "designcraft-compose", "designcraft-doc", @@ -2105,7 +2129,7 @@ dependencies = [ [[package]] name = "hayro" version = "0.7.1" -source = "git+https://github.com/storytold/pdfcraft?rev=b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9#b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" +source = "git+https://github.com/aiongg/craft-libs?rev=0fe6156614de2e8780f34612fa6a577c745f35bc#0fe6156614de2e8780f34612fa6a577c745f35bc" dependencies = [ "bytemuck", "hayro-interpret", @@ -2135,7 +2159,7 @@ dependencies = [ [[package]] name = "hayro-interpret" version = "0.7.0" -source = "git+https://github.com/storytold/pdfcraft?rev=b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9#b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" +source = "git+https://github.com/aiongg/craft-libs?rev=0fe6156614de2e8780f34612fa6a577c745f35bc#0fe6156614de2e8780f34612fa6a577c745f35bc" dependencies = [ "bitflags 2.13.2", "hayro-cmap", @@ -2153,7 +2177,7 @@ dependencies = [ [[package]] name = "hayro-jbig2" version = "0.3.0" -source = "git+https://github.com/storytold/pdfcraft?rev=b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9#b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" +source = "git+https://github.com/aiongg/craft-libs?rev=0fe6156614de2e8780f34612fa6a577c745f35bc#0fe6156614de2e8780f34612fa6a577c745f35bc" dependencies = [ "fearless_simd", "hayro-ccitt", @@ -2177,7 +2201,7 @@ checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa" [[package]] name = "hayro-syntax" version = "0.7.2" -source = "git+https://github.com/storytold/pdfcraft?rev=b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9#b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" +source = "git+https://github.com/aiongg/craft-libs?rev=0fe6156614de2e8780f34612fa6a577c745f35bc#0fe6156614de2e8780f34612fa6a577c745f35bc" dependencies = [ "flate2", "hayro-ccitt", diff --git a/Cargo.toml b/Cargo.toml index 7d563d07a..14aabc7e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -120,8 +120,9 @@ debug = 0 [patch.crates-io] winit = { git = "https://github.com/skylartaylor/winit.git", rev = "3d51077fd1a966f84df9ef287839dc84fd5b4ca7" } krilla = { git = "https://github.com/aiongg/craft-libs", rev = "0fe6156614de2e8780f34612fa6a577c745f35bc", version = "=0.8.2" } -# The hayro crates from pdfcraft's vendor/ directory, with the patches its README lists. -hayro = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" } -hayro-interpret = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" } -hayro-syntax = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" } -hayro-jbig2 = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" } +# The hayro crates from craft-libs' vendor/ directory (pdfcraft's, plus the plate mode and decode +# limits craft-pdf and craft-prepress need); same revision as craft-pdf. +hayro = { git = "https://github.com/aiongg/craft-libs", rev = "0fe6156614de2e8780f34612fa6a577c745f35bc" } +hayro-interpret = { git = "https://github.com/aiongg/craft-libs", rev = "0fe6156614de2e8780f34612fa6a577c745f35bc" } +hayro-syntax = { git = "https://github.com/aiongg/craft-libs", rev = "0fe6156614de2e8780f34612fa6a577c745f35bc" } +hayro-jbig2 = { git = "https://github.com/aiongg/craft-libs", rev = "0fe6156614de2e8780f34612fa6a577c745f35bc" } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 1bf834ae0..ed6691bd4 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -225,7 +225,8 @@ impl Document { } } -/// Ink Manager settings for output. +/// Ink Manager settings for output, by swatch name. Output applies them through a +/// `craft_prepress::InkSet` built from the document (`designcraft_pdf::inks`). #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct InkManager { @@ -241,19 +242,6 @@ 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)) - } } /// XML tags and mappings. diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 69e0e8d50..c1c0f1d33 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -23,6 +23,7 @@ designcraft-epub = { workspace = true } designcraft-pptx = { workspace = true } designcraft-textimport = { workspace = true } designcraft-pdf = { workspace = true } +craft-prepress = { workspace = true } designcraft-tools = { workspace = true } designcraft-idml = { workspace = true } indd = { workspace = true } diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index d1bd06ad0..5936eecca 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -498,13 +498,14 @@ pub fn specs() -> Vec { }), 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 = designcraft_pdf::inks::ink_set(d); 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}) + json!({"name": w.name, "process": designcraft_pdf::inks::spot_output(d, &inks, &w.name).1, "alias": alias}) }) .collect(); Ok(json!({"allToProcess": d.inks.all_to_process, "inks": inks})) @@ -550,6 +551,13 @@ pub fn specs() -> Vec { } match alias { Some(Value::String(to)) => { + let mut set = designcraft_pdf::inks::ink_set(d); + let ids = (set.lookup(name), set.lookup(&to)); + if let (Some(from), Some(target)) = ids + && !set.alias(from, target) + { + return Err(bad("ink.options", format!("aliasing `{name}` to `{to}` would make a loop"))); + } d.inks.aliases.retain(|(a, _)| a != name); d.inks.aliases.push((name.clone(), to)); } @@ -2789,9 +2797,10 @@ mod ink_tests { 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!(designcraft_pdf::inks::spot_output(&back, &designcraft_pdf::inks::ink_set(&back), "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()); + assert!(s.execute("ink.options", &json!({"ink": "Pantone B", "alias": "Pantone A"})).is_err(), "an alias loop is refused"); } } diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index 6f40462b7..efb059bfc 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -15,6 +15,7 @@ designcraft-doc = { workspace = true } designcraft-fonts = { workspace = true } designcraft-compose = { workspace = true } craft-pdf = { workspace = true, features = ["write-pdf"] } +craft-prepress = { workspace = true } krilla-svg = { workspace = true } designcraft-images = { workspace = true } image = { workspace = true } diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 4e7627e9e..d528a7b7c 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -159,6 +159,7 @@ fn export_pdf_with_report_inner(doc: &Document, cache: &Cache, opts: &PdfOptions let mut ex = Exporter { drawing_items: Vec::new(), + inks: crate::inks::ink_set(doc), doc, cache, opts, @@ -346,6 +347,7 @@ fn export_booklet_inner(doc: &Document, cache: &Cache, opts: &BookletOptions) -> let pdf = writer.document_mut(); let mut ex = Exporter { drawing_items: Vec::new(), + inks: crate::inks::ink_set(doc), doc, cache, opts: &pdf_opts, @@ -480,6 +482,8 @@ impl std::hash::Hasher for ConstantImageHasher { pub(crate) struct Exporter<'a> { pub doc: &'a Document, + /// The document's inks with its Ink Manager settings. + inks: craft_prepress::InkSet, pub cache: &'a Cache, pub opts: &'a PdfOptions, /// Current sheet's visible area (spread space) for culling. @@ -644,7 +648,7 @@ impl Exporter<'_> { SwatchValue::Registration => return Some(registration(t, 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 (ink, process) = crate::inks::spot_output(self.doc, &self.inks, &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 }; @@ -1800,6 +1804,7 @@ mod image_content_cache_tests { fn exporter<'a>(doc: &'a Document, cache: &'a Cache, opts: &'a PdfOptions) -> Exporter<'a> { Exporter { drawing_items: Vec::new(), + inks: crate::inks::ink_set(doc), doc, cache, opts, diff --git a/crates/pdf/src/inks.rs b/crates/pdf/src/inks.rs new file mode 100644 index 000000000..72009e9e1 --- /dev/null +++ b/crates/pdf/src/inks.rs @@ -0,0 +1,97 @@ +//! The document's inks as a [`craft_prepress::InkSet`]: the four process inks, each spot swatch +//! with its color as the alternate, and the Ink Manager settings (aliases, spots to process). +//! +//! The document saves the Ink Manager by swatch name ([`designcraft_doc::InkManager`]); the set +//! is built from it whenever output needs it, so renaming or editing a swatch never leaves a +//! stale copy. + +use craft_prepress::{Alternate, Ink, InkId, InkSet, Spot}; +use designcraft_color::swatch::{ColorType, Swatch, SwatchValue}; +use designcraft_doc::Document; + +fn is_spot(w: &Swatch) -> bool { + matches!(w.value, SwatchValue::Color { color_type: ColorType::Spot, .. }) +} + +/// The inks of `doc` with its Ink Manager settings applied. An alias whose target isn't a spot +/// swatch, or that would make a loop, is left out: that spot prints on its own plate. +pub fn ink_set(doc: &Document) -> InkSet { + let mut set = InkSet::new(); + for w in doc.swatches.iter().filter(|w| is_spot(w)) { + let SwatchValue::Color { color, .. } = w.value else { continue }; + let alternate = match color { + designcraft_color::Color::Cmyk { c, m, y, k } => Alternate::Cmyk([c, m, y, k]), + other => { + let lab = other.to_lab(); + Alternate::Lab([lab.l, lab.a, lab.b]) + } + }; + set.add(Ink::Spot(Spot { name: w.name.clone(), alternate, solidity: None })); + } + let spot_id = |set: &InkSet, name: &str| doc.swatch(name).filter(|w| is_spot(w)).and_then(|_| set.lookup(name)); + for name in &doc.inks.to_process { + if let Some(id) = spot_id(&set, name) { + set.set_to_process(id, true); + } + } + for (from, to) in &doc.inks.aliases { + if let (Some(from), Some(to)) = (spot_id(&set, from), spot_id(&set, to)) { + set.alias(from, to); + } + } + set.set_all_to_process(doc.inks.all_to_process); + set +} + +/// How spot swatch `name` prints: the spot swatch whose plate (or, converted, whose color) it +/// takes after aliases, and whether it prints as process color. A name the set doesn't hold +/// prints as itself on its own plate. +pub fn spot_output<'a>(doc: &'a Document, set: &InkSet, name: &'a str) -> (&'a str, bool) { + let Some(id) = set.lookup(name) else { return (name, false) }; + let end = std::iter::successors(Some(id), |i| set.alias_of(*i)).take(set.len()).last().unwrap_or(id); + let target = if end == id { name } else { swatch_of(doc, set, end).unwrap_or(name) }; + (target, set.plate_of(id).is_none()) +} + +/// The first spot swatch that is ink `id`. +fn swatch_of<'a>(doc: &'a Document, set: &InkSet, id: InkId) -> Option<&'a str> { + doc.swatches.iter().filter(|w| is_spot(w)).find(|w| set.lookup(&w.name) == Some(id)).map(|w| w.name.as_str()) +} + +#[cfg(test)] +mod tests { + use super::*; + use designcraft_color::Color; + use designcraft_doc::build::NewDocument; + + fn spot(d: &mut Document, name: &str, color: Color) { + d.swatches.push(Swatch { + name: name.into(), + value: SwatchValue::Color { color, color_type: ColorType::Spot }, + locked: false, + named: true, + hidden: false, + }); + } + + #[test] + fn aliases_follow_chains_and_converted_inks_print_as_process() { + let mut d = Document::new(&NewDocument::default()); + spot(&mut d, "Gold", Color::Cmyk { c: 0.0, m: 0.2, y: 0.8, k: 0.1 }); + spot(&mut d, "Bronze", Color::Rgb { r: 0.6, g: 0.4, b: 0.2 }); + spot(&mut d, "Copper", Color::Cmyk { c: 0.0, m: 0.5, y: 0.6, k: 0.2 }); + d.inks.aliases = vec![("Gold".into(), "Bronze".into()), ("Bronze".into(), "Copper".into()), ("Copper".into(), "Gold".into())]; + d.inks.to_process = vec!["Copper".into()]; + let set = ink_set(&d); + assert_eq!(set.spots().map(|(_, s)| s.name.as_str()).collect::>(), ["Gold", "Bronze", "Copper"]); + // The loop-closing alias is refused, so Gold → Bronze → Copper, which is converted. + assert_eq!(spot_output(&d, &set, "Gold"), ("Copper", true)); + assert_eq!(spot_output(&d, &set, "Copper"), ("Copper", true)); + d.inks.to_process.clear(); + let set = ink_set(&d); + assert_eq!(spot_output(&d, &set, "Bronze"), ("Copper", false)); + assert_eq!(spot_output(&d, &set, "Unknown"), ("Unknown", false)); + d.inks.all_to_process = true; + assert!(spot_output(&d, &ink_set(&d), "Gold").1); + } +} diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index ad1123897..92a73d206 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -30,6 +30,7 @@ #![forbid(unsafe_code)] mod export; +pub mod inks; mod links; mod marks; mod text; From dd1f4b02ede26549e2954ac355b8ab571ff066f2 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 13:30:14 +0000 Subject: [PATCH 2/5] Print color: Separations Preview shows the exported PDF's plates Each visible spread is exported in memory as Export PDF writes it (its print settings, the document bleed) and separated with craft_prepress::plates off the UI thread, at a resolution matched to the zoom. The canvas draws all inks composited, one plate as film or the ink limit over gray; Window > Output > Separations Preview lists the plates, spot plates by name, with each ink's percentage under the pointer. view.separations takes spot ink names and reports the plates, ui.inspect shows the preview's sheets, and ink.separations gives coverage and the areas over an ink limit to automation. The renderer's GCR separation view is gone; its plate passes stay for the CMYK blend space. --- Cargo.lock | 1 + ROADMAP.md | 4 +- crates/engine/src/cmd/export.rs | 68 ++-- crates/engine/src/cmd/mod.rs | 3 + crates/engine/src/cmd/object.rs | 21 -- crates/engine/src/cmd/prepress.rs | 94 ++++++ crates/engine/src/lib.rs | 1 + crates/engine/src/separations.rs | 251 +++++++++++++++ crates/render/src/lib.rs | 50 +-- crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/canvas.rs | 8 +- crates/ui-egui/src/control.rs | 1 + crates/ui-egui/src/dock.rs | 2 + crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/i18n/cs.rs | 1 + crates/ui-egui/src/i18n/de.rs | 1 + crates/ui-egui/src/i18n/fr.rs | 1 + crates/ui-egui/src/i18n/ja.rs | 1 + crates/ui-egui/src/i18n/ka.rs | 1 + crates/ui-egui/src/i18n/pl.rs | 1 + crates/ui-egui/src/i18n/ru.rs | 1 + crates/ui-egui/src/lib.rs | 13 +- crates/ui-egui/src/menus.rs | 85 ++++- crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/panels/separations.rs | 116 +++++++ crates/ui-egui/src/separations.rs | 377 +++++++++++++++++++++++ docs/control-protocol.md | 2 +- docs/target-app-parity.md | 3 +- 28 files changed, 988 insertions(+), 122 deletions(-) create mode 100644 crates/engine/src/cmd/prepress.rs create mode 100644 crates/engine/src/separations.rs create mode 100644 crates/ui-egui/src/panels/separations.rs create mode 100644 crates/ui-egui/src/separations.rs diff --git a/Cargo.lock b/Cargo.lock index eaae5efd3..886cdf4f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1244,6 +1244,7 @@ dependencies = [ name = "designcraft-ui-egui" version = "0.9.0" dependencies = [ + "craft-prepress", "craft-ui", "designcraft-color", "designcraft-compose", diff --git a/ROADMAP.md b/ROADMAP.md index 0128924c0..44cec5c1b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -95,8 +95,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Content Collector and Placer tools (B; `conveyor.collect/place/list/clear`): copies of objects (with stories, styles, images) ride a conveyor between pages and documents; Alt-click keeps them on it. - Linked stories (Edit ▸ Place and Link, `story.placeAndLink/links/updateLink/unlink`): child stories copy a parent, show out of date in the Links panel and update. - Edit ▸ Menus (`window.hideMenuItem`): hide menu items; menus with hidden items end in Show All Menu Items. -- View ▸ Separations Preview (`view.separations`): one process plate as ink density, or areas over an ink limit in red (a GCR preview of the rendered view; spot plates and the flattener preview aren't there). -- Separations Preview plates now come from each object's own colour (CMYK as authored; RGB and images through the colour settings, images via a cached 17³ table), so pure black shows only on the black plate. +- View ▸ Separations Preview (`view.separations`, Window ▸ Output ▸ Separations Preview, `ink.separations`): the plates the print PDF puts on film, spot plates included. Each visible spread is exported as Export PDF writes it and separated with craft-prepress, so the Ink Manager's aliases and spots to process, overprint and the flattener show as printed. All inks as printed, one plate as film, or an ink limit in red over gray; the panel lists the plates with each ink's percentage under the pointer. - Language (Character panel ▸ Language, Properties ▸ Character, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, Japanese and Traditional Chinese 「…」, Simplified Chinese “…”), the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too, and so does the fallback for CJK characters the font lacks (with Draw Missing Glyphs from Fallback Fonts on): Japanese, Simplified Chinese, Traditional Chinese and Korean each try their own installed system fonts (Japanese starts with the craft-fonts Japanese faces, Mincho first, when built with them; Hangul, kana and Bopomofo pick their language's fonts whatever the language). Language names from IDML keep their spelling; locale codes as InDesign writes them for some languages (`de_DE_2006`, `nl_NL_2005`; any `ll`, `ll_CC`, `ll-CC`) are their language and region for all of the above, and Chinese is also recognised in its common spellings ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`). Spanish is hyphenated with Javier Bezos's Liang patterns (`hyph-es.tex` 5.0, MIT) through the `hypher` crate; hyphenation for other languages isn't bundled yet. - Books (`book.new/open/add/remove/list/styleSource/paginate/syncStyles/exportPdf`, Book panel): `.dcbook` files, continuous page numbering, style sync from a style source, one merged PDF. - Track Changes (`changes.track/list/acceptAll/rejectAll`, Type ▸ Track Changes): typing is marked added (highlighted on screen), deletions are kept but hidden from layout and exports until accepted or rejected. Per-change accept/reject and authors aren't there yet. @@ -348,6 +347,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-20** · Separations Preview shows the plates the exported PDF prints, spot plates included: each visible spread is exported in memory with the Export PDF settings and separated by craft-prepress off the UI thread, then shown as all inks, one plate or an ink limit. The Ink Manager builds craft-prepress's `InkSet` from the document for export and preview. On the sample magazine the first preview of a spread takes about 0.6–1.1 s, most of it the PDF export. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 8bbe41a8a..61fb6f946 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -707,33 +707,39 @@ fn first_page_ref(pdf: &[u8]) -> Option { s[i..].split_whitespace().next()?.parse().ok() } -fn export_pdf(s: &mut Session, p: &Value) -> Result { - let st = s.doc()?; - let opts = options(p, st.doc.page_count())?; - // Transparency Flattener presets: High / Medium / Low Resolution, or a ppi. - let ppi = match p.get("flatten") { +/// Transparency Flattener presets (`flatten`): High / Medium / Low Resolution, or a ppi. +pub(crate) fn flatten_ppi(id: &str, p: &Value) -> Result> { + Ok(match p.get("flatten") { None | Some(Value::Null) | Some(Value::Bool(false)) => None, Some(Value::String(s)) if s == "high" => Some(300.0), Some(Value::String(s)) if s == "medium" => Some(150.0), Some(Value::String(s)) if s == "low" => Some(72.0), Some(Value::Bool(true)) => Some(300.0), - Some(v) => Some( - v.as_f64().filter(|v| (36.0..=1200.0).contains(v)).ok_or_else(|| bad("file.exportPdf", "`flatten`: high|medium|low or 36–1200 ppi"))?, - ), - }; - let flattened = ppi.map(|ppi| flatten(&st.doc, &s.cache, ppi)); + Some(v) => Some(v.as_f64().filter(|v| (36.0..=1200.0).contains(v)).ok_or_else(|| bad(id, "`flatten`: high|medium|low or 36–1200 ppi"))?), + }) +} + +/// The print PDF of `opts`' pages, as Export PDF writes it before its interactive options: +/// transparency flattened at `ppi` (if any), placed PDFs with hidden layers and soft effects +/// rasterized, the PDF boxes of placed graphics applied, and the transparency blend space set. +pub(crate) fn print_pdf( + d: &designcraft_doc::Document, + cache: &designcraft_compose::Cache, + opts: &PdfOptions, + ppi: Option, +) -> Result { + let flattened = ppi.map(|ppi| flatten(d, cache, ppi)); // Placed PDFs with hidden layers go out as images showing just their visible layers. - let base: &designcraft_doc::Document = flattened.as_ref().map_or(&st.doc, |f| &f.0); + let base: &designcraft_doc::Document = flattened.as_ref().map_or(d, |f| &f.0); let (layered, layer_count) = rasterize_layered(base); let base2: &designcraft_doc::Document = layered.as_ref().unwrap_or(base); - let (fx_doc, fx_count) = rasterize_effects(base2, &s.cache); + let (fx_doc, fx_count) = rasterize_effects(base2, cache); let doc: &designcraft_doc::Document = fx_doc.as_ref().unwrap_or(base2); let boxed = super::interchange::with_pdf_boxes(doc); let doc: &designcraft_doc::Document = &boxed; - let r = designcraft_pdf::export_pdf_with_report(doc, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?; - let mut r = r; + let mut r = designcraft_pdf::export_pdf_with_report(doc, cache, opts).map_err(|e| EngineError::Other(e.to_string()))?; // Transparency Blend Space: the page group of pages with transparency. - let spreads = designcraft_pdf::sheet_spreads(doc, &opts); + let spreads = designcraft_pdf::sheet_spreads(doc, opts); let blended: Vec = spreads.iter().map(|si| doc.spreads.get(*si).is_some_and(|sp| sp.items.iter().any(|it| transparent(it)))).collect(); if blended.iter().any(|b| *b) { match designcraft_pdf::add_blend_space(&r.bytes, &blended, doc.settings.blend_space == designcraft_doc::BlendSpace::Cmyk) { @@ -741,6 +747,27 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result { None => r.warnings.push("the transparency blend space couldn't be set".into()), } } + if fx_count > 0 { + r.warnings.push(format!("{fx_count} object(s) with soft effects (shadows, glows, feathers, bevels) or knockout groups exported as images")); + } + if layer_count > 0 { + r.warnings.push(format!("{layer_count} placed PDF(s) with hidden layers exported as images")); + } + if let Some((_, n)) = &flattened + && *n > 0 + { + r.warnings.push(format!("transparency flattened: {n} region(s) rasterised")); + } + Ok(r) +} + +fn export_pdf(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let opts = options(p, st.doc.page_count())?; + let ppi = flatten_ppi("file.exportPdf", p)?; + let mut r = print_pdf(&st.doc, &s.cache, &opts, ppi)?; + let doc: &designcraft_doc::Document = &st.doc; + let spreads = designcraft_pdf::sheet_spreads(doc, &opts); // Interactive PDF view options. let mut cat = String::new(); if p.get("fullScreen").and_then(Value::as_bool) == Some(true) { @@ -793,17 +820,6 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result { None => r.warnings.push("page transitions couldn't be added to this PDF".into()), } } - if fx_count > 0 { - r.warnings.push(format!("{fx_count} object(s) with soft effects (shadows, glows, feathers, bevels) or knockout groups exported as images")); - } - if layer_count > 0 { - r.warnings.push(format!("{layer_count} placed PDF(s) with hidden layers exported as images")); - } - if let Some((_, n)) = &flattened - && *n > 0 - { - r.warnings.push(format!("transparency flattened: {n} region(s) rasterised")); - } match str_param(p, "path") { Some(path) => { write_file(path, &r.bytes)?; diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 3b16b9cfc..2d963bd2e 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -15,6 +15,7 @@ mod edit; mod editorial; mod endnotes; mod export; +pub(crate) use export::print_pdf; mod file; pub(crate) mod find; pub use find::same_find_domain; @@ -46,6 +47,7 @@ mod pdflayers; mod place_text; pub mod preflight; mod prefs; +mod prepress; mod print; mod qr; mod rulers; @@ -207,6 +209,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(thread::specs()); v.extend(tabs::specs()); v.extend(style::specs()); + v.extend(prepress::specs()); v.extend(load_styles::specs()); v.extend(table::specs()); v.extend(layout::specs()); diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 82f33a6d8..1aa9ed8ac 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -4135,27 +4135,6 @@ mod attributes_tests { mod fitting_tests { use super::*; - #[test] - fn separations_come_from_source_colours() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let c = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].clone(); - s.execute("object.fill", &json!({"swatch": "C=100 M=0 Y=0 K=0", "ids": [c]})).unwrap(); - let k = s.execute("frame.create", &json!({"rect": [300, 100, 400, 200]})).unwrap()["id"].clone(); - s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [k]})).unwrap(); - let d = s.doc().unwrap().doc.clone(); - let plate = |p: u8| { - let mut rr = designcraft_render::Renderer::new(); - rr.threads = 0; - rr.render_page(&d, &s.cache, 0, 1.0, false, &designcraft_render::RenderOptions { plate: Some(p), ..Default::default() }).unwrap() - }; - let (cyan, black) = (plate(0), plate(3)); - // Cyan ink only on the cyan frame; black ink only on the black one (no rich-black guess). - assert!(cyan.pixel(150, 150)[0] < 10 && cyan.pixel(350, 150)[0] > 245, "{:?} {:?}", cyan.pixel(150, 150), cyan.pixel(350, 150)); - assert!(black.pixel(350, 150)[0] < 10 && black.pixel(150, 150)[0] > 245); - assert!(cyan.pixel(20, 20)[0] > 245, "paper has no ink"); - } - #[test] fn inner_glow_bevel_and_satin_change_the_inside() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/prepress.rs b/crates/engine/src/cmd/prepress.rs new file mode 100644 index 000000000..8cf0f0b69 --- /dev/null +++ b/crates/engine/src/cmd/prepress.rs @@ -0,0 +1,94 @@ +//! Window › Output › Separations Preview, for automation: the plates a spread's print PDF +//! prints, with ink coverage and the areas over an ink limit. + +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, cmd, has_doc}; +use crate::{Result, Session}; + +const ID: &str = "ink.separations"; + +pub fn specs() -> Vec { + vec![cmd!(query "ink.separations", "Separations Preview", [], None, + "{page?: 1-based page position (default 1; its spread is separated), dpi?: 9–300 (default 72), inkLimit?: percent, standard?: none|x4|a2b, compressImages?: bool, flatten?: high|medium|low|ppi (as file.exportPdf)} — the spread exported as Export PDF prints it and separated → {spread, dpi, width, height, conversion, inks: [{name, spot, max, mean, area} (percent)], unseparated: [spot names past 32], maxTotal, maxTotalAt?: [x, y] (pt, spread), overLimit?: {limit, pixels, regions: [{x, y, width, height}] (pt, spread)}, warnings}", + has_doc, separations)] +} + +fn separations(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let d = &st.doc; + let page = match p.get("page") { + None | Some(Value::Null) => 0, + Some(v) => v + .as_u64() + .and_then(|n| usize::try_from(n).ok()) + .filter(|n| (1..=d.page_count()).contains(n)) + .map(|n| n - 1) + .ok_or_else(|| bad(ID, format!("bad page {v}")))?, + }; + let (si, _) = d.page_loc(page).ok_or_else(|| bad(ID, "the document has no pages"))?; + let dpi = match p.get("dpi") { + None | Some(Value::Null) => 72.0, + Some(v) => v + .as_f64() + .filter(|v| (f64::from(crate::separations::MIN_DPI)..=f64::from(crate::separations::MAX_DPI)).contains(v)) + .ok_or_else(|| bad(ID, "`dpi`: 9–300"))? as f32, + }; + let limit = match p.get("inkLimit") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_f64().filter(|v| v.is_finite() && *v >= 0.0).ok_or_else(|| bad(ID, "`inkLimit`: a percentage"))? as f32), + }; + let standard = match super::str_param(p, "standard") { + Some(v) => designcraft_pdf::Standard::parse(v).ok_or_else(|| bad(ID, format!("unknown standard `{v}` (none, x4, a2b)")))?, + None => designcraft_pdf::Standard::None, + }; + let print = crate::separations::Print { + standard, + compress_images: p.get("compressImages").and_then(Value::as_bool).unwrap_or(false), + flatten: super::export::flatten_ppi(ID, p)?, + }; + let sheet = crate::separations::separate(d, &s.cache, si, dpi, &print)?; + let pl = &sheet.plates; + let stats = pl.stats(&[]); + let inks: Vec = pl + .inks + .iter() + .zip(&stats.inks) + .map(|((_, ink), st)| json!({"name": ink.name(), "spot": ink.is_spot(), "max": st.max, "mean": st.mean, "area": st.area})) + .collect(); + let conversion = match &pl.conversion { + craft_prepress::Conversion::OutputIntent { profile, .. } => format!("output intent: {profile}"), + craft_prepress::Conversion::Fallback { profile } => profile.clone(), + craft_prepress::Conversion::DeviceFormulas => "device formulas".into(), + }; + let pt = |x: u32, y: u32| { + let p = sheet.to_spread(x, y); + [p.x, p.y] + }; + let mut out = json!({ + "spread": si, + "dpi": pl.dpi, + "width": pl.width, + "height": pl.height, + "conversion": conversion, + "inks": inks, + "unseparated": pl.unseparated, + "maxTotal": stats.max_total, + "maxTotalAt": stats.max_total_at.map(|(x, y)| pt(x, y)), + "warnings": sheet.warnings, + }); + if let Some(limit) = limit { + let above = pl.above(limit, &[]); + let k = 72.0 / f64::from(pl.dpi.max(f32::MIN_POSITIVE)); + let regions: Vec = above + .regions() + .iter() + .map(|r| { + let [x, y] = pt(r.x, r.y); + json!({"x": x, "y": y, "width": f64::from(r.width) * k, "height": f64::from(r.height) * k}) + }) + .collect(); + out["overLimit"] = json!({"limit": limit, "pixels": above.count, "regions": regions}); + } + Ok(out) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 183b81c5c..6ab10ea00 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -20,6 +20,7 @@ pub mod math; pub mod recovery; pub mod sample; pub mod script; +pub mod separations; mod tooling; use std::sync::Arc; diff --git a/crates/engine/src/separations.rs b/crates/engine/src/separations.rs new file mode 100644 index 000000000..a13b8af4f --- /dev/null +++ b/crates/engine/src/separations.rs @@ -0,0 +1,251 @@ +//! Separations Preview: the plates the print PDF puts on film, read back from that PDF. +//! +//! A spread is exported as Export PDF writes it (one PDF page for the spread, with the +//! document bleed) into memory, and `craft_prepress::plates` separates that page: one 8-bit +//! plane per ink, the four process inks and every spot ink the page paints, after the Ink +//! Manager's aliases and spots to process. The preview shows those plates, so it shows what +//! the PDF prints, not what the document model intends. + +use std::sync::Arc; + +use craft_prepress::{CmsConversion, Fallback, InkId, Options, Plates, Process}; +use designcraft_compose::Cache; +use designcraft_doc::{Document, PageSide}; +use designcraft_geom::Rect; +use designcraft_pdf::PdfOptions; +pub use designcraft_pdf::Standard; + +use crate::{EngineError, Result}; + +/// The highest resolution a preview separates at, in pixels per inch. craft-prepress further +/// lowers it to fit 4 megapixels per plate. +pub const MAX_DPI: f32 = 300.0; +/// The lowest resolution a preview separates at. +pub const MIN_DPI: f32 = 9.0; +/// The color over-limit areas show in, sRGB. +pub const OVER_LIMIT: [u8; 3] = [230, 40, 40]; + +/// A spread separated into plates. +pub struct Sheet { + pub spread: usize, + /// The PDF page's area in spread coordinates: the spread's trim plus the document bleed. + pub area: Rect, + pub plates: Plates, + /// The printing condition the composite converts process plates through. + cms: CmsConversion, + /// Export warnings. + pub warnings: Vec, +} + +impl std::fmt::Debug for Sheet { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Sheet").field("spread", &self.spread).field("area", &self.area).field("plates", &self.plates).finish_non_exhaustive() + } +} + +/// The area spread `si`'s print PDF page covers, in spread coordinates (the trim and the bleed, +/// as the PDF writer lays out a spread sheet). +fn sheet_area(doc: &Document, si: usize) -> Option { + let sp = doc.spreads.get(si)?; + let b = doc.settings.bleed.map(|v| if v.is_finite() { v.max(0.0) } else { 0.0 }); + // Bleed is top, bottom, inside, outside; single-sided pages read inside as left. + let lr = |side: PageSide| if side == PageSide::Left { (b[3], b[2]) } else { (b[2], b[3]) }; + let outer = sp.outer_page_indices(); + let l = outer.and_then(|(l, _)| sp.pages.get(l)).map_or(b[2], |p| lr(p.side).0); + let r = outer.and_then(|(_, r)| sp.pages.get(r)).map_or(b[3], |p| lr(p.side).1); + let trim = sp.bounds(); + Some(Rect::new(trim.x0 - l, trim.y0 - b[0], trim.x1 + r, trim.y1 + b[1])) +} + +/// The print profile: the working CMYK space's output intent, as PDF/X-4 export embeds it. +fn printing_condition() -> (Fallback, CmsConversion) { + let Ok(intent) = designcraft_color::cms::active().output_intent() else { return (Fallback::Fogra39, CmsConversion::fogra39()) }; + match CmsConversion::new(&intent.icc) { + Ok(cms) => (Fallback::Profile(Arc::from(intent.icc.as_ref())), cms), + Err(_) => (Fallback::Fogra39, CmsConversion::fogra39()), + } +} + +/// The Export PDF settings that change what a page prints. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Print { + pub standard: Standard, + pub compress_images: bool, + /// The transparency flattener's resolution, in ppi. + pub flatten: Option, +} + +/// Exports spread `si` as Export PDF prints it with `print` (one page for the spread, with +/// the document bleed, without printer's marks) and separates it at `dpi`, clamped to +/// [`MIN_DPI`]–[`MAX_DPI`]. +pub fn separate(doc: &Document, cache: &Cache, si: usize, dpi: f32, print: &Print) -> Result { + let sp = doc.spreads.get(si).ok_or_else(|| EngineError::Other(format!("spread {si} does not exist")))?; + let first = doc.first_page_of_spread(si); + let pages: Vec = (first..first.saturating_add(sp.pages.len())).collect(); + let area = sheet_area(doc, si).ok_or_else(|| EngineError::Other(format!("spread {si} does not exist")))?; + let opts = PdfOptions { + pages: Some(pages), + spreads: true, + bleed: true, + standard: print.standard, + compress_images: print.compress_images, + ..PdfOptions::default() + }; + let report = crate::cmd::print_pdf(doc, cache, &opts, print.flatten)?; + let dpi = if dpi.is_finite() { dpi.clamp(MIN_DPI, MAX_DPI) } else { MIN_DPI }; + let (fallback, cms) = printing_condition(); + let plates = craft_prepress::plates(&report.bytes, 0, dpi, &Options { overprint: true, fallback }) + .map_err(|e| EngineError::Other(format!("separations: {e}")))?; + Ok(Sheet { spread: si, area, plates, cms, warnings: report.warnings }) +} + +impl Sheet { + /// The ink a plate name denotes: a process ink by its name in any case (`cyan`), or a spot + /// ink by name. + pub fn ink(&self, name: &str) -> Option { + Process::ALL.into_iter().find(|p| p.name().eq_ignore_ascii_case(name)).map(InkId::process).or_else(|| self.plates.inks.lookup(name)) + } + + /// Every plate: name and whether it's a spot ink, process inks first. + pub fn inks(&self) -> Vec<(String, bool)> { + self.plates.inks.iter().map(|(_, ink)| (ink.name().to_string(), ink.is_spot())).collect() + } + + /// The preview as opaque RGBA8 (`plates.width` × `plates.height`): every ink composited, + /// or with `plate` one ink alone as gray film (black is 100%). With `ink_limit` (percent of + /// total ink) the image is gray and the areas over the limit show in [`OVER_LIMIT`]. + pub fn image(&self, plate: Option, ink_limit: Option) -> Vec { + let p = &self.plates; + let mut rgb = match plate.and_then(|id| p.plane(id)) { + Some(plane) => plane.iter().flat_map(|t| [255 - t; 3]).collect(), + None => p.composite(&[], &self.cms), + }; + if let Some(limit) = ink_limit.filter(|l| l.is_finite()) { + for px in rgb.as_chunks_mut::<3>().0 { + let [r, g, b] = px.map(u32::from); + let y = ((r * 77 + g * 150 + b * 29) >> 8) as u8; + *px = [y; 3]; + } + p.above(limit, &[]).paint(&mut rgb, OVER_LIMIT); + } + rgb.as_chunks::<3>().0.iter().flat_map(|[r, g, b]| [*r, *g, *b, 255]).collect() + } + + /// Converts a pixel position to spread coordinates. + pub fn to_spread(&self, x: u32, y: u32) -> designcraft_geom::Point { + let k = 72.0 / f64::from(self.plates.dpi.max(f32::MIN_POSITIVE)); + designcraft_geom::Point::new(self.area.x0 + f64::from(x) * k, self.area.y0 + f64::from(y) * k) + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + use crate::Session; + + /// A letter page with a Gold spot square at (72, 72), a cyan square at (300, 72) and a + /// 400% square at (72, 400), each 100 pt. + fn document() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("swatch.create", &json!({"name": "Gold", "spot": true, "color": {"c": 0, "m": 20, "y": 80, "k": 10}})).unwrap(); + s.execute("swatch.create", &json!({"name": "Rich", "color": {"c": 100, "m": 100, "y": 100, "k": 100}})).unwrap(); + for (rect, swatch) in [([72, 72, 172, 172], "Gold"), ([300, 72, 400, 172], "C=100 M=0 Y=0 K=0"), ([72, 400, 172, 500], "Rich")] { + let id = s.execute("frame.create", &json!({"rect": rect})).unwrap()["id"].clone(); + s.execute("object.fill", &json!({"swatch": swatch, "ids": [id]})).unwrap(); + } + s + } + + fn sheet(s: &Session) -> Sheet { + separate(&s.doc().unwrap().doc, &s.cache, 0, 36.0, &Print::default()).unwrap() + } + + /// The tint of `ink` at spread point (`x`, `y`). + fn tint(sheet: &Sheet, ink: &str, x: f64, y: f64) -> u8 { + let k = f64::from(sheet.plates.dpi) / 72.0; + let (px, py) = (((x - sheet.area.x0) * k) as u32, ((y - sheet.area.y0) * k) as u32); + sheet.plates.tint(sheet.ink(ink).unwrap(), px, py).unwrap() + } + + #[test] + fn the_plates_are_the_process_inks_and_the_spot_and_the_spot_plate_shows_only_the_spot() { + let s = document(); + let sheet = sheet(&s); + let names: Vec<(String, bool)> = sheet.inks(); + let expected: Vec<(String, bool)> = + [("Cyan", false), ("Magenta", false), ("Yellow", false), ("Black", false), ("Gold", true)].map(|(n, sp)| (n.to_string(), sp)).into(); + assert_eq!(names, expected); + assert!(tint(&sheet, "Gold", 122.0, 122.0) > 250, "Gold under the Gold square"); + assert_eq!(tint(&sheet, "Gold", 350.0, 122.0), 0, "no Gold under the cyan square"); + assert_eq!(tint(&sheet, "Gold", 122.0, 450.0), 0, "no Gold under the 400% square"); + assert_eq!(tint(&sheet, "cyan", 122.0, 122.0), 0, "the spot isn't separated into process"); + assert!(tint(&sheet, "cyan", 350.0, 122.0) > 250); + + // The Gold plate alone: film only where the Gold square is. + let img = sheet.image(sheet.ink("Gold"), None); + let at = |x: f64, y: f64| { + let k = f64::from(sheet.plates.dpi) / 72.0; + let i = (((y - sheet.area.y0) * k) as usize * sheet.plates.width as usize + ((x - sheet.area.x0) * k) as usize) * 4; + [img[i], img[i + 1], img[i + 2]] + }; + assert!(at(122.0, 122.0).iter().all(|v| *v < 5), "{:?}", at(122.0, 122.0)); + assert_eq!(at(350.0, 122.0), [255; 3]); + assert_eq!(at(122.0, 450.0), [255; 3]); + let inked = img.as_chunks::<4>().0.iter().filter(|px| px[0] < 128).count(); + let square = (100.0 * f64::from(sheet.plates.dpi) / 72.0).powi(2); + assert!((inked as f64 - square).abs() < square * 0.1, "{inked} dark pixels, the square has {square}"); + } + + #[test] + fn ink_limit_flags_the_400_percent_area() { + let mut s = document(); + let r = s.execute("ink.separations", &json!({"dpi": 36, "inkLimit": 300})).unwrap(); + assert!(r["maxTotal"].as_f64().unwrap() > 399.0, "{r}"); + let regions = r["overLimit"]["regions"].as_array().unwrap(); + assert_eq!(regions.len(), 1, "{r}"); + let (x, y) = (regions[0]["x"].as_f64().unwrap(), regions[0]["y"].as_f64().unwrap()); + assert!((x - 72.0).abs() < 3.0 && (y - 400.0).abs() < 3.0, "the 400% square: {r}"); + assert!(r["inks"].as_array().unwrap().iter().any(|i| i["name"] == "Gold" && i["spot"] == true)); + + let sheet = sheet(&s); + let img = sheet.image(None, Some(300.0)); + let k = f64::from(sheet.plates.dpi) / 72.0; + let at = |x: f64, y: f64| { + let i = (((y - sheet.area.y0) * k) as usize * sheet.plates.width as usize + ((x - sheet.area.x0) * k) as usize) * 4; + [img[i], img[i + 1], img[i + 2]] + }; + assert_eq!(at(122.0, 450.0), OVER_LIMIT); + let gray = at(350.0, 122.0); + assert!(gray[0] == gray[1] && gray[1] == gray[2], "under the limit the preview is gray: {gray:?}"); + } + + #[test] + fn a_spot_aliased_to_another_spot_prints_on_its_plate() { + let mut s = document(); + s.execute("swatch.create", &json!({"name": "Silver", "spot": true, "color": {"c": 0, "m": 0, "y": 0, "k": 40}})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [300, 400, 400, 500]})).unwrap()["id"].clone(); + s.execute("object.fill", &json!({"swatch": "Silver", "ids": [id]})).unwrap(); + assert!(sheet(&s).ink("Silver").is_some()); + s.execute("ink.options", &json!({"ink": "Gold", "alias": "Silver"})).unwrap(); + let sheet = sheet(&s); + assert!(sheet.ink("Gold").is_none(), "{:?}", sheet.inks()); + assert!(tint(&sheet, "Silver", 122.0, 122.0) > 250, "Gold's square is on the Silver plate"); + assert!(tint(&sheet, "Silver", 350.0, 450.0) > 250); + } + + #[test] + fn a_saved_document_with_ink_settings_loads_and_its_preview_applies_them() { + let s = document(); + let mut v = serde_json::to_value(&*s.doc().unwrap().doc).unwrap(); + // Ink Manager settings as documents save them. + v["inks"] = json!({"allToProcess": false, "toProcess": ["Gold"], "aliases": []}); + let doc: Document = serde_json::from_value(v).unwrap(); + assert_eq!(doc.inks.to_process, ["Gold"]); + let sheet = separate(&doc, &s.cache, 0, 36.0, &Print::default()).unwrap(); + assert!(sheet.ink("Gold").is_none(), "Gold prints as process: {:?}", sheet.inks()); + assert!(tint(&sheet, "Yellow", 122.0, 122.0) > 150, "Gold's yellow"); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index c42d8c897..904590811 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -145,9 +145,9 @@ pub struct RenderOptions { pub change_markup: bool, /// View › Structure › Show Tag Markers: brackets around inline-tagged text (screen view). pub tag_markers: bool, - /// Separations Preview: draw one process plate (0 = cyan … 3 = black) as grey ink coverage, - /// from each object's own colour (CMYK as authored, others through the colour settings). - /// 4 (internal, the CMYK blend space): cyan, magenta and yellow coverage in red, green, blue. + /// The CMYK blend space's ink passes (internal): 3 draws black ink coverage as grey, 4 cyan, + /// magenta and yellow coverage in red, green and blue, from each object's own colour (CMYK as + /// authored, others through the colour settings); 0–2 draw cyan, magenta or yellow as grey. pub plate: Option, /// View › Display Performance. pub quality: DisplayQuality, @@ -1017,7 +1017,7 @@ thread_local! { static HIDDEN_LAYERS: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; /// Appearance of Black for the render on this thread: 100% K as pure black. static RICH_BLACK: std::cell::Cell = const { std::cell::Cell::new(false) }; - /// Separations Preview plate for the render on this thread. + /// The CMYK blend space's ink pass for the render on this thread (`RenderOptions::plate`). static PLATE: std::cell::Cell> = const { std::cell::Cell::new(None) }; } @@ -1393,9 +1393,6 @@ pub fn crop_pixmap(pm: &Pixmap, frac: [f64; 4]) -> Option { Some(out) } -/// Separations Preview on a rendered image: `plate` 0–3 shows that process plate (C, M, Y, K) as -/// ink density in grey; with an `ink_limit` (0–4, total ink) areas over it are shown in red. -/// Pixels are separated with a plain GCR (black = 1 − max(r, g, b)), so it's a preview. /// View › Proof Colors: simulate the proof target (a press, another RGB space, colour-vision /// deficiencies) through the active colour settings' proof table. pub fn proof_view(img: &mut Rendered, setup: &designcraft_color::cms::ProofSetup) { @@ -1440,31 +1437,6 @@ fn blend_space_view(img: &mut Rendered, area: Rect) { } } -pub fn separation_view(img: &mut Rendered, plate: Option, ink_limit: Option) { - for px in img.pixels.as_chunks_mut::<4>().0 { - let a = px[3] as f32 / 255.0; - // Composite over paper white, then separate. - let ch = |v: u8| (v as f32 / 255.0) + (1.0 - a); - let (r, g, b) = (ch(px[0]).min(1.0), ch(px[1]).min(1.0), ch(px[2]).min(1.0)); - let k = 1.0 - r.max(g).max(b); - let inks = - if k >= 0.999 { [0.0, 0.0, 0.0, 1.0] } else { [(1.0 - r - k) / (1.0 - k), (1.0 - g - k) / (1.0 - k), (1.0 - b - k) / (1.0 - k), k] }; - let mut out = match plate { - Some(p) => { - let v = ((1.0 - inks[p.min(3) as usize].clamp(0.0, 1.0)) * 255.0).round() as u8; - [v, v, v, 255] - } - None => [(r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8, 255], - }; - if let Some(limit) = ink_limit - && inks.iter().sum::() > limit - { - out = [230, 40, 40, 255]; - } - px.copy_from_slice(&out); - } -} - pub fn image_mime(bytes: &[u8]) -> &'static str { designcraft_images::mime(bytes) } @@ -1797,20 +1769,6 @@ mod tests { } } - #[test] - fn separations_split_plates_and_flag_ink_limits() { - // Pure cyan (0, 255, 255) and rich black-ish (20, 20, 20). - let mut img = Rendered { width: 2, height: 1, pixels: vec![0, 255, 255, 255, 20, 20, 20, 255] }; - let mut c = img.clone(); - separation_view(&mut c, Some(0), None); - assert_eq!(c.pixel(0, 0)[0], 0, "cyan plate solid under cyan"); - let mut m = img.clone(); - separation_view(&mut m, Some(1), None); - assert_eq!(m.pixel(0, 0)[0], 255, "no magenta"); - separation_view(&mut img, None, Some(0.5)); - assert_eq!(img.pixel(0, 0), [230, 40, 40, 255], "100% over a 50% limit"); - } - #[test] fn black_overprints_only_with_the_preference() { let mut d = Document::new(&NewDocument::default()); diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index c9d85253f..5a81c8843 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -19,6 +19,7 @@ designcraft-compose = { workspace = true } designcraft-render = { workspace = true } designcraft-tools = { workspace = true } designcraft-engine = { workspace = true } +craft-prepress = { workspace = true } unicode-bidi = { workspace = true } unicode-bidi-mirroring = { workspace = true } unicode-segmentation = { workspace = true } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8d94f099f..c12f1761b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -526,6 +526,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { painter.add(egui::Shape::mesh(mesh)); } } + crate::separations::paint(app, ui.ctx(), &painter, &xf, &layout, screen); let hair = 1.0 / ui.ctx().pixels_per_point(); for slot in &layout.slots { painter.rect_stroke(xf.rect(slot.bounds), 0.0, Stroke::new(hair, Color32::from_rgb(0x4a, 0x4a, 0x4a)), StrokeKind::Outside); @@ -649,7 +650,6 @@ fn screen_options(app: &DesignApp, layout: &CanvasLayout, preview: bool, ppp: f6 note_indicators: !preview && app.session.prefs.show_note_anchors, change_markup: !preview && app.session.prefs.show_added_text, tag_markers: !preview && app.ui.tag_markers, - plate: app.ui.separation, highlight_keeps: !preview && app.session.prefs.highlight_keeps, highlight_custom_tracking: !preview && app.session.prefs.highlight_custom_tracking, quality: app.ui.display_quality, @@ -682,7 +682,6 @@ fn grid_areas(layout: &CanvasLayout) -> Vec<(designcraft_doc::SpreadRef, DRect)> #[derive(Clone, Copy, Debug, PartialEq)] pub struct DisplayKey { quality: designcraft_render::DisplayQuality, - separation: Option, rich_black: bool, overprint_preview: bool, proof_colors: bool, @@ -700,7 +699,6 @@ fn display_key(app: &DesignApp) -> DisplayKey { let (ui, prefs) = (&app.ui, &app.session.prefs); DisplayKey { quality: ui.display_quality, - separation: ui.separation, rich_black: ui.rich_black, overprint_preview: ui.overprint_preview, proof_colors: ui.proof_colors, @@ -909,10 +907,6 @@ fn upload(app: &mut DesignApp, ctx: &egui::Context, mut img: designcraft_render: if app.ui.proof_colors { designcraft_render::proof_view(&mut img, &app.ui.proof_setup); } - // Plates render from source colours; the ink limit is estimated from the composite. - if app.ui.separation.is_none() && app.ui.ink_limit.is_some() { - designcraft_render::separation_view(&mut img, None, app.ui.ink_limit); - } let ci = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); let ci = crate::widgets::fit_texture(ci, ctx.input(|i| i.max_texture_side)); match &mut app.canvas.texture { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 1cb7395c0..2e4e2d5c8 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -91,6 +91,7 @@ pub fn inspect(app: &DesignApp, ctx: &egui::Context) -> Value { }) .collect::>(), "currentPage": crate::canvas::current_page(app), + "separations": crate::separations::inspect(app), "dialog": app.ui.dialog.as_ref().map(|d| serde_json::to_value(d).unwrap_or_default()), "activeDocument": app.session.active_index(), "perf": {"frameMs": app.perf.frame_ms, "renderMs": app.perf.render_ms, "fps": app.perf.fps, "patches": app.canvas.patches}, diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index d4551e813..417c4754e 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -31,6 +31,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("links", "Links", "panel-links"), ("info", "Info", "panel-info"), ("preflight", "Preflight", "panel-preflight"), + ("separations", "Separations Preview", "panel-swatches"), ("attributes", "Attributes", "panel-attributes"), ("conditions", "Conditional Text", "panel-conditions"), ("notes", "Notes", "panel-notes"), @@ -297,6 +298,7 @@ pub fn panel_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str) { "attributes" => panels::properties::attributes_panel(app, ui), "conditions" => panels::conditions::show(app, ui), "notes" => panels::notes::show(app, ui), + "separations" => panels::separations::show(app, ui), "library" => panels::library::show(app, ui), "hyperlinks" => panels::interactive::hyperlinks(app, ui), "bookmarks" => panels::interactive::bookmarks(app, ui), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index b050de38b..551f1c4ba 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -1874,6 +1874,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Import XML", "استيراد XML"), ("Indent to Here", "إزاحة بادئة إلى هنا"), ("Ink Limit 300%", "حد الحبر 300%"), + ("All Inks", "جميع الأحبار"), ("Insert Anchored Object", "إدراج كائن مرسّى"), ("Insert Cross-Reference", "إدراج مرجع متصالب"), ("Insert Math Expression", "إدراج تعبير رياضي"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index e77e72a1c..eababec6d 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -1688,6 +1688,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("I, II, III, IV...", "I, II, III, IV..."), ("Indent to Here", "Odsadit sem"), ("Ink Limit 300%", "Limit nánosu barev 300 %"), + ("All Inks", "Všechny barvy"), ("Internet Standard RGB (sRGB)", "Standardní internetové RGB (sRGB)"), ("Left:", "Vlevo:"), ("Legacy Macintosh RGB", "Starší Macintosh RGB"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index efc128858..2211acba2 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -1724,6 +1724,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("I, II, III, IV...", "I, II, III, IV..."), ("Indent to Here", "Einzug bis hierhin"), ("Ink Limit 300%", "Farbauftrag max. 300 %"), + ("All Inks", "Alle Druckfarben"), ("Internet Standard RGB (sRGB)", "Internet-Standard-RGB (sRGB)"), ("Left:", "Links:"), ("Legacy Macintosh RGB", "Älteres Macintosh-RGB"), diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 78c5d5948..ebb490e57 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -81,6 +81,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Indent", "Retrait"), ("Indent to Here", "Retrait jusqu'à ce point"), ("Ink Limit 300%", "Limite d'encre 300 %"), + ("All Inks", "Toutes les encres"), ("Insert Anchored Object", "Insérer un objet ancré"), ("Insert Cross-Reference", "Insérer une référence croisée"), ("Insert Math Expression", "Insérer une expression mathématique"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 5647ca940..317c67718 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -1922,6 +1922,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("I, II, III, IV...", "I, II, III, IV..."), ("Indent to Here", "ここまでインデント"), ("Ink Limit 300%", "インキ量の上限 300%"), + ("All Inks", "すべてのインキ"), ("Internet Standard RGB (sRGB)", "インターネット標準 RGB (sRGB)"), ("Left:", "左:"), ("Legacy Macintosh RGB", "旧 Macintosh RGB"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index d938c2dc7..3976c220e 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -1735,6 +1735,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("I, II, III, IV...", "I, II, III, IV..."), ("Indent to Here", "აქამდე შეწევა"), ("Ink Limit 300%", "საღებავის ზღვარი 300%"), + ("All Inks", "ყველა საღებავი"), ("Internet Standard RGB (sRGB)", "ინტერნეტის სტანდარტული RGB (sRGB)"), ("Left:", "მარცხნივ:"), ("Legacy Macintosh RGB", "მოძველებული Macintosh RGB"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 68d685d99..4b8736115 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -1660,6 +1660,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("I, II, III, IV...", "I, II, III, IV..."), ("Indent to Here", "Wcięcie do tego miejsca"), ("Ink Limit 300%", "Limit farby 300%"), + ("All Inks", "Wszystkie farby"), ("Internet Standard RGB (sRGB)", "Standardowe internetowe RGB (sRGB)"), ("Left:", "Lewy:"), ("Legacy Macintosh RGB", "Starsze RGB komputerów Macintosh"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 251555ca5..13f8f003c 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -1670,6 +1670,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("I, II, III, IV...", "I, II, III, IV..."), ("Indent to Here", "Отступ до этого места"), ("Ink Limit 300%", "Предел красок 300%"), + ("All Inks", "Все краски"), ("Internet Standard RGB (sRGB)", "Стандартный RGB для Интернета (sRGB)"), ("Left:", "Слева:"), ("Legacy Macintosh RGB", "Устаревший Macintosh RGB"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index cb98cac7f..0f285af3a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -27,6 +27,7 @@ mod prefs; pub mod render_worker; mod rtl; mod section_options; +pub mod separations; pub mod story_editor; pub mod taskbar; pub mod theme; @@ -235,11 +236,9 @@ pub struct UiState { pub flattener: String, /// File › Export PDF… options (persisted; see `PdfExportSettings`). pub pdf_export: PdfExportSettings, - /// View › Separations Preview: a process plate (0–3) and/or an ink limit (total, 0–4). + /// View › Separations Preview: the plate shown and the ink limit; `None` is off. #[serde(skip)] - pub separation: Option, - #[serde(skip)] - pub ink_limit: Option, + pub separations: Option, /// View › Display Performance. pub display_quality: designcraft_render::DisplayQuality, /// Preferences › Interface › UI scaling (1 = 100%). @@ -387,8 +386,7 @@ impl Default for UiState { language: String::new(), flattener: String::new(), pdf_export: PdfExportSettings::default(), - separation: None, - ink_limit: None, + separations: None, display_quality: designcraft_render::DisplayQuality::High, ui_scale: 1.0, rich_black: true, @@ -510,6 +508,7 @@ impl CanvasCache { display_key: None, patcher: designcraft_render::Renderer::new(), patches: 0, + separations: separations::Preview::default(), } } } @@ -541,6 +540,8 @@ pub struct CanvasCache { pub patcher: designcraft_render::Renderer, /// Partial repaints / full renders since start (perf readout). pub patches: u64, + /// View › Separations Preview: the visible spreads' plates. + pub separations: separations::Preview, } #[derive(Default, Clone, Copy, Debug, Serialize)] diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 79b8b2c4e..bfd33b7ce 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -218,7 +218,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ "{target?: workingCmyk|srgb|legacyMacRgb|monitorRgb|protanopia|deuteranopia|cmyk:, intent?, simulatePaper?: bool|\"toggle\"}", ), ("app.colorSettings", "Color Settings…", None, "{} — the working spaces, intent and black-point compensation"), - ("view.separations", "Separations Preview", None, "{plate?: cyan|magenta|yellow|black|null, inkLimit?: percent|null}"), + ( + "view.separations", + "Separations Preview", + None, + "{on?: bool, plate?: all|cyan|magenta|yellow|black|spot ink name|null (null: all inks, and off unless an ink limit stays), inkLimit?: percent|null} — the plates of the PDF Export PDF writes → {on, plate, inkLimit, spread, inks: [{name, spot}], dpi, warning?, error?}", + ), ("view.overprintPreview", "Overprint Preview", Some("Cmd+Alt+Shift+Y"), "{} — show how overprinting inks mix"), ("view.fastDisplay", "Fast Display", Some("Cmd+Alt+Shift+Z"), "{} — placed graphics as grey boxes, no effects"), ("view.typicalDisplay", "Typical Display", Some("Cmd+Alt+Z"), "{} — low-resolution image proxies"), @@ -767,6 +772,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "ui:view.proofColors", ">Separations Preview", "ui:view.separations|Off|{\"plate\": null, \"inkLimit\": null}", + "ui:view.separations|All Inks|{\"plate\": \"all\"}", "ui:view.separations|Cyan|{\"plate\": \"cyan\"}", "ui:view.separations|Magenta|{\"plate\": \"magenta\"}", "ui:view.separations|Yellow|{\"plate\": \"yellow\"}", @@ -872,6 +878,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ ">Output", "ui:window.panel|Attributes|{\"panel\": \"attributes\"}", "ui:window.panel|Preflight|{\"panel\": \"preflight\"}", + "ui:window.panel|Separations Preview|{\"panel\": \"separations\"}", "<", "ui:window.panel|Pages|{\"panel\": \"pages\"}", "ui:window.panel|Properties|{\"panel\": \"properties\"}", @@ -1598,16 +1605,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option { - if let Some(v) = p.get("plate") { - app.ui.separation = v.as_str().and_then(|s| ["cyan", "magenta", "yellow", "black"].iter().position(|x| *x == s)).map(|i| i as u8); - } - if let Some(v) = p.get("inkLimit") { - app.ui.ink_limit = v.as_f64().map(|l| (l / 100.0) as f32); - } - app.canvas.shown = None; - Ok(Value::Null) - } + "view.separations" => separations_view(app, p), "view.firstPage" | "view.previousPage" | "view.nextPage" | "view.lastPage" => { let n = app.session.active().map_or(0, |d| d.doc.page_count()); if n > 0 { @@ -2053,6 +2051,61 @@ fn export_bytes(app: &mut DesignApp, p: &Value, ext: &str, cmd: &str) -> Result< } } +/// View › Separations Preview (`view.separations`): updates the plate and the ink limit and +/// reports the plates of the spread nearest the view centre. +fn separations_view(app: &mut DesignApp, p: &Value) -> Result { + let mut sep = app.ui.separations.clone().unwrap_or_default(); + let mut on = app.ui.separations.is_some(); + match p.get("plate") { + Some(Value::String(s)) if s.eq_ignore_ascii_case("all") || s.is_empty() => { + sep.plate = None; + on = true; + } + Some(Value::String(s)) => { + let process = ["Cyan", "Magenta", "Yellow", "Black"].into_iter().find(|n| n.eq_ignore_ascii_case(s)); + sep.plate = Some(process.map_or_else(|| s.clone(), str::to_string)); + on = true; + } + Some(Value::Null) => { + sep.plate = None; + on = false; + } + Some(v) => return Err(format!("`plate`: an ink name, \"all\" or null, not {v}")), + None => {} + } + match p.get("inkLimit") { + Some(Value::Null) => sep.ink_limit = None, + Some(v) => { + let l = v.as_f64().filter(|l| l.is_finite() && *l >= 0.0).ok_or_else(|| format!("`inkLimit`: a percentage, not {v}"))?; + sep.ink_limit = Some(l as f32); + on = true; + } + None => {} + } + on |= sep.ink_limit.is_some(); + if let Some(v) = p.get("on").and_then(Value::as_bool) { + on = v; + } + app.ui.separations = on.then_some(sep.clone()); + let mut out = json!({"on": on, "plate": sep.plate.as_deref().unwrap_or("all"), "inkLimit": sep.ink_limit}); + if on && app.session.active().is_some() { + match crate::separations::current_sheet(app) { + Ok(sheet) => { + if let Some(name) = &sep.plate + && sheet.ink(name).is_none() + { + out["warning"] = json!(format!("this spread prints no `{name}` plate")); + } + out["spread"] = json!(sheet.spread); + out["dpi"] = json!(sheet.plates.dpi); + out["inks"] = sheet.inks().into_iter().map(|(name, spot)| json!({"name": name, "spot": spot})).collect(); + } + Err(e) => out["error"] = json!(e), + } + } + Ok(out) +} + /// File › Export PDF…: ask for a path, export through `file.exportPdf` and write the bytes with the /// platform writer (a download on the web). pub(crate) fn export_pdf(app: &mut DesignApp, p: &Value) -> Result { @@ -2364,6 +2417,16 @@ pub fn checked(app: &DesignApp, id: &str, params: &Value) -> Option { "view.rulers" => app.ui.rulers, "view.frameEdges" => app.ui.frame_edges, "view.overprintPreview" => app.ui.overprint_preview, + "view.separations" => { + let sep = app.ui.separations.as_ref(); + match (params.get("plate"), params.get("inkLimit")) { + (Some(Value::Null), _) => sep.is_none(), + (Some(Value::String(s)), _) if s == "all" => sep.is_some_and(|x| x.plate.is_none()), + (Some(Value::String(s)), _) => sep.and_then(|x| x.plate.as_deref()).is_some_and(|p| p.eq_ignore_ascii_case(s)), + (None, Some(v)) => sep.and_then(|x| x.ink_limit).is_some_and(|l| Some(f64::from(l)) == v.as_f64()), + _ => false, + } + } "view.fastDisplay" => app.ui.display_quality == designcraft_render::DisplayQuality::Fast, "view.typicalDisplay" => app.ui.display_quality == designcraft_render::DisplayQuality::Typical, "view.highQualityDisplay" => app.ui.display_quality == designcraft_render::DisplayQuality::High, diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 7c40f3b1b..163b4abe6 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -12,6 +12,7 @@ pub mod library; pub mod notes; pub mod pages; pub mod properties; +pub mod separations; pub mod styles; pub mod swatches; pub mod table; diff --git a/crates/ui-egui/src/panels/separations.rs b/crates/ui-egui/src/panels/separations.rs new file mode 100644 index 000000000..78b038288 --- /dev/null +++ b/crates/ui-egui/src/panels/separations.rs @@ -0,0 +1,116 @@ +//! Window › Output › Separations Preview: the plates the print PDF of the spread nearest the +//! view centre puts on film, each ink's percentage under the pointer, one plate or all inks, +//! and the ink limit. + +use craft_prepress::{Alternate, Ink, Process}; +use serde_json::json; + +use crate::DesignApp; +use crate::i18n::tr; +use crate::theme::Tokens; + +/// A swatch for an ink's row. +fn ink_color(ink: &Ink) -> egui::Color32 { + match ink { + Ink::Process(Process::Cyan) => egui::Color32::from_rgb(0, 174, 239), + Ink::Process(Process::Magenta) => egui::Color32::from_rgb(236, 0, 140), + Ink::Process(Process::Yellow) => egui::Color32::from_rgb(255, 242, 0), + Ink::Process(Process::Black) => egui::Color32::from_rgb(35, 31, 32), + Ink::Spot(s) => { + let rgb = match s.alternate { + Alternate::Lab([l, a, b]) => designcraft_color::cms::lab::lab_to_srgb(designcraft_color::cms::Lab { l, a, b }), + Alternate::Cmyk([c, m, y, k]) => designcraft_color::Color::Cmyk { c, m, y, k }.to_rgb(), + }; + let [r, g, b] = rgb.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8); + egui::Color32::from_rgb(r, g, b) + } + } +} + +pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let lang = app.ui.language.clone(); + let sep = app.ui.separations.clone(); + let mut run = None; + ui.horizontal(|ui| { + let limit = sep.as_ref().and_then(|s| s.ink_limit); + let modes = [ + ("Off", sep.is_none(), json!({"plate": null, "inkLimit": null})), + ("Separations", sep.is_some() && limit.is_none(), json!({"plate": "all", "inkLimit": null})), + ("Ink Limit", limit.is_some(), json!({"inkLimit": limit.unwrap_or(300.0)})), + ]; + for (label, on, params) in modes { + if ui.selectable_label(on, crate::rtl::widget(ui, tr(&lang, label))).clicked() && !on { + run = Some(params); + } + } + }); + if let Some(mut limit) = sep.as_ref().and_then(|s| s.ink_limit) { + ui.horizontal(|ui| { + ui.label(crate::rtl::widget(ui, tr(&lang, "Ink Limit"))); + if ui.add(egui::DragValue::new(&mut limit).range(0.0..=400.0).suffix("%")).changed() { + run = Some(json!({"inkLimit": limit})); + } + }); + } + if let Some(p) = run.take() { + if let Err(e) = app.run("view.separations", p) { + app.status_error("Separations Preview", e); + } + return; + } + let Some(sep) = sep else { + ui.label(egui::RichText::new(tr(&lang, "Shows the plates the exported PDF prints.")).size(11.0).color(t.text_dim)); + return; + }; + ui.separator(); + let Some(designcraft_doc::SpreadRef::Doc(si)) = crate::canvas::current_spread(app) else { return }; + let sheet = match app.canvas.separations.sheet(si) { + None => { + ui.label(egui::RichText::new(tr(&lang, "Separating…")).size(11.0).color(t.text_dim)); + return; + } + Some(Err(e)) => { + ui.label(egui::RichText::new(e.as_str()).size(11.0).color(t.text_dim)); + return; + } + Some(Ok(sheet)) => sheet.clone(), + }; + // Each ink's percentage under the pointer. + let k = f64::from(sheet.plates.dpi) / 72.0; + let coverage = app.canvas.separations.pointer.filter(|(psi, _)| *psi == si).and_then(|(_, p)| { + let (x, y) = ((p.x - sheet.area.x0) * k, (p.y - sheet.area.y0) * k); + (x >= 0.0 && y >= 0.0).then(|| sheet.plates.coverage_at(x as u32, y as u32, &[])).flatten() + }); + let pct = |v: Option| v.map_or_else(String::new, |v| format!("{v:.0}%")); + let row = |ui: &mut egui::Ui, selected: bool, color: Option, name: &str, value: String| { + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(egui::vec2(12.0, 12.0), egui::Sense::hover()); + if let Some(c) = color { + ui.painter().rect_filled(r, 1.0, c); + } + let clicked = ui.selectable_label(selected, name).clicked(); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| ui.label(egui::RichText::new(value).size(11.0))); + clicked + }) + .inner + }; + if row(ui, sep.plate.is_none(), None, tr(&lang, "All Inks"), pct(coverage.as_ref().map(|c| c.total))) { + run = Some(json!({"plate": "all"})); + } + for (id, ink) in sheet.plates.inks.iter() { + let selected = sep.plate.as_deref().and_then(|n| sheet.ink(n)) == Some(id); + let value = pct(coverage.as_ref().and_then(|c| c.inks.get(id.index()).copied())); + if row(ui, selected, Some(ink_color(ink)), ink.name(), value) { + run = Some(json!({"plate": if selected { "all" } else { ink.name() }})); + } + } + for name in &sheet.plates.unseparated { + ui.label(egui::RichText::new(format!("{name} ({})", tr(&lang, "not separated"))).size(11.0).color(t.text_dim)); + } + if let Some(p) = run + && let Err(e) = app.run("view.separations", p) + { + app.status_error("Separations Preview", e); + } +} diff --git a/crates/ui-egui/src/separations.rs b/crates/ui-egui/src/separations.rs new file mode 100644 index 000000000..3e635cf3d --- /dev/null +++ b/crates/ui-egui/src/separations.rs @@ -0,0 +1,377 @@ +//! View › Separations Preview on the canvas: each visible spread exported as Export PDF prints +//! it and separated into plates (`designcraft_engine::separations`), drawn over the spread. +//! +//! Sheets are kept per spread, keyed by document revision, resolution and print settings, so +//! panning and redrawing reuse them. Separating runs on a background thread (native); while it +//! runs the previous sheet of that spread stays on screen. On the web it runs on the UI thread +//! at a lower resolution. + +use std::sync::Arc; + +use designcraft_doc::SpreadRef; +use designcraft_engine::separations::{MAX_DPI, MIN_DPI, Print, Sheet}; +use designcraft_geom::Point; +use designcraft_tools::CanvasLayout; +use egui::{Color32, pos2}; + +use crate::DesignApp; +use crate::canvas::Xf; + +/// View › Separations Preview settings (`None` in `UiState::separations` is off). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Separations { + /// The ink shown alone as film; `None` shows every ink as printed. + pub plate: Option, + /// Ink Limit: areas whose total ink is over this percentage show in red over gray. + pub ink_limit: Option, +} + +/// Sheets kept at most (each holds one plane per ink). +const MAX_SHEETS: usize = 6; +/// The highest resolution the web build separates at (on the UI thread). +#[cfg(target_arch = "wasm32")] +const WEB_MAX_DPI: f32 = 72.0; + +/// What a sheet was separated from. +#[derive(Clone, Debug, PartialEq)] +pub struct Key { + pub uid: u64, + pub revision: u64, + pub spread: usize, + pub dpi: f32, + pub print: Print, +} + +struct Entry { + key: Key, + sheet: Result, String>, + /// The texture of the current view of `sheet`. + texture: Option<(Separations, egui::TextureHandle)>, + /// The frame it was last drawn in. + used: u64, +} + +struct Job { + key: Key, + doc: Arc, + cache: Arc, +} + +struct Done { + key: Key, + sheet: Result, + ms: f64, +} + +#[cfg(not(target_arch = "wasm32"))] +struct Worker { + tx: std::sync::mpsc::Sender, + rx: std::sync::mpsc::Receiver, +} + +#[cfg(not(target_arch = "wasm32"))] +impl Worker { + fn spawn(ctx: egui::Context) -> Option { + let (tx, jobs) = std::sync::mpsc::channel::(); + let (done_tx, rx) = std::sync::mpsc::channel::(); + std::thread::Builder::new() + .name("designcraft-separations".into()) + .spawn(move || { + while let Ok(first) = jobs.recv() { + // Of the jobs waiting, only the newest per spread matters. + let mut batch = vec![first]; + while let Ok(next) = jobs.try_recv() { + batch.retain(|j| j.key.spread != next.key.spread); + batch.push(next); + } + for job in batch { + let done = separate(job); + if done_tx.send(done).is_err() { + return; + } + ctx.request_repaint(); + } + } + }) + .ok()?; + Some(Worker { tx, rx }) + } +} + +fn separate(job: Job) -> Done { + let t0 = crate::now_ms(); + let sheet = + designcraft_engine::separations::separate(&job.doc, &job.cache, job.key.spread, job.key.dpi, &job.key.print).map_err(|e| e.to_string()); + Done { key: job.key, sheet, ms: crate::now_ms() - t0 } +} + +/// The canvas's separated sheets. +#[derive(Default)] +pub struct Preview { + entries: Vec, + pending: Vec, + #[cfg(not(target_arch = "wasm32"))] + worker: Option, + #[cfg(not(target_arch = "wasm32"))] + worker_started: bool, + frame: u64, + /// The spread and the point in it under the pointer, for the panel's readout. + pub pointer: Option<(usize, Point)>, + /// How long the last separation took, in milliseconds. + pub last_ms: f64, + /// The pixels per point the canvas last drew at (0 before it drew). + ppp: f64, +} + +impl Preview { + fn insert(&mut self, key: Key, sheet: Result, String>) { + self.pending.retain(|k| *k != key); + let frame = self.frame; + match self.entries.iter_mut().find(|e| e.key.spread == key.spread) { + Some(e) => *e = Entry { key, sheet, texture: None, used: frame }, + None => self.entries.push(Entry { key, sheet, texture: None, used: frame }), + } + if self.entries.len() > MAX_SHEETS { + self.entries.sort_by_key(|e| std::cmp::Reverse(e.used)); + self.entries.truncate(MAX_SHEETS); + } + } + + /// Collects finished separations. + fn poll(&mut self) { + #[cfg(not(target_arch = "wasm32"))] + { + let mut done = Vec::new(); + if let Some(w) = &self.worker { + while let Ok(d) = w.rx.try_recv() { + done.push(d); + } + } + for d in done { + self.last_ms = d.ms; + self.insert(d.key, d.sheet.map(Arc::new)); + } + } + } + + /// Asks for `key` to be separated, unless it is already or is being. + fn request(&mut self, ctx: &egui::Context, job: Job) { + if self.pending.contains(&job.key) || self.entries.iter().any(|e| e.key == job.key) { + return; + } + #[cfg(not(target_arch = "wasm32"))] + { + if !self.worker_started { + self.worker_started = true; + self.worker = Worker::spawn(ctx.clone()); + } + if let Some(w) = &self.worker { + self.pending.push(job.key.clone()); + if w.tx.send(job).is_ok() { + return; + } + self.pending.pop(); + self.worker = None; + return; + } + } + let _ = ctx; + let done = separate(job); + self.last_ms = done.ms; + self.insert(done.key, done.sheet.map(Arc::new)); + } + + /// The newest sheet of spread `si` (it may be of an earlier revision while the current one + /// is being separated). + pub fn sheet(&self, si: usize) -> Option<&Result, String>> { + self.entries.iter().find(|e| e.key.spread == si).map(|e| &e.sheet) + } +} + +/// The Export PDF settings the preview separates with. +pub fn print_settings(app: &DesignApp) -> Print { + let s = &app.ui.pdf_export; + let flatten = if s.flatten.is_empty() { app.ui.flattener.as_str() } else { s.flatten.as_str() }; + Print { + standard: designcraft_engine::separations::Standard::parse(&s.standard).unwrap_or_default(), + compress_images: s.compress_images, + flatten: match flatten { + "high" => Some(300.0), + "medium" => Some(150.0), + "low" => Some(72.0), + _ => None, + }, + } +} + +/// The resolution for a view at `zoom` with `ppp` pixels per point: device pixels per point, +/// rounded up to a power of two of 72 dpi so that zooming within a step keeps the plates. +pub fn dpi_for(zoom: f64, ppp: f64) -> f32 { + let want = (zoom * ppp).max(1e-3); + let step = want.log2().ceil().exp2() * 72.0; + let dpi = (step as f32).clamp(MIN_DPI, MAX_DPI); + #[cfg(target_arch = "wasm32")] + let dpi = dpi.min(WEB_MAX_DPI); + dpi +} + +fn key(app: &DesignApp, si: usize, dpi: f32) -> Option<(Key, Job)> { + let st = app.session.active()?; + let key = Key { uid: st.uid, revision: st.revision, spread: si, dpi, print: print_settings(app) }; + Some((key.clone(), Job { key, doc: st.doc.clone(), cache: app.session.cache.clone() })) +} + +/// The sheet of the spread nearest the view centre, separated now if the canvas hasn't yet +/// (for commands and automation, which want an answer). +pub fn current_sheet(app: &mut DesignApp) -> Result, String> { + let st = app.session.active().ok_or("no document")?; + let layout = CanvasLayout::new(&st.doc, st.editing_parents); + let slot = crate::canvas::current_slot(app, &layout).and_then(|i| layout.slots.get(i)).map(|s| s.spread); + let si = match slot { + Some(SpreadRef::Doc(si)) => si, + Some(_) => return Err("parent spreads aren't separated".into()), + None => st.doc.page_loc(0).map(|(si, _)| si).ok_or("no spread")?, + }; + let ppp = if app.canvas.separations.ppp > 0.0 { app.canvas.separations.ppp } else { 1.0 }; + let dpi = app.view().map_or(72.0, |v| dpi_for(v.zoom, ppp)); + let (key, job) = key(app, si, dpi).ok_or("no document")?; + let preview = &mut app.canvas.separations; + preview.poll(); + if let Some(e) = + preview.entries.iter().find(|e| e.key.uid == key.uid && e.key.revision == key.revision && e.key.spread == si && e.key.print == key.print) + { + return e.sheet.clone(); + } + let done = separate(job); + preview.last_ms = done.ms; + let sheet = done.sheet.map(Arc::new); + preview.insert(done.key, sheet.clone()); + sheet +} + +/// The preview's state for `ui.inspect`: the view, and each kept sheet's plates (`null` when the +/// preview is off). +pub fn inspect(app: &DesignApp) -> serde_json::Value { + let Some(view) = &app.ui.separations else { return serde_json::Value::Null }; + let revision = app.session.active().map(|st| (st.uid, st.revision)); + let preview = &app.canvas.separations; + let sheets: Vec = preview + .entries + .iter() + .map(|e| { + let mut v = serde_json::json!({"spread": e.key.spread, "dpi": e.key.dpi, "current": revision == Some((e.key.uid, e.key.revision))}); + match &e.sheet { + Ok(sheet) => v["inks"] = sheet.inks().into_iter().map(|(name, spot)| serde_json::json!({"name": name, "spot": spot})).collect(), + Err(err) => v["error"] = serde_json::json!(err), + } + v + }) + .collect(); + serde_json::json!({ + "plate": view.plate.as_deref().unwrap_or("all"), + "inkLimit": view.ink_limit, + "pending": preview.pending.len(), + "lastMs": preview.last_ms, + "sheets": sheets, + }) +} + +/// Draws the preview over each visible spread of the canvas. +pub fn paint(app: &mut DesignApp, ctx: &egui::Context, painter: &egui::Painter, xf: &Xf, layout: &CanvasLayout, screen: egui::Rect) { + let Some(view) = app.ui.separations.clone() else { + // Off: free the plates. + app.canvas.separations.entries.clear(); + return; + }; + let ppp = f64::from(ctx.pixels_per_point()); + let dpi = dpi_for(xf.zoom, ppp); + app.canvas.separations.ppp = ppp; + app.canvas.separations.poll(); + app.canvas.separations.frame += 1; + let frame = app.canvas.separations.frame; + let hover = ctx.pointer_hover_pos().filter(|p| screen.contains(*p)).map(|p| xf.to_canvas(p)); + app.canvas.separations.pointer = None; + let Some(st) = app.session.active() else { return }; + let doc = st.doc.clone(); + for slot in &layout.slots { + let SpreadRef::Doc(si) = slot.spread else { continue }; + let Some(sp) = doc.spreads.get(si) else { continue }; + if !xf.rect(slot.xf.transform_rect_bbox(sp.bounds())).intersects(screen) { + continue; + } + if let Some(c) = hover { + let p = slot.xf.inverse() * c; + if sp.bounds().contains(p) { + app.canvas.separations.pointer = Some((si, p)); + } + } + if let Some((_, job)) = key(app, si, dpi) { + app.canvas.separations.request(ctx, job); + } + let Some(entry) = app.canvas.separations.entries.iter_mut().find(|e| e.key.spread == si) else { continue }; + entry.used = frame; + let Ok(sheet) = &entry.sheet else { continue }; + let texture = match &entry.texture { + Some((v, tex)) if *v == view => tex.clone(), + _ => { + let plate = view.plate.as_deref().and_then(|n| sheet.ink(n)); + let rgba = sheet.image(plate, view.ink_limit); + let size = [sheet.plates.width as usize, sheet.plates.height as usize]; + if size[0] == 0 || size[1] == 0 || rgba.len() != size[0] * size[1] * 4 { + continue; + } + let ci = egui::ColorImage::from_rgba_unmultiplied(size, &rgba); + let ci = crate::widgets::fit_texture(ci, ctx.input(|i| i.max_texture_side)); + let tex = ctx.load_texture(format!("separations-{si}"), ci, egui::TextureOptions::LINEAR); + entry.texture = Some((view.clone(), tex.clone())); + tex + } + }; + let a = sheet.area; + let corners = [ + (Point::new(a.x0, a.y0), pos2(0.0, 0.0)), + (Point::new(a.x1, a.y0), pos2(1.0, 0.0)), + (Point::new(a.x1, a.y1), pos2(1.0, 1.0)), + (Point::new(a.x0, a.y1), pos2(0.0, 1.0)), + ]; + let mut mesh = egui::Mesh::with_texture(texture.id()); + for (c, uv) in corners { + mesh.vertices.push(egui::epaint::Vertex { pos: xf.to_screen(slot.xf * c), uv, color: Color32::WHITE }); + } + mesh.indices.extend([0, 1, 2, 0, 2, 3]); + painter.add(egui::Shape::mesh(mesh)); + } +} + +#[cfg(test)] +mod tests { + use serde_json::{Value, json}; + + use crate::menus::run_ui; + + #[test] + fn view_separations_lists_the_spot_plate_and_shows_it_alone() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({})).unwrap(); + app.session.execute("swatch.create", &json!({"name": "Gold", "spot": true, "color": {"c": 0, "m": 20, "y": 80, "k": 10}})).unwrap(); + let id = app.session.execute("frame.create", &json!({"rect": [72, 72, 172, 172]})).unwrap()["id"].clone(); + app.session.execute("object.fill", &json!({"swatch": "Gold", "ids": [id]})).unwrap(); + + let r = run_ui(&mut app, "view.separations", &json!({"plate": "Gold"})).unwrap().unwrap(); + assert_eq!((r["on"].clone(), r["plate"].clone()), (json!(true), json!("Gold")), "{r}"); + let inks: Vec<(String, bool)> = + r["inks"].as_array().unwrap().iter().map(|i| (i["name"].as_str().unwrap().to_string(), i["spot"] == true)).collect(); + assert_eq!(inks.last(), Some(&("Gold".to_string(), true)), "{r}"); + assert_eq!(inks.len(), 5, "the process inks and Gold: {r}"); + assert_eq!(app.ui.separations.as_ref().and_then(|s| s.plate.as_deref()), Some("Gold")); + + // The menu's params keep working. + run_ui(&mut app, "view.separations", &json!({"plate": "cyan"})).unwrap().unwrap(); + assert_eq!(app.ui.separations.as_ref().and_then(|s| s.plate.as_deref()), Some("Cyan")); + let r = run_ui(&mut app, "view.separations", &json!({"inkLimit": 300})).unwrap().unwrap(); + assert_eq!(r["inkLimit"], 300.0); + let r = run_ui(&mut app, "view.separations", &json!({"plate": null, "inkLimit": null})).unwrap().unwrap(); + assert_eq!(r["on"], false); + assert!(app.ui.separations.is_none() && r.get("inks").is_none_or(Value::is_null)); + } +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 1dc0eed49..c2103f795 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -35,7 +35,7 @@ so only enable it while you use it. Transport: `apps/designcraft/src/control_ser | `engine.execute` / `ui.menu.invoke` | `{command, params}` | Run any engine or UI command (see `engine.commands`). `ui.menu.invoke` without `params` acts like choosing the menu item: a command labelled "…" that takes parameters opens its dialog. A key the command's params description doesn't name is an error (`unknown parameter …`), not ignored | | `engine.commands` | — | Every command: id, label, menu path, shortcut, params doc, enabled | | `document.inspect` | — | Pages, spreads, items, stories (overset), styles, swatches, selection | -| `ui.inspect` | — | Tool, UI state, view (zoom/origin), canvas rect, perf | +| `ui.inspect` | — | Tool, UI state, view (zoom/origin), canvas rect, perf, Separations Preview (`separations`: plate, ink limit, each separated spread's plates) | | `ui.menu.list` / `ui.tool.list` | — | Menu tree / Tools panel groups | | `ui.tool.select` | `{tool}` | Select a tool (`selection`, `type`, `rectangleFrame`, …) | | `ui.pointer` | `{events:[{kind: down\|drag\|up\|move\|doubleclick, x, y, space?: "screen"\|"canvas", clicks?}], mods?}` | Drive the active tool through the same code path as the mouse; `clicks` on a `down` is the press count (3 = triple click); returns the selection, the tool and its cursor at the last event (e.g. `LoadedText`, `ThreadLink`, `Unthread`) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cdd6da689..93cab88fc 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -353,8 +353,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Content Collector and Placer tools (B; `conveyor.collect/place/list/clear`): copies of objects (with stories, styles, images) ride a conveyor between pages and documents; Alt-click keeps them on it. - Linked stories (Edit ▸ Place and Link, `story.placeAndLink/links/updateLink/unlink`): child stories copy a parent, show out of date in the Links panel and update. - Edit ▸ Menus (`window.hideMenuItem`): hide menu items; menus with hidden items end in Show All Menu Items. -- View ▸ Separations Preview (`view.separations`): one process plate as ink density, or areas over an ink limit in red (a GCR preview of the rendered view; spot plates and the flattener preview aren't there). -- Separations Preview plates now come from each object's own colour (CMYK as authored; RGB and images through the colour settings, images via a cached 17³ table), so pure black shows only on the black plate. +- View ▸ Separations Preview (`view.separations`, Window ▸ Output ▸ Separations Preview, `ink.separations`): the plates the print PDF puts on film, spot plates included. Each visible spread is exported as Export PDF writes it and separated with craft-prepress, so the Ink Manager's aliases and spots to process, overprint and the flattener show as printed. All inks as printed, one plate as film, or an ink limit in red over gray; the panel lists the plates with each ink's percentage under the pointer. - Language (Character panel ▸ Language, Properties ▸ Character, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, Japanese and Traditional Chinese 「…」, Simplified Chinese “…”), the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too, and so does the fallback for CJK characters the font lacks (with Draw Missing Glyphs from Fallback Fonts on): Japanese, Simplified Chinese, Traditional Chinese and Korean each try their own installed system fonts (Japanese starts with the craft-fonts Japanese faces, Mincho first, when built with them; Hangul, kana and Bopomofo pick their language's fonts whatever the language). Language names from IDML keep their spelling; locale codes as InDesign writes them for some languages (`de_DE_2006`, `nl_NL_2005`; any `ll`, `ll_CC`, `ll-CC`) are their language and region for all of the above, and Chinese is also recognised in its common spellings ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`). Spanish is hyphenated with Javier Bezos's Liang patterns (`hyph-es.tex` 5.0, MIT), Portuguese with hyph-utf8's `hyph-pt.tex` 1.4 (BSD-3-Clause) and Norwegian with hyph-utf8's `hyph-no.tex` (permissive), all through the `hypher` crate; hyphenation for other languages isn't bundled yet. - Books (`book.new/open/add/remove/list/styleSource/paginate/syncStyles/exportPdf`, Book panel): `.dcbook` files, continuous page numbering, chapter numbers resolved in book order (Automatic, Start Chapter Numbering at, Same as Previous Document; `layout.chapterNumbering`, with a style for Chapter Number variables and cross-references), style sync from a style source, one merged PDF. Section prefixes follow InDesign's rule (up to 8 characters, no + or comma). - Numbering & Section Options window as in InDesign (Start Section, automatic or explicit page numbering up to 99999, prefix, style, marker, Include Prefix; the document's chapter numbering and its book); OK applies both in one undo step (`layout.numberingOptions`). From 39f7ed4349f4c405f7645cefce3ff2647e74c0ba Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 16:44:57 +0000 Subject: [PATCH 3/5] Print color: Separations Preview separates again when the CMYK working space changes --- crates/ui-egui/src/separations.rs | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/separations.rs b/crates/ui-egui/src/separations.rs index 8d96e0248..9e1bac580 100644 --- a/crates/ui-egui/src/separations.rs +++ b/crates/ui-egui/src/separations.rs @@ -1,7 +1,8 @@ //! View › Separations Preview on the canvas: each visible spread exported as Export PDF prints //! it and separated into plates (`designcraft_engine::separations`), drawn over the spread. //! -//! Sheets are kept per spread, keyed by document revision, resolution and print settings, so +//! Sheets are kept per spread, keyed by document revision, resolution, print settings and color +//! settings (the CMYK working space is the printing condition plates are separated for), so //! panning and redrawing reuse them. Separating runs on a background thread (native); while it //! runs the previous sheet of that spread stays on screen. On the web it runs on the UI thread //! at a lower resolution. @@ -40,6 +41,8 @@ pub struct Key { pub spread: usize, pub dpi: f32, pub print: Print, + /// The color settings it was separated under ([`designcraft_color::cms::generation`]). + pub cms: u64, } struct Entry { @@ -217,7 +220,7 @@ pub fn dpi_for(zoom: f64, ppp: f64) -> f32 { fn key(app: &DesignApp, si: usize, dpi: f32) -> Option<(Key, Job)> { let st = app.session.active()?; - let key = Key { uid: st.uid, revision: st.revision, spread: si, dpi, print: print_settings(app) }; + let key = Key { uid: st.uid, revision: st.revision, spread: si, dpi, print: print_settings(app), cms: designcraft_color::cms::generation() }; Some((key.clone(), Job { key, doc: st.doc.clone(), cache: app.session.cache.clone() })) } @@ -237,8 +240,10 @@ pub fn current_sheet(app: &mut DesignApp) -> Result, String> { let (key, job) = key(app, si, dpi).ok_or("no document")?; let preview = &mut app.canvas.separations; preview.poll(); - if let Some(e) = - preview.entries.iter().find(|e| e.key.uid == key.uid && e.key.revision == key.revision && e.key.spread == si && e.key.print == key.print) + if let Some(e) = preview + .entries + .iter() + .find(|e| e.key.uid == key.uid && e.key.revision == key.revision && e.key.spread == si && e.key.print == key.print && e.key.cms == key.cms) { return e.sheet.clone(); } @@ -374,4 +379,19 @@ mod tests { assert_eq!(r["on"], false); assert!(app.ui.separations.is_none() && r.get("inks").is_none_or(Value::is_null)); } + + /// Plates separated under other color settings (another CMYK working space) aren't reused. + #[test] + fn plates_of_other_color_settings_are_separated_again() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({})).unwrap(); + let first = super::current_sheet(&mut app).unwrap(); + assert!(std::sync::Arc::ptr_eq(&first, &super::current_sheet(&mut app).unwrap()), "the same settings reuse the sheet"); + for e in &mut app.canvas.separations.entries { + e.key.cms = e.key.cms.wrapping_add(1); + } + let again = super::current_sheet(&mut app).unwrap(); + assert!(!std::sync::Arc::ptr_eq(&first, &again), "a sheet of other color settings is separated again"); + assert!(app.canvas.separations.entries.iter().all(|e| e.key.cms == designcraft_color::cms::generation())); + } } From 39746cbff9790a36e74c30850cf0c978bd66208b Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 01:15:10 +0000 Subject: [PATCH 4/5] Place the Separations panel in the default icon groups --- crates/ui-egui/src/panel_docking.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index 6c48614e2..c569e0420 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -123,7 +123,7 @@ const ICON_GROUPS: &[&[&str]] = &[ &["flexLayout", "liquid"], &["links", "library"], &["info"], - &["attributes", "preflight"], + &["attributes", "preflight", "separations"], &["notes", "trackChanges", "conditions"], &["hyperlinks", "bookmarks"], &["buttons", "states"], From a138e1f0109ca7527c715a5cfc07e200ebc59962 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 02:31:06 +0000 Subject: [PATCH 5/5] Separations Preview keeps every visible sheet; a superseded job is no longer pending --- ROADMAP.md | 1 - crates/ui-egui/src/separations.rs | 58 +++++++++++++++++++++++++------ 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 003f2b0ff..f9a944468 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -382,7 +382,6 @@ Newest first. - **2026-10-20** · PDF/X-1a:2003 export (craft-libs#194): `file.exportPdf {standard: "x1a"}`, the Export PDF dialog's PDF/X-1a:2003 preset and standard. The PDF/X-4 print file is flattened in its inks by craft-prepress (no transparency; spot, process, overprint and live text kept; complex regions as DeviceN images at the Transparency Flattener preset's resolution), then written as PDF 1.4 with the X-1a identification and checked; `validate-pdf --standard x1a`. Soft drop shadows reach the flattener in their swatch's inks; other soft effects are still RGB images. No readiness percentage change. - **2026-10-20** · Print color (craft-libs#190): CMYK print PDFs blend in DeviceCMYK through craft-pdf's print settings instead of byte rewrites, with the CMYK working space as the output intent in PDF/X-4 only; overprint (Overprint Fill, Stroke and Gap, overprinting text, 100% [Black]) is written as `/op` `/OP` `/OPM 1`; Knockout Group writes `/K true` instead of becoming an image; File › Print prints the PDF Export PDF writes. No readiness percentage change. -- **2026-10-20** · Spot gradients print on their plates. A gradient stop keeps the swatch it was given (Gradient panel drop, `swatch.create {gradient}`, `object.gradient`, IDML `StopColor`), following its renames and edits. PDF export shades one spot with its tints or [Paper] as a Separation, and a spot with process inks or a second spot as DeviceN; a process gradient to [Paper] prints in DeviceCMYK. Documents with plain-colour stops export as before. No readiness percentage change. - **2026-10-20** · Separations Preview shows the plates the exported PDF prints, spot plates included: each visible spread is exported in memory with the Export PDF settings and separated by craft-prepress off the UI thread, then shown as all inks, one plate or an ink limit. The Ink Manager builds craft-prepress's `InkSet` from the document for export and preview. On the sample magazine the first preview of a spread takes about 0.6–1.1 s, most of it the PDF export. No readiness percentage change. - **2026-10-20** · Spot gradients print on their plates. A gradient stop keeps the swatch it was given (Gradient panel drop, `swatch.create {gradient}`, `object.gradient`, IDML `StopColor`), following its renames and edits. PDF export shades one spot with its tints or [Paper] as a Separation, and a spot with process inks or a second spot as DeviceN; a process gradient to [Paper] prints in DeviceCMYK. Documents with plain-color stops export as before. No readiness percentage change. - **2026-10-24** · Panels dock through the suite's shared docking (craft-libs), and on desktop a floating panel opens in its own window that can leave the main window: keys pressed there run the main window's shortcuts, clipboard and tool keys, and the canvas paints no tool cursor while another of the app's windows has the pointer. The web build keeps floating panels in the page. No readiness percentage change. diff --git a/crates/ui-egui/src/separations.rs b/crates/ui-egui/src/separations.rs index 9e1bac580..1db68664b 100644 --- a/crates/ui-egui/src/separations.rs +++ b/crates/ui-egui/src/separations.rs @@ -27,8 +27,10 @@ pub struct Separations { pub ink_limit: Option, } -/// Sheets kept at most (each holds one plane per ink). +/// Sheets kept when few spreads are visible (each holds one plane per ink). const MAX_SHEETS: usize = 6; +/// Visible spreads separated at most: beyond this many the rest show without plates. +const MAX_VISIBLE: usize = 4 * MAX_SHEETS; /// The highest resolution the web build separates at (on the UI thread). #[cfg(target_arch = "wasm32")] const WEB_MAX_DPI: f32 = 72.0; @@ -113,6 +115,9 @@ fn separate(job: Job) -> Done { pub struct Preview { entries: Vec, pending: Vec, + /// The spreads the canvas last drew: every one keeps its sheet, so a visible sheet is never + /// evicted only to be separated again next frame. + visible: usize, #[cfg(not(target_arch = "wasm32"))] worker: Option, #[cfg(not(target_arch = "wasm32"))] @@ -134,12 +139,20 @@ impl Preview { Some(e) => *e = Entry { key, sheet, texture: None, used: frame }, None => self.entries.push(Entry { key, sheet, texture: None, used: frame }), } - if self.entries.len() > MAX_SHEETS { + let limit = self.visible.clamp(MAX_SHEETS, MAX_VISIBLE); + if self.entries.len() > limit { self.entries.sort_by_key(|e| std::cmp::Reverse(e.used)); - self.entries.truncate(MAX_SHEETS); + self.entries.truncate(limit); } } + /// Notes `key` as sent to the worker. The worker keeps only the newest waiting job of a + /// spread and drops the rest unanswered, so an older key of the spread is no longer pending. + fn queued(&mut self, key: Key) { + self.pending.retain(|k| k.spread != key.spread); + self.pending.push(key); + } + /// Collects finished separations. fn poll(&mut self) { #[cfg(not(target_arch = "wasm32"))] @@ -169,11 +182,11 @@ impl Preview { self.worker = Worker::spawn(ctx.clone()); } if let Some(w) = &self.worker { - self.pending.push(job.key.clone()); + let key = job.key.clone(); if w.tx.send(job).is_ok() { + self.queued(key); return; } - self.pending.pop(); self.worker = None; return; } @@ -298,12 +311,19 @@ pub fn paint(app: &mut DesignApp, ctx: &egui::Context, painter: &egui::Painter, app.canvas.separations.pointer = None; let Some(st) = app.session.active() else { return }; let doc = st.doc.clone(); - for slot in &layout.slots { - let SpreadRef::Doc(si) = slot.spread else { continue }; + let visible: Vec<(usize, &designcraft_tools::SpreadSlot)> = layout + .slots + .iter() + .filter_map(|slot| { + let SpreadRef::Doc(si) = slot.spread else { return None }; + let sp = doc.spreads.get(si)?; + xf.rect(slot.xf.transform_rect_bbox(sp.bounds())).intersects(screen).then_some((si, slot)) + }) + .take(MAX_VISIBLE) + .collect(); + app.canvas.separations.visible = visible.len(); + for (si, slot) in visible { let Some(sp) = doc.spreads.get(si) else { continue }; - if !xf.rect(slot.xf.transform_rect_bbox(sp.bounds())).intersects(screen) { - continue; - } if let Some(c) = hover { let p = slot.xf.inverse() * c; if sp.bounds().contains(p) { @@ -354,6 +374,24 @@ mod tests { use crate::menus::run_ui; + #[test] + fn every_visible_sheet_stays_and_a_superseded_job_is_not_pending() { + let key = |spread, dpi| super::Key { uid: 1, revision: 1, spread, dpi, print: Default::default(), cms: 0 }; + let mut p = super::Preview { visible: 10, ..Default::default() }; + for si in 0..10 { + p.insert(key(si, 72.0), Err("unseparated".into())); + } + assert_eq!(p.entries.len(), 10, "ten visible spreads keep ten sheets"); + p.visible = 1; + p.insert(key(10, 72.0), Err("unseparated".into())); + assert_eq!(p.entries.len(), super::MAX_SHEETS); + // The worker drops a spread's older waiting job, so only the newest stays pending. + p.queued(key(3, 72.0)); + p.queued(key(3, 288.0)); + p.queued(key(4, 72.0)); + assert_eq!(p.pending.iter().map(|k| (k.spread, k.dpi)).collect::>(), [(3, 288.0), (4, 72.0)]); + } + #[test] fn view_separations_lists_the_spot_plate_and_shows_it_alone() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());