Part of #394: Appearance of Black › On Screen shows pure CMYK black as RGB black

Preferences › Appearance of Black › On Screen was stored and never read.
Display All Blacks Accurately (the default) shows pure CMYK black, black
ink alone at 100%, through the working CMYK profile, a dark grey, as the
canvas always did. Display All Blacks as Rich Black now shows it on the
canvas as RGB black: fills, strokes, type, gradient stops, effect colours
and pattern tiles, and in CMYK documents drawn by ink planes a pixel
whose inks come out as black ink alone. Rich blacks, black tints and
placed images show the same either way; exports and printing are
unchanged (Printing / Exporting is a separate option).

The renderer gets RenderOptions::rich_black, painted with a new
Ink::RichBlack (screen colours with pure black as RGB black) and applied
by ink::compose. The canvas sets it from blackOnScreen and keeps it in
its cache key, so changing the preference redraws.

A headless frame test reads the canvas raster: K100 shows its profile
grey by default, RGB black with rich, a rich black unchanged, and grey
again with accurate. A render test covers the opaque and ink-plane paths
and checks that rich blacks, tints and images don't change.

Docs: docs/mcp.md (blackOnScreen), docs/target-app-parity.md,
docs/gaps.md, docs/ui-parity.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Guo 2026-10-11 23:41:59 -05:00 committed by bflatastic
parent ae061eacfb
commit b29e271b4a
11 changed files with 122 additions and 21 deletions

View File

