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