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:
parent
ae061eacfb
commit
b29e271b4a
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue