mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-02 22:20:50 -06:00
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:
committed by
bflatastic
co-authored by
Claude Opus 5.5
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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user