From 7b636fb9f5f07706e363bf1e7f57555af5e06add Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 23:54:54 -0400 Subject: [PATCH] C: separations from source colours Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/object.rs | 21 ++++++++ crates/render/src/lib.rs | 91 +++++++++++++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 7 ++- docs/parity.md | 6 +-- 5 files changed, 121 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index bc5c930..37cb07c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -81,6 +81,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - 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. - 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. - Track Changes: per-change Accept / Reject (`changes.accept`, `changes.reject`) and a Track Changes panel listing every change. diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 4e0ea36..3f07a89 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -2183,6 +2183,27 @@ 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/render/src/lib.rs b/crates/render/src/lib.rs index 4cf5c53..fff1609 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -120,6 +120,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). + pub plate: Option, /// View › Display Performance. pub quality: DisplayQuality, /// Preferences › Appearance of Black: show 100% K as rich (pure) black instead of the @@ -159,6 +162,7 @@ impl Default for RenderOptions { note_indicators: false, change_markup: false, tag_markers: false, + plate: None, quality: DisplayQuality::High, rich_black: false, overprint_preview: false, @@ -272,6 +276,7 @@ impl Renderer { }; let mt = ctx.render_settings().num_threads > 0; RICH_BLACK.with(|r| r.set(opts.rich_black)); + PLATE.with(|p| p.set(opts.plate)); self.stats = FrameStats::default(); self.stories.clear(); self.glyphs.tick(); @@ -704,6 +709,10 @@ impl Renderer { let hidden = HIDDEN_LAYERS.with(|h| h.borrow().clone()); let data = if !hidden.is_empty() && is_pdf(&asset.data) { pdf_layers::layered(&asset.data, &hidden) } else { asset.data.clone() }; let Some(pm) = images::mip(&data, asset.page, g.size.0, on_screen) else { return }; + let pm = match PLATE.with(|p| p.get()) { + Some(plate) => plate_pixmap(&pm, plate), + None => pm, + }; let rect = Rect::new(0.0, 0.0, g.size.0, g.size.1); ctx.set_transform(f.view * xf * g.xf); let sx = g.size.0 / pm.width().max(1) as f64; @@ -718,6 +727,78 @@ impl Renderer { } } +/// sRGB → working CMYK through a 17³ table built once per colour settings (trilinear). +fn cmyk_lut(rgb: [f32; 3]) -> [f32; 4] { + const N: usize = 17; + type Lut = (designcraft_color::cms::ColorSettings, std::sync::Arc>); + static LUT: std::sync::Mutex> = std::sync::Mutex::new(None); + let settings = designcraft_color::cms::active_settings(); + let table = { + let mut g = LUT.lock().unwrap_or_else(|e| e.into_inner()); + match g.as_ref() { + Some((s, t)) if *s == settings => t.clone(), + _ => { + let cms = designcraft_color::cms::active(); + let step = |i: usize| i as f32 / (N - 1) as f32; + let t: Vec<[f32; 4]> = (0..N * N * N) + .map(|i| { + cms.srgb_to_cmyk([step(i / (N * N)), step(i / N % N), step(i % N)], designcraft_color::cms::Intent::RelativeColorimetric) + }) + .collect(); + let t = std::sync::Arc::new(t); + *g = Some((settings, t.clone())); + t + } + } + }; + let f = rgb.map(|v| v.clamp(0.0, 1.0) * (N - 1) as f32); + let i = f.map(|v| (v as usize).min(N - 2)); + let d = [f[0] - i[0] as f32, f[1] - i[1] as f32, f[2] - i[2] as f32]; + let mut out = [0.0f32; 4]; + for (corner, w) in (0..8).map(|c| { + let (a, b, cc) = (c >> 2 & 1, c >> 1 & 1, c & 1); + let w = (if a == 1 { d[0] } else { 1.0 - d[0] }) * (if b == 1 { d[1] } else { 1.0 - d[1] }) * (if cc == 1 { d[2] } else { 1.0 - d[2] }); + ((i[0] + a) * N * N + (i[1] + b) * N + i[2] + cc, w) + }) { + for k in 0..4 { + out[k] += table[corner][k] * w; + } + } + out +} + +/// An image's ink coverage on one process plate, as grey (cached per image and plate). +fn plate_pixmap(pm: &std::sync::Arc, plate: u8) -> std::sync::Arc { + type Cache = std::collections::HashMap<(usize, u8), std::sync::Arc>; + static CACHE: std::sync::Mutex> = std::sync::Mutex::new(None); + let key = (std::sync::Arc::as_ptr(pm) as usize, plate); + if let Some(v) = CACHE.lock().unwrap_or_else(|e| e.into_inner()).as_ref().and_then(|c| c.get(&key).cloned()) { + return v; + } + let data: Vec = pm + .data() + .iter() + .map(|p| { + if p.a == 0 { + return *p; + } + let k = 255.0 / p.a as f32; + let rgb = [p.r, p.g, p.b].map(|v| (v as f32 * k / 255.0).min(1.0)); + let cmyk = cmyk_lut(rgb); + let v = ((1.0 - cmyk[plate.min(3) as usize].clamp(0.0, 1.0)) * p.a as f32).round() as u8; + vello_cpu::color::PremulRgba8 { r: v, g: v, b: v, a: p.a } + }) + .collect(); + let out = std::sync::Arc::new(vello_cpu::Pixmap::from_parts(data, pm.width(), pm.height())); + let mut c = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + let map = c.get_or_insert_with(Default::default); + if map.len() > 32 { + map.clear(); + } + map.insert(key, out.clone()); + out +} + fn rect_overlaps(a: Rect, b: Rect) -> bool { a.x0 < b.x1 && a.x1 > b.x0 && a.y0 < b.y1 && a.y1 > b.y0 } @@ -727,9 +808,19 @@ 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. + static PLATE: std::cell::Cell> = const { std::cell::Cell::new(None) }; } pub fn color_of(c: &designcraft_color::Color, alpha: f32) -> peniko::Color { + if let Some(p) = PLATE.with(|p| p.get()) { + let cmyk = match *c { + designcraft_color::Color::Cmyk { c, m, y, k } => [c, m, y, k], + _ => c.to_cmyk_managed(designcraft_color::cms::Intent::RelativeColorimetric), + }; + let v = ((1.0 - cmyk[p.min(3) as usize].clamp(0.0, 1.0)) * 255.0).round() as u8; + return peniko::Color::from_rgba8(v, v, v, (alpha.clamp(0.0, 1.0) * 255.0).round() as u8); + } if RICH_BLACK.with(|r| r.get()) && let designcraft_color::Color::Cmyk { c: cc, m, y, k } = *c && cc <= 1e-3 diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f649647..9479b0a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -414,6 +414,7 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, 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, @@ -526,6 +527,7 @@ fn patch_texture(app: &mut DesignApp, layout: &CanvasLayout, ppp: f64, doc_key: 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, @@ -556,8 +558,9 @@ 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); } - if app.ui.separation.is_some() || app.ui.ink_limit.is_some() { - designcraft_render::separation_view(&mut img, app.ui.separation, app.ui.ink_limit); + // 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); match &mut app.canvas.texture { diff --git a/docs/parity.md b/docs/parity.md index 87dbf2b..9b239dd 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -11,7 +11,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia |---|---|---|---|---|---| | P0 | 102 | 100 | 2 | 0 | 99% | | P1 | 97 | 94 | 3 | 0 | 98% | -| P2 | 76 | 64 | 12 | 0 | 92% | +| P2 | 76 | 65 | 11 | 0 | 93% | | Area | Parity | |---|---| @@ -20,7 +20,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Layers | 100% | | Frames, shapes & paths | 100% | | Transform | 100% | -| Fill, stroke, colour | 99% | +| Fill, stroke, colour | 100% | | Effects & transparency | 92% | | Placing & links | 93% | | Type & text frames | 99% | @@ -155,7 +155,7 @@ Open P0 items: Vector/page formats (PDF/AI/SVG/EPS place), Export to PDF (print) | Fill, stroke, colour | Stroke styles | P1 | D | | Fill, stroke, colour | Color management | P1 | D | | Fill, stroke, colour | Overprint Preview | P1 | D | -| Fill, stroke, colour | Separations Preview | P2 | P | +| Fill, stroke, colour | Separations Preview | P2 | D | | Fill, stroke, colour | Eyedropper | P1 | D | | Fill, stroke, colour | Appearance of Black prefs | P1 | D | | Effects & transparency | Opacity & blend modes | P0 | D |