@ -684,8 +684,8 @@ impl Renderer {
let channels = match f.ink {
Ink::Cmy => effects::pixel::Channels::CmyPlane,
Ink::K => effects::pixel::Channels::KPlane,
Ink::Display if f.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk => effects::pixel::Channels::Cmyk,
Ink::Display => effects::pixel::Channels::Rgb,
Ink::Display | Ink::RichBlack if f.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk => effects::pixel::Channels::Cmyk,
Ink::Display | Ink::RichBlack => effects::pixel::Channels::Rgb,
};
// De-Interlace's field lines: the rows of the document's raster grid (1 to 2400 ppi).
let ppi = Some(f.doc.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0);

View File

@ -40,13 +40,16 @@ pub(crate) enum Ink {
Cmy,
/// The complemented K ink, as a grey.
K,
/// Screen colours, with pure CMYK black as RGB black ([`RenderOptions::rich_black`]).
RichBlack,
}
impl Ink {
/// The three values painted for colour `c`.
pub(crate) fn rgb(self, c: &Color) -> [f32; 3] {
match self {
Ink::Display => c.to_rgb(),
match (self, *c) {
(Ink::RichBlack, Color::Cmyk { c, m, y, k }) if is_pure_black([c, m, y, k]) => [0.0; 3],
(Ink::Display | Ink::RichBlack, _) => c.to_rgb(),
_ => self.plane(inks(c)),
}
}
@ -65,7 +68,7 @@ impl Ink {
fn fixed8(self, [r, g, b, a]: [u8; 4]) -> [u8; 4] {
match self {
Ink::Display => [r, g, b, a],
Ink::Display | Ink::RichBlack => [r, g, b, a],
_ => {
let lab = lab::srgb_to_lab([r, g, b].map(|v| v as f32 / 255.0));
let [r, g, b] = self.plane(inks(&Color::Lab { l: lab.l, a: lab.a, b: lab.b })).map(q);
@ -83,6 +86,11 @@ fn q(v: f32) -> u8 {
(v.clamp(0.0, 1.0) * 255.0).round() as u8
}
/// Black ink alone at 100%: what Appearance of Black calls pure CMYK black.
fn is_pure_black([c, m, y, k]: [f32; 4]) -> bool {
c <= 0.0 && m <= 0.0 && y <= 0.0 && k >= 1.0
}
/// Whether `doc` is drawn by ink planes: a CMYK document that shows transparency, unless drawn
/// in Outline view or Separations Preview (which paints plates).
pub(crate) fn blends_in_cmyk(doc: &Document, opts: &RenderOptions) -> bool {
@ -168,11 +176,12 @@ fn cached<T>(slot: &Cached<T>, build: impl FnOnce(&Cms) -> T) -> (Arc<T>, u64) {
}
/// The picture of a CMYK document from its ink planes (premultiplied RGBA8 drawn with
/// [`Ink::Cmy`] and [`Ink::K`]): each pixel's inks shown through the working CMYK profile. Where
/// only the frame's `background` shows it keeps its exact screen colour.
pub(crate) fn compose(cmy: &[u8], k: &[u8], background: Option<[u8; 4]>) -> Vec<u8> {
/// [`Ink::Cmy`] and [`Ink::K`]): each pixel's inks shown through the working CMYK profile (pure
/// black as RGB black with [`RenderOptions::rich_black`]). Where only the `background` of `opts`
/// shows it keeps its exact screen colour.
pub(crate) fn compose(cmy: &[u8], k: &[u8], opts: &RenderOptions) -> Vec<u8> {
let (lut, _) = cached(&DISPLAY, InkLut::build);
let bg = background.map(|b| (Ink::Cmy.fixed8(b), Ink::K.fixed8(b), b));
let bg = opts.background.map(|b| (Ink::Cmy.fixed8(b), Ink::K.fixed8(b), b));
let mut out = Vec::with_capacity(cmy.len());
// Runs of one colour convert once.
let mut last: Option<([u8; 8], [u8; 4])> = None;
@ -185,7 +194,8 @@ pub(crate) fn compose(cmy: &[u8], k: &[u8], background: Option<[u8; 4]>) -> Vec<
_ => {
let un = |v: u8, a: u8| if a == 0 { 1.0 } else { (v as f32 / a as f32).min(1.0) };
let a = p[3];
let rgb = lut.apply(planes_cmyk([un(p[0], a), un(p[1], a), un(p[2], a)], un(q[0], q[3])));
let inks = planes_cmyk([un(p[0], a), un(p[1], a), un(p[2], a)], un(q[0], q[3]));
let rgb = if opts.rich_black && is_pure_black(inks) { [0.0; 3] } else { lut.apply(inks) };
let pm = |v: f32| (v.clamp(0.0, 1.0) * a as f32).round() as u8;
let v = [pm(rgb[0]), pm(rgb[1]), pm(rgb[2]), a];
last = Some((key, v));
@ -245,7 +255,7 @@ pub(crate) fn cmyk_pixmap(blob: &ImageBlob, lut: &InkLut) -> Option<Pixmap> {
/// Image pixels `pm` (cached as `key`, from `blob` when it is a document's image as it is) as
/// painted with `ink`, cached with the images, and their key.
pub(crate) fn ink_image(key: &ImageKey, pm: &Arc<Pixmap>, ink: Ink, blob: Option<&ImageBlob>, touch: bool) -> (ImageKey, Arc<Pixmap>) {
if ink == Ink::Display {
if matches!(ink, Ink::Display | Ink::RichBlack) {
return (key.clone(), pm.clone());
}
let (luts, generation) = cached(&SEPARATE, |cms| {

View File

@ -127,6 +127,9 @@ pub struct RenderOptions {
/// (box-filtered from the full image, kept with the decoded images), so the full image needn't
/// stay in memory or be sampled. Off, images always sample their full pixels (exports).
pub image_mips: bool,
/// Appearance of Black's Rich Black: pure CMYK black (black ink alone at 100%) shows as RGB
/// black. Off, it shows through the CMYK profile, a dark grey, as other colours do.
pub rich_black: bool,
}
/// The opacity of the art around an isolated group or layer.
@ -201,6 +204,7 @@ impl Default for RenderOptions {
precise: false,
supersample: false,
image_mips: false,
rich_black: false,
}
}
}
@ -549,7 +553,8 @@ impl Renderer {
let inv = view.inverse();
let visible = inv.transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64));
let px = 1.0 / view.determinant().abs().sqrt().max(1e-12);
let frame = Frame { mt: threads > 0, doc, view, visible, px, opts, ink: Ink::Display };
let screen = if opts.rich_black { Ink::RichBlack } else { Ink::Display };
let frame = Frame { mt: threads > 0, doc, view, visible, px, opts, ink: screen };
self.begin_frame();
// View Opacity Mask: the mask's coverage in greyscale instead of the artwork.
if let Some(m) = opts.mask_view.and_then(|id| doc.node(id)).and_then(|n| n.extras().mask.as_deref()) {
@ -564,7 +569,7 @@ impl Renderer {
let stats = self.stats;
let k = self.draw_frame(&Frame { ink: Ink::K, ..frame }, w, h);
self.stats = stats;
if inks { ink::amounts(&cmy, &k) } else { ink::compose(&cmy, &k, opts.background) }
if inks { ink::amounts(&cmy, &k) } else { ink::compose(&cmy, &k, opts) }
} else {
self.draw_frame(&frame, w, h)
};

View File

@ -38,7 +38,7 @@ struct TileEntry {
struct PatternCache {
/// Per ink ([`Ink`] as index), each within [`lru::PATTERN_BUDGET`], least recently used first out.
tiles: [FrameCache<String, TileEntry>; 3],
tiles: [FrameCache<String, TileEntry>; 4],
renderer: Option<Box<Renderer>>,
depth: u32,
}

View File

@ -131,3 +131,34 @@ fn placed_images_are_separated_not_blackened() {
let [r, g, b] = cms.cmyk_to_srgb(cms.srgb_to_cmyk([1.0, 0.0, 0.0], cms.settings().intent), false).map(|v| (v * 255.0).round() as u8);
assert!(near(got, [r, g, b, 255], 4), "{got:?} ≠ {:?}", [r, g, b]);
}
/// Appearance of Black's Rich Black (`rich_black`, #394): pure CMYK black (black ink alone at
/// 100%) shows as RGB black, in an opaque CMYK document (screen colours) and in one drawn by ink
/// planes (Multiply); a rich black, a black tint and placed images show as they do without it.
#[test]
fn rich_black_shows_black_ink_alone_as_rgb_black() {
let k = |k| Color::cmyk(0.0, 0.0, 0.0, k);
let centre_with = |d: &Document, rich_black| {
let opts = RenderOptions { background: Some([255; 4]), skip_templates: true, rich_black, ..Default::default() };
Renderer::new().render_region_with(d, d.artboards[0].rect, 1.0, &opts).pixel(10, 10)
};
for blend in [Blend::Normal, Blend::Multiply] {
let black = doc(ColorMode::Cmyk, None, k(1.0), blend, 1.0);
let accurate = centre_with(&black, false);
assert!(near(accurate, shown([0.0, 0.0, 0.0, 1.0]), 1) && accurate[0] > 10, "{blend:?}: accurate {accurate:?}");
assert_eq!(centre_with(&black, true), [0, 0, 0, 255], "{blend:?}: rich");
for c in [Color::cmyk(0.6, 0.4, 0.4, 1.0), k(0.5)] {
let d = doc(ColorMode::Cmyk, None, c, blend, 1.0);
assert_eq!(centre_with(&d, true), centre_with(&d, false), "{blend:?}: {c:?}");
}
}
let mut d = Document::new_with_mode(20.0, 20.0, ColorMode::Cmyk);
let img = image::RgbaImage::from_pixel(20, 20, image::Rgba([255, 0, 0, 255]));
let mut png = vec![];
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
d.images.insert("red".into(), ImageBlob::new("image/png", png));
let im = ImageObject { key: "red".into(), width: 20, height: 20, xf: Affine::IDENTITY, link: None, placement: Default::default() };
let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)));
add(&mut d, n);
assert_eq!(centre_with(&d, true), centre_with(&d, false), "an image shows as it is");
}

View File

@ -254,6 +254,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
pixel,
smooth_images,
isolated: st.isolation.map(|id| id.0),
// Appearance of Black › On Screen: Display All Blacks as Rich Black.
rich_black: app.session.prefs.black_on_screen == "rich",
};
// Placed documents' bitmaps are being made: draw again when they are ready.
if vectorcraft_render::placed_document::busy() {
@ -307,6 +309,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
isolated: key.isolated.map(vectorcraft_doc::NodeId),
// Images far smaller on screen than they are sample their mip levels (#1244).
image_mips: true,
rich_black: key.rich_black,
..opts
};
// Light documents render synchronously (no lag vs overlays); heavy ones go to the worker.
@ -2827,6 +2830,52 @@ mod tests {
assert!(smooth(&mut app), "Pixel Preview, on");
}
/// Appearance of Black › On Screen (#394): Display All Blacks Accurately (the default) shows
/// pure CMYK black through the CMYK profile, a dark grey; Display All Blacks as Rich Black
/// shows it as RGB black. A rich black shows the same either way.
#[test]
fn appearance_of_black_on_screen_shows_pure_black_as_rgb_black() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300, "colorMode": "cmyk"})).unwrap();
app.canvas.worker_started = true; // render on this thread
// Larger than the view: the middle of the art's raster is its fill.
let id = app.run("shape.rectangle", json!({"x": -5000, "y": -5000, "width": 10000, "height": 10000})).unwrap()["id"].clone();
let fill = |app: &mut VectorcraftApp, cmyk: [u8; 4]| {
let [c, m, y, k] = cmyk;
app.run("paint.setFill", json!({"ids": [id], "color": {"c": c, "m": m, "y": y, "k": k}})).unwrap();
};
let ctx = egui::Context::default();
// The middle pixel of the art's raster uploaded by one frame, None when it was not re-rendered.
let middle = |app: &mut VectorcraftApp| -> Option<[u8; 4]> {
let (_, mut delta) = frame_output(app, &ctx);
let art = app.canvas.texture.as_ref().unwrap().id();
let px = delta.set.iter().find(|e| e.0.eq(&art)).and_then(|e| e.1.last()).map(|d| {
let egui::ImageData::Color(img) = &d.image;
let [w, h] = img.size;
img.pixels[h / 2 * w + w / 2].to_array()
});
delta.clear();
px
};
let shown = |cmyk: [f32; 4]| vectorcraft_color::Color::cmyk(cmyk[0], cmyk[1], cmyk[2], cmyk[3]).to_rgba8(1.0);
let near = |a: [u8; 4], b: [u8; 4]| a.iter().zip(b).all(|(x, y)| x.abs_diff(y) <= 1);
fill(&mut app, [0, 0, 0, 100]);
let accurate = middle(&mut app).expect("the first frame renders the art");
assert!(near(accurate, shown([0.0, 0.0, 0.0, 1.0])), "accurate: through the CMYK profile, {accurate:?}");
assert!(accurate[..3].iter().all(|v| *v > 10), "accurate: a dark grey, {accurate:?}");
app.run("prefs.set", json!({"key": "blackOnScreen", "value": "rich"})).unwrap();
assert_eq!(middle(&mut app).expect("the preference change re-renders"), [0, 0, 0, 255], "rich: RGB black");
fill(&mut app, [60, 40, 40, 100]);
let rich = middle(&mut app).expect("re-rendered");
assert!(near(rich, shown([0.6, 0.4, 0.4, 1.0])), "a rich black is not pure black: {rich:?}");
fill(&mut app, [0, 0, 0, 100]);
app.run("prefs.set", json!({"key": "blackOnScreen", "value": "accurate"})).unwrap();
assert!(near(middle(&mut app).expect("re-rendered"), accurate), "accurate again");
}
/// Guides & Grid › Show Pixel Grid (Above 600% Zoom) (#394): in Pixel Preview at 600% zoom and
/// above, a line at every document pixel over the art; none below 600%, out of Pixel Preview,
/// or with the option off.

View File

@ -310,6 +310,9 @@ pub struct CacheKey {
pub smooth_images: bool,
/// Isolation mode's group or layer ([`vectorcraft_render::RenderOptions::isolated`]).
pub isolated: Option<u64>,
/// Appearance of Black › On Screen › Display All Blacks as Rich Black
/// ([`vectorcraft_render::RenderOptions::rich_black`]).
pub rich_black: bool,
}
#[derive(Clone, Copy, Debug, Default)]

View File

@ -125,8 +125,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
## G12 Preferences and panels that don't act yet
- **Missing:** ~17 Preferences options stored but not acted on (#394: Highlight Alternate Glyphs, Open
Documents As Tabs, Real-time Drawing, hyphenation language, Appearance of Black, devices…), Properties panel per
- **Missing:** ~16 Preferences options stored but not acted on (#394: Highlight Alternate Glyphs, Open
Documents As Tabs, Real-time Drawing, hyphenation language, Appearance of Black for output, devices…), Properties panel per
context, context menus beyond the canvas, Layers panel row menus and Collect for Export, SVG Interactivity.
- **Impact:** settings that silently do nothing erode trust.
- **Estimate:** 13–20 h.

View File

@ -1615,7 +1615,10 @@ control channel's `ui.drag` scrubs), `showHomeScreen` (on by default: the Home s
off, an empty window, and the Home button or `app.home` still shows the screen), `autoCollapseIconPanels` (off by
default: on, a click away from a panel popped out of the icon column puts it away) and `displayPrintSize` (off by
default: View › Actual Size / `view.actualSize` is one document point per screen point; on, one document inch fills
96 screen points, an inch at the system's reference density whatever the display scaling).
96 screen points, an inch at the system's reference density whatever the display scaling). `blackOnScreen`
(Appearance of Black › On Screen, `accurate` by default) sets how the canvas shows pure CMYK black, black ink alone
at 100%: `accurate` shows it through the CMYK profile, a dark grey, and `rich` shows it as RGB black. Rich blacks,
black tints and images show the same either way, and exports are unchanged.
`gridlineEvery` (72 pt by default) and `gridSubdivisions` (8 by default) set the document grid that View › Show Grid
draws, and View › Snap to Grid snaps to its subdivisions. Changing either sets the grid of every open document.

File diff suppressed because one or more lines are too long

View File

@ -167,7 +167,7 @@ Layers panel.
| SVG Interactivity, Retype, Comments, Version History panels | missing | the last two are cloud features (out of scope) |
| Workspaces (save, reset, switch) | done | |
| macOS save sheet: choose the format from a menu | done | #866; `apps/vectorcraft/src/mac_save_format.rs`; current suffix selects the initial format, changing it updates the filename; native SVG save and cancel after changing to PDF checked |
| Preferences: every option acts | partial | ~17 stored but unused (#394) |
| Preferences: every option acts | partial | ~16 stored but unused (#394) |
## Look and feel