C: separations from source colours

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 23:54:54 -04:00
parent 0d02222dab
commit 7b636fb9f5
5 changed files with 121 additions and 5 deletions

View File

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

View File

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

View File

@ -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<u8>,
/// 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<Vec<[f32; 4]>>);
static LUT: std::sync::Mutex<Option<Lut>> = 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<vello_cpu::Pixmap>, plate: u8) -> std::sync::Arc<vello_cpu::Pixmap> {
type Cache = std::collections::HashMap<(usize, u8), std::sync::Arc<vello_cpu::Pixmap>>;
static CACHE: std::sync::Mutex<Option<Cache>> = 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<vello_cpu::color::PremulRgba8> = 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<Vec<String>> = 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<bool> = const { std::cell::Cell::new(false) };
/// Separations Preview plate for the render on this thread.
static PLATE: std::cell::Cell<Option<u8>> = 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

View File

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

View File

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