Gradients: freeform points that share a color model mix in that model

The canvas, the gradient chips and the SVG, PDF, EPS and EMF writers mixed
freeform points in display RGB, while Object > Expand and Create Gradient
Mesh mixed them in the model the points share. Points that are all CMYK,
Gray, Lab or RGB now mix in that model in those places too. Points in
different models mix as before: in display RGB, and on ink planes as the
inks of each point.

This changes how existing art looks:

- CMYK and Lab points. With the default Generic CMYK working space and no
  ink planes, halfway between C100 and K100 the canvas drew (17, 106, 139)
  and now draws (54, 118, 143).
- RGB points in a working RGB space other than sRGB mix in that space.
- On ink planes (a CMYK document that shows transparency), RGB and Lab
  points mix first and then separate.

Gray mixes keep their values, and CMYK and Gray points on ink planes draw
as before. RGB points in an sRGB working space draw as before except on
ink planes.

- PDF and EPS write a gradient whose points are all CMYK, or all Gray in a
  CMYK file, as a CMYK image. PDF's Output settings convert it as they
  convert any CMYK image.
- Darkening applies to the points before they mix.
- With Appearance of Black set to Rich, the canvas mixes a gradient with a
  pure CMYK black point in display RGB, and the point fades from RGB black.
This commit is contained in:
dotCooCoo
2026-11-08 17:18:43 -08:00
parent 205f54a7cb
commit ac0bb3c4ce
19 changed files with 602 additions and 82 deletions
+47 -5
View File
@@ -395,6 +395,15 @@ impl Source {
}
}
/// The centers of a `cols` × `rows` grid of cells over `b`, row by row.
fn cell_centers(b: Rect, cols: u16, rows: u16) -> impl Iterator<Item = Point> {
let (cw, ch) = (b.width() / cols as f64, b.height() / rows as f64);
(0..rows as usize * cols as usize).map(move |k| {
let (i, j) = (k % cols as usize, k / cols as usize);
Point::new(b.x0 + (i as f64 + 0.5) * cw, b.y0 + (j as f64 + 0.5) * ch)
})
}
/// A freeform gradient ready to sample ([`Freeform::field`]).
#[derive(Clone, Debug)]
pub struct Field {
@@ -429,11 +438,7 @@ impl Field {
/// Samples at the centres of a `cols` × `rows` grid of cells over `b`, row by row: RGB and
/// opacity (as [`Self::sample`]).
pub fn grid(&self, b: Rect, cols: u16, rows: u16) -> impl Iterator<Item = ([f32; 3], f32)> + '_ {
let (cw, ch) = (b.width() / cols as f64, b.height() / rows as f64);
(0..rows as usize * cols as usize).map(move |k| {
let (i, j) = (k % cols as usize, k / cols as usize);
self.sample(Point::new(b.x0 + (i as f64 + 0.5) * cw, b.y0 + (j as f64 + 0.5) * ch))
})
cell_centers(b, cols, rows).map(move |p| self.sample(p))
}
/// Display RGB and opacity at `p`.
@@ -503,6 +508,27 @@ impl ModelField {
};
(c, a)
}
/// [`Self::sample`] at the centers of a `cols` × `rows` grid of cells over `b`, row by row.
pub fn grid(&self, b: Rect, cols: u16, rows: u16) -> impl Iterator<Item = (Color, f32)> + '_ {
cell_centers(b, cols, rows).map(move |p| self.sample(p))
}
/// Display RGB and opacity at `p`: the display color of [`Self::sample`], or the display RGB
/// the points mix in when their models differ.
pub fn display_sample(&self, p: Point) -> ([f32; 3], f32) {
if self.model.is_none() {
return self.first.sample(p);
}
let (c, a) = self.sample(p);
(c.to_rgb(), a)
}
/// [`Self::display_sample`] at the centers of a `cols` × `rows` grid of cells over `b`, row by
/// row.
pub fn display_grid(&self, b: Rect, cols: u16, rows: u16) -> impl Iterator<Item = ([f32; 3], f32)> + '_ {
cell_centers(b, cols, rows).map(move |p| self.display_sample(p))
}
}
#[cfg(test)]
@@ -530,6 +556,22 @@ mod tests {
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-4)
}
#[test]
fn the_model_field_grid_and_display_samples_match_its_sample() {
let mut f = two(0.0);
(f.points[0].color, f.points[1].color) = (Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 0.0, 0.0, 1.0));
let field = f.model_field(50.0);
let b = Rect::new(0.0, 0.0, 100.0, 20.0);
let (cells, shown): (Vec<_>, Vec<_>) = (field.grid(b, 5, 1).collect(), field.display_grid(b, 5, 1).collect());
let (mid, a) = field.sample(Point::new(50.0, 10.0));
assert_eq!(cells[2], (mid, a));
assert_eq!(shown[2], (mid.to_rgb(), a));
// Points in different models give their display mix.
(f.points[0].color, f.points[1].color) = (red(), Color::gray(0.5));
let (rgb, _) = f.model_field(50.0).display_sample(Point::new(50.0, 0.0));
assert!(close(rgb, [0.75, 0.25, 0.25]), "{rgb:?}");
}
#[test]
fn the_sample_at_a_point_is_its_colour() {
let f = two(0.0);
+41 -22
View File
@@ -905,19 +905,48 @@ fn expanded_colours_keep_the_stops_colour_model() {
assert!((c - 0.625).abs() < 1e-5 && (m - 0.375).abs() < 1e-5, "interpolated in CMYK: {c} {m}");
}
/// A new 200 × 200 CMYK document.
fn cmyk_session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200, "colorMode": "cmyk"})).unwrap();
s
}
/// The selection's freeform gradient expanded: the mesh's colors, after checking that it draws
/// within `most` (mean channel difference) of the gradient.
fn expanded(s: &mut Session, most: f64) -> Vec<Color> {
let before = render(s);
run(s, "object.expand", json!({}));
let diff = mean_diff(&before, &render(s));
assert!(diff < most, "looks the same: {diff}");
let NodeKind::Mesh(m) = selected(s).kind else { panic!("a mesh: {:?}", selected(s).kind) };
m.points.into_iter().map(|p| p.color).collect()
}
/// Cyan and black points mix as inks: cyan and black only, adding up to one. The gradient and the
/// mesh both mix inks, so the two draw alike.
fn cyan_and_black_points_expand_as_they_draw(mut s: Session) {
let (cyan, black) = (json!({"c": 1.0, "m": 0.0, "y": 0.0, "k": 0.0}), json!({"c": 0.0, "m": 0.0, "y": 0.0, "k": 1.0}));
freeform_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 180.0), [cyan.clone(), cyan, black.clone(), black]);
for c in expanded(&mut s, 2.5) {
let [c, m, y, k] = inks(c);
assert!(m.abs() < 1e-5 && y.abs() < 1e-5 && (c + k - 1.0).abs() < 1e-5, "C{c} M{m} Y{y} K{k}");
}
}
#[test]
fn cyan_and_black_freeform_points_expand_as_they_draw_in_an_rgb_document() {
cyan_and_black_points_expand_as_they_draw(session());
}
#[test]
fn cyan_and_black_freeform_points_expand_as_they_draw_in_a_cmyk_document() {
cyan_and_black_points_expand_as_they_draw(cmyk_session());
}
#[test]
fn a_freeform_gradient_expands_with_the_inks_of_its_points() {
let ink = |c: f32, m: f32, y: f32, k: f32| json!({"c": c, "m": m, "y": y, "k": k});
// The mesh's colors, after checking that it draws within `most` (mean channel difference) of
// the gradient.
let expanded = |s: &mut Session, most: f64| {
let before = render(s);
run(s, "object.expand", json!({}));
let diff = mean_diff(&before, &render(s));
assert!(diff < most, "looks the same: {diff}");
let NodeKind::Mesh(m) = selected(s).kind else { panic!("a mesh: {:?}", selected(s).kind) };
m.points.into_iter().map(|p| p.color).collect::<Vec<_>>()
};
// Points of one ink set give every mesh point that ink set.
let mut s = session();
let one = ink(0.5, 0.2, 0.0, 0.1);
@@ -925,19 +954,9 @@ fn a_freeform_gradient_expands_with_the_inks_of_its_points() {
for c in expanded(&mut s, 1.0) {
assert!(close_inks(inks(c), [0.5, 0.2, 0.0, 0.1]), "{c:?}");
}
// Cyan and black points mix as inks: cyan and black only, adding up to one. The gradient blends
// the points' display colors and the mesh blends their inks, so the two draw a little apart in between.
let mut s = session();
let (cyan, black) = (ink(1.0, 0.0, 0.0, 0.0), ink(0.0, 0.0, 0.0, 1.0));
freeform_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 180.0), [cyan.clone(), cyan.clone(), black.clone(), black]);
for c in expanded(&mut s, 7.5) {
let [c, m, y, k] = inks(c);
assert!(m.abs() < 1e-5 && y.abs() < 1e-5 && (c + k - 1.0).abs() < 1e-5, "C{c} M{m} Y{y} K{k}");
}
// Points in different models mix in display RGB, stored in a CMYK document as CMYK.
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200, "colorMode": "cmyk"})).unwrap();
let gray = json!({"gray": 0.5});
let (cyan, gray) = (ink(1.0, 0.0, 0.0, 0.0), json!({"gray": 0.5}));
let mut s = cmyk_session();
freeform_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 180.0), [cyan.clone(), cyan, gray.clone(), gray]);
for c in expanded(&mut s, 2.5) {
inks(c);
+36
View File
@@ -347,6 +347,42 @@ fn a_freeform_gradient_expands_with_its_rgb_values_in_the_working_rgb_space() {
}
}
/// With a working RGB space other than sRGB, RGB freeform points mix in that space on the canvas
/// and in a writer, as Object > Expand mixes them.
#[test]
fn freeform_rgb_points_mix_in_the_working_rgb_space() {
use vectorcraft_color::freeform::spread_scale;
use vectorcraft_color::{Color, Freeform, FreeformPoint, Gradient, GradientKind, GradientPaint, Paint};
use vectorcraft_doc::{Appearance, Document, Node};
use vectorcraft_geom::{Point, Rect, shapes};
let _g = settings();
let old = cms::active_settings();
cms::set_active(&cms::ColorSettings { rgb: cms::WIDE_GAMUT_RGB.into(), ..old.clone() }).unwrap();
let box_ = Rect::new(0.0, 0.0, 100.0, 100.0);
let (a, b) = (Color::rgb(0.9, 0.1, 0.1), Color::rgb(0.1, 0.2, 0.9));
let p = |x, c| FreeformPoint::new(Point::new(x, 50.5), c);
let f = Freeform { points: vec![p(20.5, a), p(80.5, b)], ..Default::default() };
let mut g = GradientPaint::new(Gradient { kind: GradientKind::Freeform, ..Default::default() });
g.freeform = Some(f.clone());
let mut d = Document::new(100.0, 100.0);
let id = d.alloc_id();
d.insert(Some(d.layers[0].id), 0, Node::path(id, shapes::rectangle(box_), Appearance::basic(Paint::Gradient(Box::new(g)), Paint::None, 0.0)))
.unwrap();
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let canvas = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts).pixel(50, 50);
let svg = vectorcraft_svg::export_full(&d, &vectorcraft_svg::ExportOptions::default(), None).svg;
let data = svg.split("data:image/png;base64,").nth(1).unwrap().split('"').next().unwrap();
let png = image::load_from_memory(&vectorcraft_testkit::format::base64_decode(data).unwrap()).unwrap().to_rgba8();
let written = png.get_pixel(png.width() / 2, png.height() / 2).0;
let want = Color::rgb(0.5, 0.15, 0.5).to_rgba8(1.0);
let display = f.field(spread_scale(box_)).sample(Point::new(50.5, 50.5)).0.map(|v| (v * 255.0).round() as u8);
cms::set_active(&old).unwrap();
let near = |got: [u8; 4]| got.iter().zip(want).take(3).all(|(g, w)| g.abs_diff(w) <= 3);
assert!(near(canvas), "canvas: {canvas:?} vs {want:?}");
assert!(near(written), "SVG: {written:?} vs {want:?}");
assert!(display.iter().zip(want).any(|(g, w)| g.abs_diff(w) > 8), "the display mix differs: {display:?} vs {want:?}");
}
/// Loading a new file of the working RGB profile (named after its description, as before) takes
/// effect at once: CMYK colors convert into the new file and the canvas redraws, without choosing
/// the working spaces again.
+57 -19
View File
@@ -1098,6 +1098,7 @@ impl<'a> Scene<'a> {
/// Paint all of `area` with an image of freeform gradient `g` placed on `bounds`, sampled at
/// the document's raster effects resolution (at most [`MAX_FIELD_PX`] a side).
fn field_image(&mut self, g: &GradientPaint, bounds: Rect, area: Rect) {
use vectorcraft_color::cms::Model;
use vectorcraft_color::freeform::{painted_box, spread_scale};
let Some(b) = painted_box(bounds) else { return };
let area = area.abs();
@@ -1108,11 +1109,24 @@ impl<'a> Scene<'a> {
let k = (self.doc.raster_effects_ppi / 72.0).min(MAX_FIELD_PX / long);
let along = |len: f64| (len * k).ceil().clamp(1.0, MAX_FIELD_PX) as u16;
let (cols, rows) = (along(area.width()), along(area.height()));
let field = g.freeform_on(b).field_with(spread_scale(b), &|c| c.to_rgb());
let field = g.freeform_on(b).model_field(spread_scale(b));
let q = |v: f32| (unit(v) * 255.0).round() as u8;
let rgba: Vec<u8> = field.grid(area, cols, rows).flat_map(|(c, a)| [q(c[0]), q(c[1]), q(c[2]), q(a)]).collect();
let (w, h) = (u32::from(cols), u32::from(rows));
let image = if field.model() == Some(Model::Cmyk) || (self.cmyk && field.model() == Some(Model::Gray)) {
let inks: Vec<u8> = field
.grid(area, cols, rows)
.flat_map(|(color, a)| {
let [c, m, y, k] = color.to_cmyk();
[q(c), q(m), q(y), q(k), q(a)]
})
.collect();
self.encode_inks(&inks, w, h)
} else {
let rgba: Vec<u8> = field.display_grid(area, cols, rows).flat_map(|(c, a)| [q(c[0]), q(c[1]), q(c[2]), q(a)]).collect();
self.encode_image(&rgba, w, h)
};
let place = Affine::translate(area.origin().to_vec2()) * Affine::scale_non_uniform(area.width(), area.height());
if let Some(image) = self.encode_image(&rgba, u32::from(cols), u32::from(rows)) {
if let Some(image) = image {
self.place_image(&image, place);
}
}
@@ -1313,41 +1327,65 @@ impl<'a> Scene<'a> {
/// Level 3 (a key colour no opaque pixel has), white at Level 2; partly transparent ones over
/// white. `None` for an empty image.
fn encode_image(&mut self, rgba: &[u8], w: u32, h: u32) -> Option<String> {
let space = if self.cmyk { Space::Cmyk } else { Space::Rgb };
let mut cache: HashMap<[u8; 3], [u8; 4]> = HashMap::new();
let cms = self.cms.clone();
let intent = cms.settings().intent;
self.encode_samples(rgba.as_chunks::<4>().0, w, h, space, |p: &[u8; 4], al| {
let rgb = [0, 1, 2].map(|i| ((u32::from(p[i]) * al + 255 * (255 - al) + 127) / 255) as u8);
match space {
Space::Cmyk => *cache
.entry(rgb)
.or_insert_with(|| cms.srgb_to_cmyk(rgb.map(|x| f32::from(x) / 255.0), intent).map(|x| (unit(x) * 255.0).round() as u8)),
_ => [rgb[0], rgb[1], rgb[2], 0],
}
})
}
/// Straight CMYK pixels with opacity (five bytes a pixel, `w` × `h`, rows from the top), drawn
/// as [`Self::encode_image`] draws its pixels and keeping their ink amounts: partly
/// transparent pixels lay down that share of each ink.
fn encode_inks(&mut self, cmyka: &[u8], w: u32, h: u32) -> Option<String> {
self.encode_samples(cmyka.as_chunks::<5>().0, w, h, Space::Cmyk, |p: &[u8; 5], al| {
[0, 1, 2, 3].map(|i| ((u32::from(p[i]) * al + 127) / 255) as u8)
})
}
/// Pixels (`w` × `h`, rows from the top, each ending in its opacity) as the operators and data
/// that draw them in `space` on the unit square. `sample` gives the samples of a pixel that
/// isn't clear, from the pixel and its opacity.
fn encode_samples<const N: usize>(
&mut self,
px: &[[u8; N]],
w: u32,
h: u32,
space: Space,
mut sample: impl FnMut(&[u8; N], u32) -> [u8; 4],
) -> Option<String> {
if w == 0 || h == 0 {
return None;
}
let px = rgba.as_chunks::<4>().0;
let clear = px.iter().any(|p| p[3] == 0);
if px.iter().any(|p| p[3] > 0 && p[3] < 255) {
let opacity = |p: &[u8; N]| p[N - 1];
let clear = px.iter().any(|p| opacity(p) == 0);
if px.iter().any(|p| opacity(p) > 0 && opacity(p) < 255) {
self.warn(IMAGE_PARTLY);
}
let keyed = clear && self.o.level == Level::Three;
if clear && !keyed {
self.warn(IMAGE_ALPHA_L2);
}
let space = if self.cmyk { Space::Cmyk } else { Space::Rgb };
let n = space.n();
// The key colour, which opaque pixels that happen to have it are nudged off.
let key: [u8; 4] = [255, 0, 255, 0];
// White: no ink, or full light.
let white: [u8; 4] = if space == Space::Cmyk { [0; 4] } else { [255; 4] };
let mut cache: HashMap<[u8; 3], [u8; 4]> = HashMap::new();
let cms = self.cms.clone();
let intent = cms.settings().intent;
let mut data = Vec::with_capacity(px.len() * n);
for p in px {
if p[3] == 0 {
if opacity(p) == 0 {
data.extend(if keyed { &key } else { &white }.iter().take(n));
continue;
}
let al = u32::from(p[3]);
let rgb = [0, 1, 2].map(|i| ((u32::from(p[i]) * al + 255 * (255 - al) + 127) / 255) as u8);
let mut v = match space {
Space::Cmyk => *cache
.entry(rgb)
.or_insert_with(|| cms.srgb_to_cmyk(rgb.map(|x| f32::from(x) / 255.0), intent).map(|x| (unit(x) * 255.0).round() as u8)),
_ => [rgb[0], rgb[1], rgb[2], 0],
};
let mut v = sample(p, u32::from(opacity(p)));
if keyed && v.iter().take(n).eq(key.iter().take(n)) {
v[1] = 1;
}
+55 -1
View File
@@ -3,7 +3,7 @@
use std::sync::Arc;
use vectorcraft_color::{Color, Gradient, GradientKind, GradientMethod, GradientPaint, GradientStop, Paint, Swatch};
use vectorcraft_color::{Color, Freeform, FreeformPoint, Gradient, GradientKind, GradientMethod, GradientPaint, GradientStop, Paint, Swatch};
use vectorcraft_doc::{Appearance, AppearanceItem, Dash, Document, ImageBlob, ImageObject, LineCap, LineJoin, Node, NodeKind};
use vectorcraft_geom::{Affine, PathData, Point, Rect, shapes};
@@ -574,6 +574,60 @@ fn image_data(page: &str) -> Vec<u8> {
ps::ascii85_decode(&page[at..end]).unwrap()
}
/// A square filled with a freeform gradient of `a` on the left and `b` on the right, with those
/// points at `opacity`, exported: (the page, the middle pixel's samples, the warnings).
fn freeform_mid(a: Color, b: Color, opacity: f32, n: usize) -> (String, Vec<u8>, Vec<String>) {
let p = |x, c| FreeformPoint { opacity, ..FreeformPoint::new(Point::new(x, 100.0), c) };
let mut g = GradientPaint::new(Gradient { kind: GradientKind::Freeform, ..Default::default() });
g.freeform = Some(Freeform { points: vec![p(40.0, a), p(160.0, b)], ..Default::default() });
let d = doc_with(|d| vec![path(d, shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), Paint::Gradient(Box::new(g)), Paint::None, 0.0)]);
let out = export(&d, &opts(&d), None, None).unwrap();
let page = text(&out);
let size = |key: &str| {
let at = page.find(key).unwrap() + key.len();
page[at..].split(' ').next().unwrap().parse::<usize>().unwrap()
};
let (w, h) = (size("/Width "), size("/Height "));
let pixels = ps::inflate(&image_data(&page), 1 << 28).unwrap();
let at = (h / 2 * w + w / 2) * n;
(page, pixels[at..at + n].to_vec(), out.warnings)
}
#[test]
fn freeform_gradients_keep_the_inks_of_cmyk_points_and_mix_in_the_points_model() {
let (cyan, black) = (Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 0.0, 0.0, 1.0));
let (page, mid, _) = freeform_mid(cyan, black, 1.0, 4);
assert!(page.contains("/DeviceCMYK setcolorspace"), "{page}");
assert!(mid.iter().zip([128, 0, 0, 128]).all(|(g, w): (&u8, i32)| (i32::from(*g) - w).abs() <= 6), "{mid:?}");
// A partly transparent point lays down that share of each ink.
let (_, mid, warnings) = freeform_mid(cyan, black, 0.5, 4);
assert!(mid.iter().zip([64, 0, 0, 64]).all(|(g, w): (&u8, i32)| (i32::from(*g) - w).abs() <= 6), "{mid:?}");
assert!(warnings.iter().any(|w| w.contains("partly transparent")), "{warnings:?}");
// Lab points go out as RGB, mixed first in Lab.
let (page, mid, _) = freeform_mid(Color::lab(90.0, -60.0, 40.0), Color::lab(30.0, 60.0, -40.0), 1.0, 3);
assert!(page.contains("/DeviceRGB setcolorspace"), "{page}");
let want = Color::lab(60.0, 0.0, 0.0).to_rgba8(1.0);
assert!(mid.iter().zip(want).all(|(g, w)| g.abs_diff(w) <= 6), "{mid:?} vs {want:?}");
}
#[test]
fn gray_freeform_points_are_written_as_black_ink_in_a_cmyk_document() {
let p = |x, c| FreeformPoint::new(Point::new(x, 100.0), c);
let mut g = GradientPaint::new(Gradient { kind: GradientKind::Freeform, ..Default::default() });
g.freeform = Some(Freeform { points: vec![p(40.0, Color::gray(0.2)), p(160.0, Color::gray(0.8))], ..Default::default() });
let mut d = doc_with(|d| vec![path(d, shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), Paint::Gradient(Box::new(g)), Paint::None, 0.0)]);
d.color_mode = vectorcraft_doc::ColorMode::Cmyk;
let page = text(&export(&d, &opts(&d), None, None).unwrap());
let size = |key: &str| {
let at = page.find(key).unwrap() + key.len();
page[at..].split(' ').next().unwrap().parse::<usize>().unwrap()
};
let (w, h) = (size("/Width "), size("/Height "));
let pixels = ps::inflate(&image_data(&page), 1 << 28).unwrap();
let at = (h / 2 * w + w / 2) * 4;
assert!(pixels[at..at + 3] == [0, 0, 0] && pixels[at + 3].abs_diff(128) <= 6, "{:?}", &pixels[at..at + 4]);
}
#[test]
fn images_are_flate_with_masked_pixels_at_level_3_and_run_length_at_level_2() {
let d = png_doc();
+2 -2
View File
@@ -408,8 +408,8 @@ impl<'a> Scene<'a> {
let (cols, rows) = (along(area.width()), along(area.height()));
let pixels: Vec<u8> = if g.gradient.kind == GradientKind::Freeform {
let Some(b) = painted_box(bounds) else { return };
let field = g.freeform_on(b).field_with(spread_scale(b), &|c| c.to_rgb());
field.grid(area, cols, rows).flat_map(|(c, a)| [q(c[0]), q(c[1]), q(c[2]), q(a)]).collect()
let field = g.freeform_on(b).model_field(spread_scale(b));
field.display_grid(area, cols, rows).flat_map(|(c, a)| [q(c[0]), q(c[1]), q(c[2]), q(a)]).collect()
} else {
let geom = g.resolve(bounds);
let sample = g.gradient.sampler();
+29
View File
@@ -85,6 +85,35 @@ fn wmf_output_round_trips() {
assert!(near(ellipse.geometric_bounds().unwrap(), Rect::new(100.0, 20.0, 180.0, 80.0), 0.1));
}
#[test]
fn freeform_points_that_share_a_model_are_written_as_their_mix() {
use vectorcraft_color::{Color, Freeform, FreeformPoint, GradientKind};
let mid = |a: Color, b: Color| {
let p = |x, c| FreeformPoint::new(Point::new(x, 50.0), c);
let mut g = GradientPaint::new(Gradient { kind: GradientKind::Freeform, ..Default::default() });
g.freeform = Some(Freeform { points: vec![p(20.0, a), p(80.0, b)], ..Default::default() });
let d =
doc_with(100.0, 100.0, vec![path(shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Paint::Gradient(Box::new(g)), Paint::None, 0.0)]);
let back = import(&emf(&d).bytes).unwrap();
let im = all(&back.document)
.iter()
.find_map(|n| match &n.kind {
NodeKind::Image(im) => Some(im.clone()),
_ => None,
})
.unwrap();
let px = image::load_from_memory(&back.document.images[&im.key].bytes).unwrap().to_rgba8();
px.get_pixel(px.width() / 2, px.height() / 2).0
};
let near = |got: [u8; 4], want: [u8; 4]| got.iter().zip(want).all(|(g, w)| g.abs_diff(w) <= 6);
let got = mid(Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 0.0, 0.0, 1.0));
let want = Color::cmyk(0.5, 0.0, 0.0, 0.5).to_rgba8(1.0);
assert!(near(got, want), "CMYK: {got:?} vs {want:?}");
let got = mid(Color::lab(90.0, -60.0, 40.0), Color::lab(30.0, 60.0, -40.0));
let want = Color::lab(60.0, 0.0, 0.0).to_rgba8(1.0);
assert!(near(got, want), "Lab: {got:?} vs {want:?}");
}
#[test]
fn dashes_gradients_and_images_round_trip_through_emf() {
let mut line = path(shapes::line((10.0, 90.0).into(), (190.0, 90.0).into()), Paint::None, red(), 2.0);
+26 -11
View File
@@ -450,6 +450,7 @@ impl Exporter<'_> {
/// [`MAX_FIELD_PX`] a side), or a linear or radial gradient (at most [`MAX_GRADIENT_PX`] a side),
/// with dither's noise when it paints a fill (`fill`).
pub(super) fn field_image(&mut self, s: &mut Surface, g: &vectorcraft_color::GradientPaint, bounds: Rect, area: Rect, fill: bool) {
use vectorcraft_color::cms::Model;
use vectorcraft_color::freeform::{painted_box, spread_scale};
let Some(b) = painted_box(bounds) else { return };
let area = area.abs();
@@ -466,18 +467,32 @@ impl Exporter<'_> {
let k = (self.doc.raster_effects_ppi / 72.0).min(max / long);
let along = |len: f64| (len * k).ceil().clamp(1.0, max) as u16;
let (cols, rows) = (along(w), along(h));
let rgba: Vec<u8> = if freeform {
let field = g.freeform_on(b).field_with(spread_scale(b), &|c| c.to_rgb());
field.grid(area, cols, rows).flat_map(|(c, a)| [q(c[0]), q(c[1]), q(c[2]), q(a)]).collect()
} else {
self.warn("linear and radial gradients drawn as pixels are exported as images");
g.pixels(b, area, cols, rows, fill, &|c| c.to_rgb()).flatten().collect()
let field = freeform.then(|| g.freeform_on(b).model_field(spread_scale(b)));
let cmyk_doc = self.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk;
let img = match &field {
Some(field) if field.model() == Some(Model::Cmyk) || (cmyk_doc && field.model() == Some(Model::Gray)) => {
let (mut inks, mut alpha) = (Vec::new(), Vec::new());
for (color, a) in field.grid(area, cols, rows) {
let [c, m, y, k] = color.to_cmyk();
inks.extend([q(c), q(m), q(y), q(k)]);
alpha.push(q(a));
}
let convert = if self.convert { self.out.cmyk_pixels() } else { None };
crate::images::from_inks(inks, alpha, cols.into(), rows.into(), self.interpolate, convert)
}
_ => {
let rgba: Vec<u8> = match &field {
Some(field) => field.display_grid(area, cols, rows).flat_map(|(c, a)| [q(c[0]), q(c[1]), q(c[2]), q(a)]).collect(),
None => {
self.warn("linear and radial gradients drawn as pixels are exported as images");
g.pixels(b, area, cols, rows, fill, &|c| c.to_rgb()).flatten().collect()
}
};
let pixels = if self.convert { self.out.pixels() } else { None };
crate::images::from_rgba_in(rgba, cols.into(), rows.into(), self.interpolate, pixels)
}
};
let pixels = if self.convert { self.out.pixels() } else { None };
let (Some(img), Some(size)) = (
crate::images::from_rgba_in(rgba, cols.into(), rows.into(), self.interpolate, pixels),
Size::from_wh(area.width() as f32, area.height() as f32),
) else {
let (Some(img), Some(size)) = (img, Size::from_wh(area.width() as f32, area.height() as f32)) else {
return;
};
s.push_transform(&xf(Affine::translate(area.origin().to_vec2())));
+16
View File
@@ -101,6 +101,22 @@ pub(crate) fn from_rgba_in(mut rgba: Vec<u8>, width: u32, height: u32, interpola
from_rgba(&rgba, width, height, interpolate, pixels)
}
/// A `width` × `height` image of CMYK ink amounts `inks` (four bytes a pixel), converted by
/// `convert` (when given), with straight `alpha` (one byte a pixel) carried only when some pixel
/// isn't opaque. `None` when the buffers don't hold that many pixels.
pub(crate) fn from_inks(inks: Vec<u8>, alpha: Vec<u8>, width: u32, height: u32, interpolate: bool, convert: Option<&CmykPixels>) -> Option<Image> {
let pixels = u64::from(width) * u64::from(height);
if pixels == 0 || inks.len() as u64 != pixels * 4 || alpha.len() as u64 != pixels {
return None;
}
let alpha = alpha.iter().any(|a| *a < 255).then_some(alpha);
let (samples, space) = match convert {
Some(p) => p.convert(&inks),
None => (inks, ImageColorspace::Cmyk),
};
lossless(samples, alpha, space, width, height, interpolate)
}
/// The kinds of images the Compression section has settings for.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Kind {
+72 -2
View File
@@ -8,9 +8,10 @@ use craft_pdf::syntax::Pdf;
use craft_pdf::syntax::object::{Array, Name, Object, Stream};
use serde_json::json;
use vectorcraft_color::cms::{self, DEVICE_CMYK, GENERIC_CMYK, SRGB};
use vectorcraft_color::{Color, Freeform, FreeformPoint, Gradient, GradientKind, GradientPaint, Paint};
use vectorcraft_doc::cmyk::Inks;
use vectorcraft_doc::{ColorMode, Document, ImageBlob, ImageObject, Node, NodeKind};
use vectorcraft_geom::Affine;
use vectorcraft_doc::{Appearance, ColorMode, Document, ImageBlob, ImageObject, Node, NodeKind};
use vectorcraft_geom::{Affine, Point, Rect, shapes};
use crate::*;
@@ -471,6 +472,75 @@ fn a_pixelated_cmyk_image_with_its_grid_mask_asks_viewers_not_to_smooth_it() {
}
}
/// A 100 × 100 pt document of `mode` whose square has a freeform fill of `a` on the left and `b`
/// on the right, with those points at `opacity`.
fn freeform_doc(mode: ColorMode, a: Color, b: Color, opacity: f32) -> Document {
let p = |x, c| FreeformPoint { opacity, ..FreeformPoint::new(Point::new(x, 50.0), c) };
let mut g = GradientPaint::new(Gradient { kind: GradientKind::Freeform, ..Default::default() });
g.freeform = Some(Freeform { points: vec![p(20.0, a), p(80.0, b)], ..Default::default() });
let mut d = Document::new_with_mode(100.0, 100.0, mode);
let square = Node::path(
d.alloc_id(),
shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)),
Appearance::basic(Paint::Gradient(Box::new(g)), Paint::None, 0.0),
);
let l = d.layers[0].id;
d.insert(Some(l), 0, square).unwrap();
d
}
#[test]
fn freeform_gradients_of_cmyk_points_are_written_as_cmyk_images() {
let (cyan, black) = (Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 0.0, 0.0, 1.0));
for mode in [ColorMode::Cmyk, ColorMode::Rgb] {
let d = freeform_doc(mode, cyan, black, 1.0);
let out = export_with_report(&d, &opts(json!({}))).unwrap();
let w = written(&out.bytes);
assert_eq!(w.len(), 1, "{mode:?}: opaque, no soft mask: {w:?}");
assert_eq!(w[0].space, "DeviceCMYK", "{mode:?}");
let back = import(&out.bytes).unwrap();
let inks = all_images(&back)[0].cmyk().unwrap();
let at = (inks.height as usize / 2 * inks.width as usize + inks.width as usize / 2) * 4;
assert!(close(&inks.data[at..at + 4], &[128, 0, 0, 128], 6), "{mode:?}: {:?}", &inks.data[at..at + 4]);
}
// Opacity goes in a soft mask, and the inks stay.
let w = written(&export_with_report(&freeform_doc(ColorMode::Cmyk, cyan, black, 0.5), &opts(json!({}))).unwrap().bytes);
assert_eq!(w.len(), 2, "the image and its soft mask: {w:?}");
assert!(w.iter().any(|i| i.space == "DeviceCMYK") && w.iter().any(|i| i.space == "DeviceGray"), "{w:?}");
// The Output settings convert the inks like any CMYK image's.
let d = freeform_doc(ColorMode::Cmyk, cyan, black, 1.0);
let w = written(&export_with_report(&d, &opts(json!({"output": {"conversion": "destination", "destination": SRGB}}))).unwrap().bytes);
assert_eq!(w.len(), 1, "{w:?}");
assert_ne!(w[0].space, "DeviceCMYK", "converted to RGB");
}
#[test]
fn freeform_gradients_of_lab_points_are_written_as_their_lab_mix() {
let d = freeform_doc(ColorMode::Rgb, Color::lab(90.0, -60.0, 40.0), Color::lab(30.0, 60.0, -40.0), 1.0);
let out = export_with_report(&d, &opts(json!({}))).unwrap();
assert_eq!(written(&out.bytes)[0].space, "DeviceRGB");
let back = import(&out.bytes).unwrap();
let px = image::load_from_memory(&all_images(&back)[0].bytes).unwrap().to_rgba8();
let got = px.get_pixel(px.width() / 2, px.height() / 2).0;
let want = Color::lab(60.0, 0.0, 0.0).to_rgba8(1.0);
assert!(close(&got, &want, 6), "{got:?} vs {want:?}");
}
#[test]
fn gray_freeform_points_are_written_as_black_ink_in_a_cmyk_document() {
let (a, b) = (Color::gray(0.2), Color::gray(0.8));
let out = export_with_report(&freeform_doc(ColorMode::Cmyk, a, b, 1.0), &opts(json!({}))).unwrap();
let w = written(&out.bytes);
assert_eq!(w[0].space, "DeviceCMYK", "{w:?}");
let back = import(&out.bytes).unwrap();
let inks = all_images(&back)[0].cmyk().unwrap();
let at = (inks.height as usize / 2 * inks.width as usize + inks.width as usize / 2) * 4;
assert!(close(&inks.data[at..at + 4], &[0, 0, 0, 128], 6), "{:?}", &inks.data[at..at + 4]);
// An RGB document writes them as an image in RGB.
let w = written(&export_with_report(&freeform_doc(ColorMode::Rgb, a, b, 1.0), &opts(json!({}))).unwrap().bytes);
assert_ne!(w[0].space, "DeviceCMYK", "{w:?}");
}
/// #2950: Press Quality converts to the document's profile with its numbers kept, so in a CMYK
/// document an RGB image comes out CMYK, as the press needs; with no conversion (the default
/// preset) it stays RGB, as before.
+31 -11
View File
@@ -10,6 +10,7 @@ use std::cell::RefCell;
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use vectorcraft_color::cms::Model;
use vectorcraft_color::freeform::{grid_size, painted_box, spread_scale};
use vectorcraft_color::{Freeform, GradientPaint};
use vectorcraft_geom::{Affine, Rect};
@@ -24,6 +25,10 @@ thread_local! {
static GRIDS: RefCell<FrameCache<u64, Arc<Pixmap>>> = RefCell::new(FrameCache::new(lru::FREEFORM_BUDGET, usize::MAX));
}
/// Most cells along a side of a grid of Lab points on an ink plane, where each cell separates its
/// color through the profile; the paint smooths the grid.
const MAX_LAB_PLANE_CELLS: u16 = 64;
/// Cache key of the grid of `f` on box `b` at `cols` × `rows` painted with `ink` (and the active
/// colour settings, which turn colours into display RGB or inks).
fn key(f: &Freeform, b: Rect, cols: u16, rows: u16, ink: Ink) -> u64 {
@@ -38,18 +43,33 @@ fn key(f: &Freeform, b: Rect, cols: u16, rows: u16, ink: Ink) -> u64 {
h.finish()
}
/// The grid size of `f` on box `b` drawn `device` pixels long with `ink`.
pub(crate) fn cells(f: &Freeform, b: Rect, device: f64, ink: Ink) -> (u16, u16) {
let (cols, rows) = grid_size(b, device);
if f.shared_model() == Some(Model::Lab) && matches!(ink.base(), Ink::Cmy | Ink::K) {
return (cols.min(MAX_LAB_PLANE_CELLS), rows.min(MAX_LAB_PLANE_CELLS));
}
(cols, rows)
}
/// Sample `f` at the centres of a `cols` × `rows` grid over `b` (premultiplied, colours as `ink`
/// paints them).
/// paints them). Points that share a color model mix in that model ([`Freeform::model_field`]);
/// points in different models, and points that `ink` paints as RGB black, mix as `ink` paints each
/// of them.
pub(crate) fn rasterize(f: &Freeform, b: Rect, cols: u16, rows: u16, ink: Ink) -> Pixmap {
let field = f.field_with(spread_scale(b), &|c| ink.rgb(c));
let data = field
.grid(b, cols, rows)
.map(|([r, g, bl], a)| {
let a = a.clamp(0.0, 1.0);
let q = |v: f32| (v.clamp(0.0, 1.0) * a * 255.0).round() as u8;
vello_cpu::color::PremulRgba8 { r: q(r), g: q(g), b: q(bl), a: (a * 255.0).round() as u8 }
})
.collect();
let adjusted = ink.freeform(f);
let (f, ink) = adjusted.as_ref().map_or((f, ink), |a| (a, ink.base()));
let cell = |[r, g, bl]: [f32; 3], a: f32| {
let a = a.clamp(0.0, 1.0);
let q = |v: f32| (v.clamp(0.0, 1.0) * a * 255.0).round() as u8;
vello_cpu::color::PremulRgba8 { r: q(r), g: q(g), b: q(bl), a: (a * 255.0).round() as u8 }
};
let scale = spread_scale(b);
let data = if f.shared_model().is_some() && !ink.blacks_a_point(f) {
f.model_field(scale).grid(b, cols, rows).map(|(c, a)| cell(ink.rgb_sampled(&c), a)).collect()
} else {
f.field_with(scale, &|c| ink.rgb(c)).grid(b, cols, rows).map(|(rgb, a)| cell(rgb, a)).collect()
};
Pixmap::from_parts(data, cols, rows)
}
@@ -58,8 +78,8 @@ pub(crate) fn rasterize(f: &Freeform, b: Rect, cols: u16, rows: u16, ink: Ink) -
pub(crate) fn set_freeform_paint(ctx: &mut RenderContext, g: &GradientPaint, bounds: Rect, ink: Ink) -> bool {
let Some(b) = painted_box(bounds) else { return false };
let device = b.width().max(b.height()) * ctx.transform().determinant().abs().sqrt();
let (cols, rows) = grid_size(b, device);
let f = g.freeform_on(b);
let (cols, rows) = cells(&f, b, device, ink);
let k = key(&f, b, cols, rows, ink);
let pm = GRIDS
.with(|c| {
+27 -1
View File
@@ -20,7 +20,7 @@ use std::sync::{Arc, Mutex};
use vectorcraft_color::blend::{cmyk_planes, planes_cmyk};
use vectorcraft_color::cms::{self, Cms, CmykLut, ProofLut, lab};
use vectorcraft_color::{Color, Gradient};
use vectorcraft_color::{Color, Freeform, Gradient};
use vectorcraft_doc::cmyk::Inks;
use vectorcraft_doc::{ColorMode, Document, ImageBlob};
use vello_cpu::Pixmap;
@@ -126,6 +126,23 @@ impl Ink {
Some(g)
}
/// Transform original freeform points before their colors mix, as [`Self::gradient`] does for
/// stops.
pub(crate) fn freeform(self, f: &Freeform) -> Option<Freeform> {
let amount = self.darkness()?;
let mut f = f.clone();
for point in &mut f.points {
point.color = Self::darkened_color(&point.color, amount, self.black_endpoint());
}
Some(f)
}
/// Whether this ink paints a point of `f` as RGB black: Appearance of Black's Rich setting and
/// a point of pure CMYK black.
pub(crate) fn blacks_a_point(self, f: &Freeform) -> bool {
self.base() == Ink::RichBlack && f.points.iter().any(|p| matches!(p.color, Color::Cmyk { c, m, y, k } if is_pure_black([c, m, y, k])))
}
/// The three values painted for colour `c`.
pub(crate) fn rgb(self, c: &Color) -> [f32; 3] {
if let Some(amount) = self.darkness() {
@@ -138,6 +155,15 @@ impl Ink {
}
}
/// [`Self::rgb`] for colors sampled densely (a freeform gradient's grid): on an ink plane, RGB
/// colors go through the separation tables placed images use.
pub(crate) fn rgb_sampled(self, c: &Color) -> [f32; 3] {
match (self, c) {
(Ink::Cmy | Ink::K, Color::Rgb { .. }) => separation().0[(self == Ink::K) as usize].apply(c.to_rgb()),
_ => self.rgb(c),
}
}
/// [`Self::rgb`] as a paint, at `alpha`.
pub(crate) fn color(self, c: &Color, alpha: f32) -> peniko::Color {
let [r, g, b] = self.rgb(c);
+108
View File
@@ -51,6 +51,114 @@ fn near(a: [u8; 4], b: [u8; 4], tol: i32) -> bool {
a.iter().zip(b).all(|(x, y)| (*x as i32 - y as i32).abs() <= tol)
}
/// `a` and `b` on either side of (50.5, 50.5): the point a pixel center and a grid cell share.
fn pair(a: Color, b: Color) -> Freeform {
let p = |x, c| FreeformPoint::new(Point::new(x, 50.5), c);
Freeform { points: vec![p(20.5, a), p(80.5, b)], ..Default::default() }
}
const CYAN: Color = Color::Cmyk { c: 1.0, m: 0.0, y: 0.0, k: 0.0 };
const BLACK: Color = Color::Cmyk { c: 0.0, m: 0.0, y: 0.0, k: 1.0 };
/// The middle pixel of a square painted with `pair(a, b)`.
fn middle(a: Color, b: Color) -> [u8; 4] {
render(&doc(shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), paint(Some(pair(a, b))), 1.0)).pixel(50, 50)
}
#[test]
fn points_that_share_a_model_mix_in_it() {
let want = Color::cmyk(0.5, 0.0, 0.0, 0.5).to_rgba8(1.0);
assert!(near(middle(CYAN, BLACK), want, 3), "CMYK: {:?} vs {want:?}", middle(CYAN, BLACK));
let (lab1, lab2) = (Color::lab(90.0, -60.0, 40.0), Color::lab(30.0, 60.0, -40.0));
let want = Color::lab(60.0, 0.0, 0.0).to_rgba8(1.0);
assert!(near(middle(lab1, lab2), want, 3), "Lab: {:?} vs {want:?}", middle(lab1, lab2));
let want = Color::gray(0.5).to_rgba8(1.0);
assert!(near(middle(Color::gray(0.2), Color::gray(0.8)), want, 3), "Gray");
}
#[test]
fn rgb_points_and_points_in_different_models_mix_in_display_rgb() {
let (red, blue) = (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0));
let want = Color::rgb(0.5, 0.0, 0.5).to_rgba8(1.0);
assert!(near(middle(red, blue), want, 3), "RGB: {:?} vs {want:?}", middle(red, blue));
let gray = Color::gray(0.5);
let [r, g, b] = [red.to_rgb(), gray.to_rgb()].into_iter().fold([0.0; 3], |m, c| [m[0] + c[0] / 2.0, m[1] + c[1] / 2.0, m[2] + c[2] / 2.0]);
let want = Color::rgb(r, g, b).to_rgba8(1.0);
assert!(near(middle(red, gray), want, 3), "mixed models: {:?} vs {want:?}", middle(red, gray));
}
/// The middle grid cell of `pair(a, b)` rasterized on a 101-point box with `ink`, as RGB.
fn middle_cell(a: Color, b: Color, ink: crate::ink::Ink) -> [u8; 3] {
let cell = crate::freeform::rasterize(&pair(a, b), Rect::new(0.0, 0.0, 101.0, 101.0), 17, 17, ink).sample(8, 8);
[cell.r, cell.g, cell.b]
}
#[test]
fn ink_planes_separate_the_mix_of_lab_points() {
use crate::ink::Ink;
let (lab1, lab2) = (Color::lab(90.0, -60.0, 40.0), Color::lab(30.0, 60.0, -40.0));
let mid = Color::lab(60.0, 0.0, 0.0);
// A color far out of gamut stays as exact as a solid fill of it.
let far = Color::lab(20.0, -125.0, 125.0);
for ink in [Ink::Cmy, Ink::K] {
for (a, b, c) in [(lab1, lab2, mid), (far, far, far)] {
let want = ink.color(&c, 1.0).to_rgba8();
let got = middle_cell(a, b, ink);
assert!(near([got[0], got[1], got[2], 255], [want.r, want.g, want.b, 255], 2), "{ink:?} {c:?}: {got:?} vs {want:?}");
}
}
}
#[test]
fn ink_planes_separate_the_mix_of_rgb_points() {
use crate::ink::Ink;
let (red, blue) = (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0));
let mid = Color::rgb(0.5, 0.0, 0.5);
for ink in [Ink::Cmy, Ink::K] {
let want = ink.color(&mid, 1.0).to_rgba8();
let got = middle_cell(red, blue, ink);
assert!(near([got[0], got[1], got[2], 255], [want.r, want.g, want.b, 255], 3), "{ink:?}: {got:?} vs {want:?}");
}
}
#[test]
fn lab_points_on_an_ink_plane_use_a_coarser_grid() {
use crate::ink::Ink;
let b = Rect::new(0.0, 0.0, 1000.0, 1000.0);
let lab = pair(Color::lab(90.0, -60.0, 40.0), Color::lab(30.0, 60.0, -40.0));
let (cols, rows) = crate::freeform::cells(&lab, b, 4000.0, Ink::Cmy);
assert_eq!((cols, rows), (64, 64));
assert_eq!(crate::freeform::cells(&lab, b, 4000.0, Ink::K), (64, 64));
let (wide, _) = crate::freeform::cells(&lab, b, 4000.0, Ink::Display);
assert!(wide > 64, "{wide}");
let (cmyk, _) = crate::freeform::cells(&pair(CYAN, BLACK), b, 4000.0, Ink::Cmy);
assert!(cmyk > 64, "{cmyk}");
}
#[test]
fn pure_black_points_fade_from_rgb_black_under_rich_black() {
use crate::ink::Ink;
let white = Color::cmyk(0.0, 0.0, 0.0, 0.0);
let [r, g, b] = white.to_rgb();
let want = [r, g, b].map(|v| (v * 0.5 * 255.0).round() as u8);
let got = middle_cell(BLACK, white, Ink::RichBlack);
assert!(near([got[0], got[1], got[2], 255], [want[0], want[1], want[2], 255], 2), "{got:?} vs {want:?}");
// Without a pure black point, the points mix in their model.
let (a, c) = (Color::cmyk(0.0, 0.0, 0.0, 0.9), white);
let want = Color::cmyk(0.0, 0.0, 0.0, 0.45).to_rgba8(1.0);
let got = middle_cell(a, c, Ink::RichBlack);
assert!(near([got[0], got[1], got[2], 255], want, 2), "{got:?} vs {want:?}");
}
#[test]
fn darkening_changes_the_points_before_they_mix() {
use crate::ink::Ink;
// Half black added to each point: K100 stays K100 and K0 becomes K50, so they meet at K75.
let got = middle_cell(BLACK, Color::cmyk(0.0, 0.0, 0.0, 0.0), Ink::Display.darkened(0.5));
let want = Color::cmyk(0.0, 0.0, 0.0, 0.75).to_rgba8(1.0);
assert!(near([got[0], got[1], got[2], 255], want, 2), "{got:?} vs {want:?}");
}
#[test]
fn the_rendered_grid_follows_the_colour_field() {
let f = freeform();
+2 -2
View File
@@ -440,9 +440,9 @@ impl Writer<'_> {
let freeform = g.gradient.kind == GradientKind::Freeform;
let (rgba, cols, rows): (Vec<u8>, u16, u16) = if freeform {
let (cols, rows) = grid_size(b, 2.0 * b.width().max(b.height()));
let field = g.freeform_on(b).field(spread_scale(b));
let field = g.freeform_on(b).model_field(spread_scale(b));
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
(field.grid(b, cols, rows).flat_map(|([r, g, bl], a)| [q(r), q(g), q(bl), q(a)]).collect(), cols, rows)
(field.display_grid(b, cols, rows).flat_map(|([r, g, bl], a)| [q(r), q(g), q(bl), q(a)]).collect(), cols, rows)
} else {
let k = self.doc.raster_effects_ppi / 72.0;
let along = |len: f64| (len * k).ceil().clamp(1.0, MAX_GRADIENT_PX) as u16;
+23
View File
@@ -41,6 +41,29 @@ fn png_of(svg: &str) -> image::RgbaImage {
image::load_from_memory(&base64_decode(data).unwrap()).unwrap().to_rgba8()
}
#[test]
fn freeform_points_that_share_a_model_are_written_as_their_mix() {
let mid = |a: Color, b: Color| {
let p = |x, c| FreeformPoint::new(Point::new(x, 80.0), c);
let mut g = GradientPaint::new(Gradient { kind: GradientKind::Freeform, ..Default::default() });
g.freeform = Some(Freeform { points: vec![p(40.0, a), p(160.0, b)], ..Default::default() });
let shape = shapes::rectangle(Rect::new(20.0, 20.0, 180.0, 140.0));
let d = doc_with(Node::path(NodeId(0), shape, Appearance::basic(Paint::Gradient(Box::new(g)), Paint::None, 0.0)));
let png = png_of(&export_full(&d, &ExportOptions::default(), None).svg);
png.get_pixel(png.width() / 2, png.height() / 2).0
};
let near = |got: [u8; 4], want: [u8; 4]| got.iter().zip(want).all(|(g, w)| g.abs_diff(w) <= 3);
let got = mid(Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 0.0, 0.0, 1.0));
let want = Color::cmyk(0.5, 0.0, 0.0, 0.5).to_rgba8(1.0);
assert!(near(got, want), "CMYK: {got:?} vs {want:?}");
let got = mid(Color::lab(90.0, -60.0, 40.0), Color::lab(30.0, 60.0, -40.0));
let want = Color::lab(60.0, 0.0, 0.0).to_rgba8(1.0);
assert!(near(got, want), "Lab: {got:?} vs {want:?}");
let got = mid(Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0));
let want = Color::rgb(0.5, 0.0, 0.5).to_rgba8(1.0);
assert!(near(got, want), "RGB: {got:?} vs {want:?}");
}
#[test]
fn a_freeform_fill_is_an_image_clipped_to_the_shape() {
let shape = shapes::ellipse(Rect::new(20.0, 20.0, 180.0, 140.0));
+19
View File
@@ -711,6 +711,25 @@ mod gradient_chip_tests {
assert_eq!(colors[0], Color32::BLACK);
}
#[test]
fn freeform_chip_mixes_points_in_the_model_they_share() {
use vectorcraft_color::{Color, Freeform, FreeformPoint};
use vectorcraft_geom::kurbo::Point;
let chip = |a: Color, b: Color| {
let p = |x, c| FreeformPoint::new(Point::new(x, 10.0), c);
let mut gp = paint(GradientKind::Freeform, 0.0);
gp.freeform = Some(Freeform { points: vec![p(0.0, a), p(20.0, b)], ..Default::default() });
gradient_chip_colors(&gp, 20.0, 20.0, 4, 4)[2 * 5 + 2]
};
let near = |got: Color32, want: [u8; 4]| [got.r(), got.g(), got.b()].iter().zip(want).all(|(g, w)| g.abs_diff(w) <= 3);
let got = chip(Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 0.0, 0.0, 1.0));
let want = Color::cmyk(0.5, 0.0, 0.0, 0.5).to_rgba8(1.0);
assert!(near(got, want), "CMYK: {got:?} vs {want:?}");
let got = chip(Color::lab(90.0, -60.0, 40.0), Color::lab(30.0, 60.0, -40.0));
let want = Color::lab(60.0, 0.0, 0.0).to_rgba8(1.0);
assert!(near(got, want), "Lab: {got:?} vs {want:?}");
}
/// At a position and display scale that fall between pixels, the gradient chip covers the same
/// pixel rows as a colour or None chip beside it (#3826). Filled rectangles are drawn on whole
/// pixels, so the toolbar's Gradient button sat up to half a pixel off its neighbors.
+2 -2
View File
@@ -71,10 +71,10 @@ pub(super) fn gradient_chip_colors(gp: &vectorcraft_color::GradientPaint, width:
let n = (cols + 1) * (rows + 1);
if gp.gradient.kind == GradientKind::Freeform {
let Some(painted) = vectorcraft_color::freeform::painted_box(b) else { return vec![Color32::TRANSPARENT; n] };
let field = gp.freeform_on(painted).field(vectorcraft_color::freeform::spread_scale(painted));
let field = gp.freeform_on(painted).model_field(vectorcraft_color::freeform::spread_scale(painted));
return (0..n)
.map(|k| {
let ([r, g, b], _) = field.sample(at(k));
let ([r, g, b], _) = field.display_sample(at(k));
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
Color32::from_rgb(q(r), q(g), q(b))
})
+8 -3
View File
@@ -803,7 +803,8 @@ A `security` password encrypts the file (RC4 40-bit at PDF 1.3, RC4 128-bit at 1
1.7 and 2.0) with its permissions; either password opens it (`document.open {password}`), and a password with PDF/A or PDF/X is refused.
Pattern fills and strokes are written as their tiles clipped to the area they paint (a stroke's outline, with its
dashes, caps, profile, arrowheads and alignment), and freeform gradients as an image of their colour field at the
document's raster effects resolution, clipped the same way.
document's raster effects resolution, clipped the same way (a CMYK image when every point is CMYK or, in a CMYK
document, every point is Gray; the Output settings convert it as they convert any CMYK image).
`createLayers` writes each layer and sublayer (template layers are left out) as a PDF layer, an optional content group
named as the layer and listed under its parent layer's: hidden layers are off (their art is written, for the reader to
show), non-printing ones have `/PrintState /OFF` and locked ones are locked. It needs PDF 1.5 or later (at 1.4 it
@@ -1017,7 +1018,10 @@ line?}`, `setPoint {index?, …}`, `deletePoint {index?}`, `addLine {points}`, `
`selectPoint {index|null}` edit them (`index` defaults to the selected point; each edit is one undo step). The color
of a point colored along the stops, of a point `splitLine` adds and of a point `addPoint` adds without `color` is a mix
of the colors around it (the two stops, the line's two points, or all the points) in the color model those colors
share, so CMYK stops and points give CMYK points. With the
share, so CMYK stops and points give CMYK points. Points that are all CMYK, Gray, Lab or RGB mix in that model on the
canvas, in the swatch and Fill and Stroke chips, and in the SVG, PDF, EPS and EMF writers; points in different models
mix in display RGB (on the canvas in a CMYK document that shows transparency, as the inks of each point). With
Appearance of Black set to Rich, the canvas mixes points that include pure CMYK black in display RGB. With the
Gradient tool on a freeform gradient a click on a point selects it (drag to move it), a click on a line adds a point
on it, a click elsewhere on the art adds a point (in Lines mode joined to the selected one), and Delete removes the
selected point:
@@ -5039,7 +5043,8 @@ without `useArtboards` (no central file when no art shows). `level: 3` (default)
shadings and masks transparent image pixels out; `level: 2` writes gradients as bands of colour (as does
`compatibleGradients`) and transparent pixels white. Type is always glyph outlines, so no fonts are needed (`embedFonts`
is accepted); spot colours are Separation colour spaces, and overprinting fills and strokes overprint unless
`overprints: "discard"`. RGB documents are written in CMYK unless `cmykPostScript: false`. When the file would hold an
`overprints: "discard"`. RGB documents are written in CMYK unless `cmykPostScript: false`. A freeform gradient is
written as an image, in CMYK when every point is CMYK or when every point is Gray in a file written in CMYK. When the file would hold an
image that can't be decoded, the image is left out, and `warnings` carries "an image couldn't be decoded and is missing
from the picture" once for all such images.
Colorized grayscale and 1-bit images use their process or spot ink; the flattener leaves them alone unless they have opacity, a blending mode, an opacity mask or effects, or overlap other transparent art. Binary coverage leaves underlying art untouched. For a grayscale image, Level 3 leaves white (zero-coverage) pixels out, Level 2 writes them white, and gray midtones are written over white at both levels.
+1 -1
View File
@@ -1192,7 +1192,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- The stop popover a double-click on a stop opens (the Gradient tool's annotator or the Gradient panel) has an eyedropper beside its Color and Swatches tabs: it closes the popover and samples the next colour clicked on the art into the stop, as the Gradient panel's eyedropper does (#4681).
Cmd/Ctrl-click on the Gradient panel's Gradient Fill box or on the Tools panel's Gradient button resets the gradient of the active Fill or Stroke to the linear White, Black gradient, as one undo step ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)).
With a gradient stop or a freeform gradient point selected, Alt-clicking a color swatch in the Swatches panel recolors it instead of the inactive Fill or Stroke.
- **Freeform gradient colors in their own model:** Object ▸ Expand and Object ▸ Create Gradient Mesh (`object.mesh.create`) mix a freeform gradient's points in the color model they share: points of C50 M20 Y0 K10 give mesh points of C50 M20 Y0 K10, and RGB points keep their values in a working RGB space other than sRGB. Points in different models mix in display RGB, and a CMYK document stores the result as CMYK through the color settings. Points colored along CMYK stops (`paint.editGradient {stops}`, or a CMYK gradient switched to freeform) are CMYK, and so are a point `paint.freeform.splitLine` adds between CMYK points and a point `paint.freeform.addPoint` adds without `color` to CMYK points. Public reference: [Gradients](https://web.archive.org/web/20250714042311/https://helpx.adobe.com/illustrator/using/gradients.html) and [Meshes](https://web.archive.org/web/20250812201019/https://helpx.adobe.com/illustrator/using/meshes.html), which do not state how colors in different models mix; the rule is VectorCraft's own.
- **Freeform gradient colors in their own model:** Object ▸ Expand and Object ▸ Create Gradient Mesh (`object.mesh.create`) mix a freeform gradient's points in the color model they share: points of C50 M20 Y0 K10 give mesh points of C50 M20 Y0 K10, and RGB points keep their values in a working RGB space other than sRGB. The canvas, the gradient chips and the SVG, PDF, EPS and EMF writers mix the points in that model too. PDF and EPS write a gradient whose points are all CMYK, or all Gray in a CMYK file, as a CMYK image, which PDF's Output settings convert as they convert any CMYK image. Points in different models mix in display RGB (on the canvas in a CMYK document that shows transparency, as the inks of each point), and a CMYK document stores the result of Expand as CMYK through the color settings. Points colored along CMYK stops (`paint.editGradient {stops}`, or a CMYK gradient switched to freeform) are CMYK, and so are a point `paint.freeform.splitLine` adds between CMYK points and a point `paint.freeform.addPoint` adds without `color` to CMYK points. Public reference: [Gradients](https://web.archive.org/web/20250714042311/https://helpx.adobe.com/illustrator/using/gradients.html) and [Meshes](https://web.archive.org/web/20250812201019/https://helpx.adobe.com/illustrator/using/meshes.html), which do not state how colors in different models mix; the rule is VectorCraft's own.
- **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance.
- **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random, Pressure, Tilt, Bearing or Rotation with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Scatter Brush Options (Size, Spacing, Scatter and Rotation, each Fixed, Random between two values, Pressure from the first at the lightest pressure to the second at the heaviest, Tilt, Bearing or Rotation; Rotation relative to Page or Path), Art Brush Options (Width, Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with the guides' start and end, the four Directions, Flip Along and Across) and Pattern Brush Options (Scale, Spacing, the five tiles shown, Flip Along and Across, Stretch to fit, Add space to fit or Approximate path) each have Colorization (None, Tints, Tints and Shades, Hue Shift with its Key Color picked from the art's colours) and a live stroke preview; values from files and commands are clamped to their ranges. Still to come: the Art brush's Overlap, Stylus Wheel, and the pen's tilt, bearing and rotation on Windows, native Wayland and the web.
Under Tints and Tints and Shades, art in black ink alone goes by its ink amount, and a process or gray stroke color's tints and shades stay in that color's model; these are VectorCraft's own rules.