diff --git a/Cargo.lock b/Cargo.lock index 4b8a2c62a..05b6d31d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1335,6 +1335,7 @@ dependencies = [ "caseless", "craft-atomic", "craft-docpath", + "craft-prepress", "craft-print", "designcraft-color", "designcraft-compose", @@ -1548,6 +1549,7 @@ name = "designcraft-ui-egui" version = "0.13.0" dependencies = [ "craft-keymap", + "craft-prepress", "craft-ui", "craft-update", "craft-window", diff --git a/Cargo.toml b/Cargo.toml index 07bcc85c7..5c9d882fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -133,6 +133,8 @@ debug = 0 [patch.crates-io] krilla = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", version = "=0.8.2" } winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" } +# 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/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", version = "=0.7.1" } hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", version = "=0.7.0" } hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", version = "=0.7.2" } diff --git a/ROADMAP.md b/ROADMAP.md index 91ec2875c..6d3f61555 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -126,8 +126,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. @@ -386,6 +385,7 @@ Newest first. - **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change. - **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** · 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. - **2026-10-24** · Story Options rejects malformed or nonexistent explicit story/frame IDs without changing the selected story, including an invalid frame alongside a valid story. Null targets remain absent; omitted targets still use the selection. No readiness percentage change. diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index e255d6d89..bf9998209 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -239,7 +239,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 { @@ -255,19 +256,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/export.rs b/crates/engine/src/cmd/export.rs index 3b7dd48d1..8de9ec1be 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -894,55 +894,83 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result { } } -/// The PDF that File › Export PDF writes for `p`, with its warnings: the flattener, placed PDFs -/// with hidden layers and soft effects as images, the PDF itself and its view options, timings -/// and transitions. File › Print prints it. -pub(crate) fn pdf_export(s: &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"))?, - ), - }; - // PDF/X-1a: craft-prepress flattens the PDF in its inks; `flatten` sets its resolution. + 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: the transparency flattener at `ppi` (if any), placed PDFs +/// with hidden layers and soft effects as images, the PDF boxes of placed graphics, the PDF +/// itself and its transparency blend space. Export PDF and File › Print add their view options +/// to it ([`pdf_export`]); Separations Preview separates it as it is. +pub(crate) fn print_pdf( + d: &designcraft_doc::Document, + cache: &designcraft_compose::Cache, + opts: &PdfOptions, + ppi: Option, +) -> Result { + // PDF/X-1a: craft-prepress flattens the PDF in its inks; `ppi` sets its resolution. let x1a = opts.standard == Standard::PdfX1a; - let mut opts = opts; + let mut opts = opts.clone(); if x1a { opts.flatten_dpi = ppi.unwrap_or(300.0) as f32; } - let flattened = if x1a { None } else { ppi.map(|ppi| flatten(&st.doc, &s.cache, ppi)) }; - let base: &designcraft_doc::Document = flattened.as_ref().map_or(&st.doc, |f| &f.0); + let flattened = if x1a { None } else { ppi.map(|ppi| flatten(d, cache, ppi)) }; + let base: &designcraft_doc::Document = flattened.as_ref().map_or(d, |f| &f.0); let (layered, layer_count) = if opts.standard == Standard::PdfA2b { rasterize_layered(base)? } else { (None, 0) }; let base2: &designcraft_doc::Document = layered.as_ref().unwrap_or(base); - let shadows = if x1a { shadow_masks(base2, &s.cache, f64::from(opts.flatten_dpi)) } else { Default::default() }; - let (fx_doc, fx_count) = rasterize_effects(base2, &s.cache, &shadows); + let shadows = if x1a { shadow_masks(base2, cache, f64::from(opts.flatten_dpi)) } else { Default::default() }; + let (fx_doc, fx_count) = rasterize_effects(base2, cache, &shadows); opts.shadows = std::sync::Arc::new(shadows); + let opts = &opts; 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()))?; // Flattening and effect rasterization replace placements, so inspect the source too. - if st.doc.has_graphic_layer_visibility_overrides() && !r.warnings.iter().any(|w| w == designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING) { + if d.has_graphic_layer_visibility_overrides() && !r.warnings.iter().any(|w| w == designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING) { r.warnings.push(designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING.into()); } // Transparency Blend Space: the page group of pages with transparency. CMYK print output // gets its CMYK page groups from the writer. - 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 !designcraft_pdf::print_color(doc, &opts) && blended.iter().any(|b| *b) { + if !designcraft_pdf::print_color(doc, opts) && blended.iter().any(|b| *b) { match designcraft_pdf::add_blend_space(&r.bytes, &blended, doc.settings.blend_space == designcraft_doc::BlendSpace::Cmyk) { Some(b) => r.bytes = b, 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) 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) +} + +/// The PDF that File › Export PDF writes for `p`, with its warnings: the print PDF +/// ([`print_pdf`]) with its view options, timings and transitions. File › Print prints it. +pub(crate) fn pdf_export(s: &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) { @@ -997,17 +1025,6 @@ pub(crate) fn pdf_export(s: &Session, p: &Value) -> Result 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) 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) } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 8213bc315..1a75d7ece 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; mod file_info; pub(crate) mod find; @@ -48,6 +49,7 @@ mod pdflayers; mod place_text; pub mod preflight; mod prefs; +mod prepress; mod print; mod qr; mod rulers; @@ -210,6 +212,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 238691dbf..90b958207 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -4235,27 +4235,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..4eb15a6eb --- /dev/null +++ b/crates/engine/src/cmd/prepress.rs @@ -0,0 +1,96 @@ +//! 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(), + // `Conversion` is non-exhaustive; a later craft-prepress conversion reports its debug form. + other => format!("{other:?}"), + }; + 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/cmd/style.rs b/crates/engine/src/cmd/style.rs index b0081afd1..724cc2069 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -506,13 +506,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})) @@ -558,6 +559,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)); } @@ -2852,9 +2860,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/engine/src/lib.rs b/crates/engine/src/lib.rs index 03e0e9946..2defc42d8 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/pdf/src/export.rs b/crates/pdf/src/export.rs index 67dde03da..672fdc67c 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -193,6 +193,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, @@ -401,6 +402,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, @@ -554,6 +556,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. + pub(crate) inks: craft_prepress::InkSet, pub cache: &'a Cache, pub opts: &'a PdfOptions, /// Current sheet's visible area (spread space) for culling. @@ -744,7 +748,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 }; @@ -2120,6 +2124,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/gradient_inks.rs b/crates/pdf/src/gradient_inks.rs index f1b4792cf..e889ee1d2 100644 --- a/crates/pdf/src/gradient_inks.rs +++ b/crates/pdf/src/gradient_inks.rs @@ -58,7 +58,7 @@ impl Exporter<'_> { n = base; } SwatchValue::Color { color_type: ColorType::Spot, .. } => { - 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 { return None }; 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 602d225ba..7d6682c6d 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -38,6 +38,7 @@ mod export; mod gradient_inks; +pub mod inks; mod links; mod marks; mod text; diff --git a/crates/pdf/tests/it/spot_gradients.rs b/crates/pdf/tests/it/spot_gradients.rs index bfd0e3ac2..eac962bc9 100644 --- a/crates/pdf/tests/it/spot_gradients.rs +++ b/crates/pdf/tests/it/spot_gradients.rs @@ -121,6 +121,19 @@ fn a_spot_to_paper_gradient_is_a_separation_shading() { assert!(close(&shadings[0].start, &[0.0, 0.3, 0.9, 0.1]), "{:?}", shadings[0].start); } +/// Stops follow the Ink Manager as fills do (`designcraft_pdf::inks`): an alias that would close +/// a loop is left out, so Gold prints on Silver's plate and Silver on its own. +#[test] +fn gradient_stops_follow_ink_aliases_as_fills_do() { + let mut d = document(&[(Ok("Gold"), 0.0), (Ok("[Paper]"), 1.0)], 0.5); + d.inks.aliases = vec![("Gold".into(), "Silver".into()), ("Silver".into(), "Gold".into())]; + let set = designcraft_pdf::inks::ink_set(&d); + assert_eq!(designcraft_pdf::inks::spot_output(&d, &set, "Gold"), ("Silver", false)); + let (shadings, _) = export(&d, Standard::PdfX4); + assert_eq!((shadings[0].family.as_str(), shadings[0].colorants.as_slice()), ("Separation", &["Silver".to_string()][..])); + assert!(close(&shadings[0].start, &[1.0]), "{:?}", shadings[0].start); +} + #[test] fn a_spot_to_cyan_gradient_is_a_device_n_shading() { let (shadings, warnings) = export(&document(&[(Ok("Gold"), 0.0), (Ok(CYAN), 1.0)], 0.5), Standard::PdfX4); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 5032e9b5c..baf73d432 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 gray, 4 cyan, + /// magenta and yellow coverage in red, green and blue, from each object's own color (CMYK as + /// authored, others through the color settings); 0–2 draw cyan, magenta or yellow as gray. pub plate: Option, /// View › Display Performance. pub quality: DisplayQuality, @@ -1031,7 +1031,7 @@ thread_local! { static HIDDEN_LAYERS: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; /// 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) }; } @@ -1513,9 +1513,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) { @@ -1560,31 +1557,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) } @@ -1917,20 +1889,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 4bbc7e15f..2ada1d0e9 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -21,6 +21,7 @@ designcraft-compose = { workspace = true } designcraft-render = { workspace = true } designcraft-tools = { workspace = true } designcraft-engine = { workspace = true } +craft-prepress = { workspace = true } # The UI draws monochrome icon glyphs from egui's bundled emoji faces (☰, ⚠, 📋); 0.37 moved # them behind this feature. They stay bundled copies of OSI/CC0 faces from `epaint_default_fonts`. egui = { workspace = true, features = ["monochrome_emoji_fonts"] } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 66d3ed373..64bed51d8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -629,6 +629,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); @@ -803,7 +804,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, @@ -836,7 +836,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, @@ -854,7 +853,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, @@ -1063,10 +1061,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 0a0114ae0..890eea3ce 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -93,6 +93,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 7e2eb3d6c..74dc37651 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -32,6 +32,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"), @@ -101,6 +102,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 f1dbe0acd..675d75f12 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -1923,6 +1923,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 2969d4cff..a0fc76a14 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -1737,6 +1737,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 aaf49bdb3..6728b1a80 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -1756,6 +1756,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 7a2571b5c..b7123a7ae 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -163,6 +163,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 Field", "Insérer un champ"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 01814f271..44fe11584 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -1954,6 +1954,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 e4136880b..104b464ab 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -1784,6 +1784,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 bd3d96b9a..dea477fb2 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -1709,6 +1709,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 5e8b58806..05821ca5f 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -1719,6 +1719,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 b3ae89d8e..9d35906e5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -29,6 +29,7 @@ mod prefs; pub mod render_worker; mod rtl; mod section_options; +pub mod separations; mod shortcut_import; pub mod story_editor; pub mod taskbar; @@ -309,11 +310,9 @@ pub struct UiState { pub pdf_export: PdfExportSettings, /// The Preview checkbox of Paragraph/Character Style Options, remembered between openings. pub style_preview: bool, - /// 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%). @@ -487,8 +486,7 @@ impl Default for UiState { flattener: String::new(), pdf_export: PdfExportSettings::default(), style_preview: false, - separation: None, - ink_limit: None, + separations: None, display_quality: designcraft_render::DisplayQuality::High, ui_scale: 1.0, rich_black: true, @@ -615,6 +613,7 @@ impl CanvasCache { display_key: None, patcher: designcraft_render::Renderer::new(), patches: 0, + separations: separations::Preview::default(), } } } @@ -646,6 +645,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 23b75b703..df2868cfe 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -249,7 +249,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"), @@ -843,6 +848,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\"}", @@ -950,6 +956,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\"}", @@ -1703,16 +1710,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 { @@ -2179,6 +2177,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 center. +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 { @@ -2469,6 +2522,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/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"], diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 8b12d9961..0e2ef2db0 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 story; pub mod styles; pub mod swatches; diff --git a/crates/ui-egui/src/panels/separations.rs b/crates/ui-egui/src/panels/separations.rs new file mode 100644 index 000000000..a685bee18 --- /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 center 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..1db68664b --- /dev/null +++ b/crates/ui-egui/src/separations.rs @@ -0,0 +1,435 @@ +//! 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, 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. + +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 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; + +/// 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, + /// The color settings it was separated under ([`designcraft_color::cms::generation`]). + pub cms: u64, +} + +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, + /// 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"))] + 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 }), + } + 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(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"))] + { + 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 { + let key = job.key.clone(); + if w.tx.send(job).is_ok() { + self.queued(key); + return; + } + 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), cms: designcraft_color::cms::generation() }; + Some((key.clone(), Job { key, doc: st.doc.clone(), cache: app.session.cache.clone() })) +} + +/// The sheet of the spread nearest the view center, 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 && e.key.cms == key.cms) + { + 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(); + 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 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 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()); + 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)); + } + + /// 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())); + } +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 2986adc1c..55b469d03 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, panel layout (`docking`), perf | +| `ui.inspect` | — | Tool, UI state, view (zoom/origin), canvas rect, panel layout (`docking`), perf, Separations Preview (`separations`: plate, ink limit, each separated spread's plates) | | `ui.docking` | — | The panel layout in use, the focused panel and the docking hit rects (see Shared panel docking) | | `ui.menu.list` / `ui.tool.list` | — | Menu tree / Tools panel groups | | `ui.tool.select` | `{tool}` | Select a tool (`selection`, `type`, `rectangleFrame`, …) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c9b0e0b27..54380dbaa 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -361,8 +361,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`).