Gradients: Dither in the Gradient panel draws a gradient fill with fine noise that hides banding

Illustrator 30.0's gradient Dither, from the public documentation
(https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-gradients/control-dither-in-gradients.html):
a Dither checkbox in the Gradient panel; dither converts the gradient into
pixels with subtle noise, works with gradient fills only, and follows the
Document Raster Effects resolution.

- Gradient gains `dither` (serde default false, written only when on, so
  older files open unchanged and new files keep it).
- The renderer draws a dithered linear or radial fill as an image paint: the
  gradient sampled a cell per device pixel or finer (quarter-octave steps,
  cached per thread), each channel given seeded triangular noise of up to a
  level before rounding. Same pixels every run; strokes and undithered fills
  draw exactly as before. PNG and other raster exports go through it.
- SVG and PDF write a dithered fill as an image at the document's raster
  effects resolution, clipped to the shape (the freeform-gradient path);
  characters' fills stay vector gradients in SVG, with a warning.
- paint.editGradient and paint.setFill take `dither`; strokes refuse it.
  The Gradient panel's Dither box is dimmed behind the Stroke proxy.
- i18n rows in all 24 catalogs; docs: G13, target-app-parity, ui-parity,
  mcp.md, plugins.md.
This commit is contained in:
Taha-Mahmoodi
2026-10-22 22:38:42 +07:00
committed by bflatastic
parent a1d8952bc8
commit cc1a660a6d
47 changed files with 579 additions and 41 deletions
+39 -9
View File
@@ -75,6 +75,10 @@ fn half() -> f32 {
pub struct Gradient {
pub kind: GradientKind,
pub stops: Vec<GradientStop>,
/// Dither: a fill of this gradient is drawn as pixels with fine noise of up to a colour level
/// added, which hides the steps (banding) between the levels 8-bit colour has.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub dither: bool,
}
impl Default for Gradient {
@@ -87,7 +91,7 @@ impl Default for Gradient {
impl Gradient {
/// A gradient of `kind` through `stops`, with the default options.
pub fn new(kind: GradientKind, stops: Vec<GradientStop>) -> Self {
Self { kind, stops }
Self { kind, stops, dither: false }
}
/// Colour and opacity at `t` (honours midpoints).
pub fn sample(&self, t: f32) -> (Color, f32) {
@@ -538,24 +542,29 @@ impl GradientPaint {
f.transform(a);
}
}
/// Is this gradient drawn as pixels rather than as a vector gradient (SVG and PDF draw such
/// gradients as images): a freeform gradient.
pub fn draws_as_pixels(&self) -> bool {
self.gradient.kind == GradientKind::Freeform
/// Is this gradient drawn as pixels rather than as a vector gradient when it paints a fill
/// (`fill`) or a stroke: a freeform gradient, or a dithered fill (dither applies to fills
/// only)?
pub fn draws_as_pixels(&self, fill: bool) -> bool {
self.gradient.kind == GradientKind::Freeform || (fill && self.gradient.dither)
}
/// A linear or radial gradient placed on box `bounds` as a `cols` × `rows` grid of
/// unpremultiplied RGBA8 pixels over `area`, row by row, each sampled at its centre (colours
/// as `rgb` gives them, mixed as the renderer mixes them: [`Ramp`]).
/// as `rgb` gives them, mixed as the renderer mixes them: [`Ramp`]). Dithered, each colour
/// channel of pixel (column, row) gets [`dither_noise`] before it is rounded to a level.
pub fn pixels(&self, bounds: Rect, area: Rect, cols: u16, rows: u16, rgb: &dyn Fn(&Color) -> [f32; 3]) -> impl Iterator<Item = [u8; 4]> + use<> {
let (kind, geom) = (self.gradient.kind, self.resolve(bounds));
let (kind, geom, dither) = (self.gradient.kind, self.resolve(bounds), self.gradient.dither);
let ramp = Ramp::new(&self.gradient, rgb);
let (cw, ch) = (area.width() / f64::from(cols.max(1)), area.height() / f64::from(rows.max(1)));
let (x0, y0) = (area.x0, area.y0);
(0..u32::from(rows)).flat_map(move |j| (0..u32::from(cols)).map(move |i| (i, j))).map(move |(i, j)| {
let p = Point::new(x0 + (f64::from(i) + 0.5) * cw, y0 + (f64::from(j) + 0.5) * ch);
let c = ramp.at(geom.param_at(kind, p) as f32);
let level = |k: usize| (c[k].clamp(0.0, 1.0) * 255.0).round() as u8;
[level(0), level(1), level(2), level(3)]
let level = |k: usize| {
let noise = if dither { dither_noise(i, j, k as u32) } else { 0.0 };
(c[k].clamp(0.0, 1.0) * 255.0 + noise).round().clamp(0.0, 255.0) as u8
};
[level(0), level(1), level(2), (c[3].clamp(0.0, 1.0) * 255.0).round() as u8]
})
}
}
@@ -598,6 +607,27 @@ impl Ramp {
}
}
/// Seed of [`dither_noise`]: fixed, so a dithered gradient draws and exports the same pixels on
/// every run.
const DITHER_SEED: u32 = 0x5eed_d17e;
/// An integer hash that mixes every input bit into every output bit (the "lowbias32" mix).
fn mix32(mut v: u32) -> u32 {
v ^= v >> 16;
v = v.wrapping_mul(0x7feb_352d);
v ^= v >> 15;
v = v.wrapping_mul(0x846c_a68b);
v ^ (v >> 16)
}
/// Dither noise for colour channel `channel` of pixel (`x`, `y`), in colour levels: -1..1,
/// densest around 0 (the sum of two uniform values: noise that hides steps between levels without
/// a pattern of its own), the same for the same pixel on every run.
pub fn dither_noise(x: u32, y: u32, channel: u32) -> f32 {
let h = mix32(x ^ mix32(y ^ mix32(channel ^ DITHER_SEED)));
((h & 0xffff) as f32 + (h >> 16) as f32) / 65535.0 - 1.0
}
#[cfg(test)]
mod tests {
use super::*;
+12 -2
View File
@@ -20,7 +20,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Gradient",
["Window", "Gradient"],
None,
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|freeform (freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), stops?: [{offset 0..1, color? (needed without swatch), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg, aspect?: %, reverse?: bool, item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient",
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|freeform (freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), stops?: [{offset 0..1, color? (needed without swatch), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg, aspect?: %, reverse?: bool, dither?: bool (fills only: a linear or radial gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient",
has_doc,
edit_gradient
),
@@ -225,6 +225,10 @@ pub(crate) fn parse_gradient(g: &Value) -> Parsed<GradientPaint> {
let kind = str_param(g, "kind").map(parse_kind).transpose()?.unwrap_or_default();
let stops = g.get("stops").map(parse_stops).transpose()?.unwrap_or_else(|| Gradient::default().stops);
let mut gp = GradientPaint::new(Gradient::new(kind, stops));
gp.gradient.dither = match g.get("dither") {
None | Some(Value::Null) => false,
Some(v) => v.as_bool().ok_or("`dither` must be true or false")?,
};
gp.angle = f64_or(g, "angle", 0.0);
gp.swatch = str_param(g, "swatch").map(str::to_string);
let geom = GeomEdit::parse(g)?;
@@ -319,6 +323,9 @@ pub(crate) fn apply_gradient_edit_in(
// Midpoints mirrored with their segments.
gp.gradient.reverse();
}
if let Some(d) = p.get("dither").and_then(Value::as_bool) {
gp.gradient.dither = d;
}
// Angle and aspect edits keep the focal point in its place in the extent ellipse.
let before = gp.geom;
if let Some(a) = p.get("angle").and_then(Value::as_f64) {
@@ -378,7 +385,7 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "paint.editGradient";
let item = item_target(s, p, C)?;
let stroke = edits_stroke(s, p, item, !s.fill_active, C)?;
check_switches(p, &["reverse"], C)?;
check_switches(p, &["reverse", "dither"], C)?;
check_numbers(p, &["angle"], C)?;
let item = item.of_kind(s, !stroke);
let ids = item.targets(s, p)?;
@@ -388,6 +395,9 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
Some(_) if !stroke => return Err(bad(C, "`strokeMode` applies to strokes (stroke: true)")),
Some(m) => Some(StrokeGradientMode::parse(m).ok_or_else(|| bad(C, format!("strokeMode must be within|along|across, got {m}")))?),
};
if p.get("dither").and_then(Value::as_bool).is_some() && stroke {
return Err(bad(C, "`dither` applies to gradient fills (stroke: false)"));
}
super::paint::check_keep_model(p, C)?;
// New RGB stop colors take the document's model; those of the swatches the stops name don't.
let stored = stop_colors(s, p, p);
+1 -1
View File
@@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Fill",
[],
None,
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear or radial `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only",
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), dither?: bool (linear and radial fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear or radial `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only",
has_doc,
|s, p| set_paint(s, p, true)
),
+41
View File
@@ -403,3 +403,44 @@ fn swatches_and_new_art_fit_a_placed_gradient_to_themselves() {
let tb = node(&s, t).geometric_bounds().unwrap();
assert!(doc.start.distance(tb.center()) < 1e-9 && (doc.aspect - 0.5).abs() < 1e-9, "{doc:?} on {tb:?}");
}
/// Dither (Illustrator 30's Gradient panel checkbox): set and cleared through the gradient
/// commands, refused on strokes (it applies to fills), kept by swatches, the params and a native
/// save and load.
#[test]
fn dither_is_a_fill_gradient_option_that_saves_and_loads() {
let mut s = session();
let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap();
assert!(!fill_gradient(&s, id).gradient.dither, "off by default");
let undo = s.doc().unwrap().history.undo.len();
s.execute("paint.editGradient", &json!({"dither": true})).unwrap();
assert!(fill_gradient(&s, id).gradient.dither);
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
// The params carry it, so a swatch and a copied paint keep it.
let g = fill_gradient(&s, id);
assert_eq!(vectorcraft_tools::params::gradient_params(&g)["dither"], json!(true));
let mut q = gradient_param(&g);
q["name"] = json!("Smooth");
s.execute("swatch.new", &q).unwrap();
let Some(Paint::Gradient(sw)) = s.doc().unwrap().doc.swatch("Smooth").map(|w| w.paint.clone()) else { panic!("a gradient swatch") };
assert!(sw.gradient.dither);
// Saved and opened again.
let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
let Paint::Gradient(saved) = back.node(id).unwrap().appearance.fill_paint() else { panic!("a gradient fill") };
assert!(saved.gradient.dither, "the native file keeps dither");
// Written only when on, so files without it (older ones) open with it off.
assert_eq!(serde_json::to_value(&saved.gradient).unwrap()["dither"], json!(true));
assert!(serde_json::to_value(Gradient::default()).unwrap().get("dither").is_none());
let old: Gradient = serde_json::from_value(json!({"kind": "Linear", "stops": []})).unwrap();
assert!(!old.dither);
s.execute("paint.editGradient", &json!({"dither": false})).unwrap();
assert!(!fill_gradient(&s, id).gradient.dither);
// Strokes: refused, nothing changed.
s.execute("paint.setStroke", &json!({"gradient": {}})).unwrap();
let before = node(&s, id).appearance.clone();
assert!(s.execute("paint.editGradient", &json!({"dither": true, "stroke": true})).is_err());
assert!(s.execute("paint.editGradient", &json!({"dither": "yes"})).is_err());
assert!(s.execute("paint.setFill", &json!({"gradient": {"dither": 1}})).is_err());
assert_eq!(node(&s, id).appearance, before);
}
+10 -8
View File
@@ -1277,18 +1277,20 @@ impl Exporter<'_> {
}
}
/// Is `p` a paint PDF has no paint for, which [`Self::area`] draws as art: a pattern swatch or
/// a gradient drawn as pixels ([`vectorcraft_color::GradientPaint::draws_as_pixels`])?
fn area_paint(&self, p: &Paint) -> bool {
/// Is `p` a paint PDF has no paint for when it paints a fill (`fill`) or a stroke, which
/// [`Self::area`] draws as art: a pattern swatch or a gradient drawn as pixels
/// ([`vectorcraft_color::GradientPaint::draws_as_pixels`])?
fn area_paint(&self, p: &Paint, fill: bool) -> bool {
match p {
Paint::Pattern { pattern, .. } => self.doc.pattern(pattern).is_some(),
Paint::Gradient(g) => g.draws_as_pixels(),
Paint::Gradient(g) => g.draws_as_pixels(fill),
_ => false,
}
}
/// Paint the area `clip` (filled by `rule`; bounds `area`) with `p` at `transparency` (opacity,
/// blend mode) when it is an [area paint](Self::area_paint): a pattern's tile instances
/// blend mode) when it is an [area paint](Self::area_paint) of a fill (strokes check theirs
/// first): a pattern's tile instances
/// covering it, or an image of a gradient drawn as pixels placed on `bounds` (the painted
/// object's box, as on the canvas), clipped to it. False (nothing drawn) for other paints.
#[allow(clippy::too_many_arguments)]
@@ -1302,7 +1304,7 @@ impl Exporter<'_> {
bounds: Rect,
area: Rect,
) -> bool {
if !self.area_paint(p) {
if !self.area_paint(p, true) {
return false;
}
let (opacity, mode) = transparency;
@@ -1375,7 +1377,7 @@ impl Exporter<'_> {
if !stroke::is_plain(st) && self.brush_art(s, bp, st, page) {
return;
}
if self.area_paint(&st.paint) {
if self.area_paint(&st.paint, false) {
// The area the stroke paints (dashes, caps, joins, profile, arrowheads, alignment), as
// Outline Stroke makes it, painted with the pattern's tiles or the freeform's image.
let region = stroke::outline_region(&vectorcraft_geom::PathData::from_bezpath(bp), r, st).to_bezpath();
@@ -1567,7 +1569,7 @@ impl Exporter<'_> {
self.overprinting(s, run.style.overprint_fill, fill, |ex, s| {
s.push_transform(&text_space);
// The outlines left to fill as paths: all of them, or those real text leaves.
let outlines = match plain.as_deref().filter(|_| !fill.is_none() && !ex.area_paint(fill)) {
let outlines = match plain.as_deref().filter(|_| !fill.is_none() && !ex.area_paint(fill, true)) {
Some(text) => {
let mut left = ex.glyph_text(s, &layout, i, text, fill);
left.extend(run_bars.iter());
+2 -1
View File
@@ -5,7 +5,8 @@
//! width profiles, fitted or dotted dashes as the canvas's filled outlines and brushed strokes
//! as their brush art), opacity and blend modes (transparency groups),
//! clip groups, linear/radial gradients (shadings), pattern fills and strokes as their tiles and
//! freeform gradients as images (clipped to what they paint), embedded images (resampled and
//! freeform gradients and dithered gradient fills as images at the raster effects resolution
//! (clipped to what they paint), embedded images (resampled and
//! compressed as the Compression settings say) and text as outlined glyph paths, or as real
//! text in embedded subset fonts (Advanced). Colours are converted, tagged with ICC profiles and
//! given an output intent as the Output settings say. Hidden objects,
+22
View File
@@ -960,3 +960,25 @@ fn dictionary_values_the_reader_cannot_parse_are_skipped() {
assert!(e.as_deref().is_some_and(|e| e.contains("built on one another without end")), "{junk}: {e:?}");
}
}
/// Illustrator's documentation describes dither as the gradient converted to pixels with subtle
/// noise, at the Document Raster Effects resolution
/// (https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-gradients/control-dither-in-gradients.html):
/// a dithered gradient fill is drawn as an image at that resolution, clipped to the shape; a
/// stroke (dither applies to fills only) and an undithered fill stay shadings.
#[test]
fn dithered_gradient_fills_export_as_images_and_strokes_as_shadings() {
let grad = |dither| Paint::Gradient(Box::new(GradientPaint::new(Gradient { dither, ..Default::default() })));
let shape = shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 60.0));
let mut d = doc(200.0, 100.0);
d.raster_effects_ppi = 144.0;
add(&mut d, Node::path(NodeId(0), shape.clone(), Appearance::basic(grad(true), grad(true), 4.0)));
let pdf = uncompressed(&d).replace(' ', "");
assert!(pdf.contains("/Subtype/Image"), "the fill is an image");
assert!(pdf.contains("/Width200") && pdf.contains("/Height100"), "two pixels a point at 144 ppi");
assert!(pdf.contains("/ShadingType2"), "the stroke keeps its shading");
let mut plain = doc(200.0, 100.0);
add(&mut plain, Node::path(NodeId(0), shape, Appearance::basic(grad(false), Paint::None, 0.0)));
let pdf = uncompressed(&plain).replace(' ', "");
assert!(!pdf.contains("/Subtype/Image") && pdf.contains("/ShadingType2"));
}
+105
View File
@@ -0,0 +1,105 @@
//! Dithered gradient fills in the renderer.
//!
//! A dithered linear or radial gradient is drawn as pixels: the gradient sampled on a grid over
//! the painted box with a cell per device pixel or finer (in quarter-octave steps, so a pan reuses
//! the grid and a zoom rarely misses), each colour channel given fine noise of up to a level
//! ([`vectorcraft_color::gradient::dither_noise`]) before it is rounded, and drawn as an image
//! paint that the fill clips. Past [`MAX_CELLS`] a side (a box larger than that on screen), cells
//! grow beyond a pixel and are mixed bilinearly, so the gradient stays smooth.
use std::cell::RefCell;
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use vectorcraft_color::GradientPaint;
use vectorcraft_color::freeform::painted_box;
use vectorcraft_geom::{Affine, Rect};
use vello_cpu::peniko::{Extend, ImageQuality, ImageSampler};
use vello_cpu::{Pixmap, RenderContext};
use crate::freeform::hash_color;
use crate::ink::Ink;
use crate::lru::{self, FrameCache};
/// Most cells along a side of a dithered gradient's grid (64 MiB for a square grid).
pub(crate) const MAX_CELLS: f64 = 4096.0;
thread_local! {
/// Grids by [`key`], within [`lru::DITHER_BUDGET`], least recently used first out.
static GRIDS: RefCell<FrameCache<u64, Arc<Pixmap>>> = RefCell::new(FrameCache::new(lru::DITHER_BUDGET, usize::MAX));
}
/// Cells along a side `px` device pixels long: at least one per pixel, rounded up to a
/// quarter-octave step, 1 to [`MAX_CELLS`].
fn cells(px: f64) -> u16 {
let steps = (4.0 * px.max(1.0).log2()).ceil();
2f64.powf(steps / 4.0).ceil().clamp(1.0, MAX_CELLS) as u16
}
/// Cache key of gradient `g` placed on box `b` as a `cols` × `rows` grid painted with `ink` (and
/// the active colour settings, which turn colours into display RGB or inks).
fn key(g: &GradientPaint, b: Rect, cols: u16, rows: u16, ink: Ink) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
let gr = &g.gradient;
(gr.kind as u8, gr.dither).hash(&mut h);
for s in &gr.stops {
(s.offset.to_bits(), s.opacity.to_bits(), s.midpoint.to_bits()).hash(&mut h);
hash_color(&s.color, &mut h);
}
let geom = g.resolve(b);
(geom.start.x.to_bits(), geom.start.y.to_bits(), geom.end.x.to_bits(), geom.end.y.to_bits(), geom.aspect.to_bits()).hash(&mut h);
geom.focal.map(|f| (f.x.to_bits(), f.y.to_bits())).hash(&mut h);
(b.x0.to_bits(), b.y0.to_bits(), b.x1.to_bits(), b.y1.to_bits(), cols, rows).hash(&mut h);
(Arc::as_ptr(&vectorcraft_color::cms::active()) as usize, ink).hash(&mut h);
h.finish()
}
/// `g` placed on `b` as a premultiplied `cols` × `rows` pixmap, colours as `ink` paints them.
pub(crate) fn rasterize(g: &GradientPaint, b: Rect, cols: u16, rows: u16, ink: Ink) -> Pixmap {
let data = g
.pixels(b, b, cols, rows, &|c| ink.rgb(c))
.map(|[r, gr, bl, a]| {
let pm = |v: u8| ((u16::from(v) * u16::from(a) + 127) / 255) as u8;
vello_cpu::color::PremulRgba8 { r: pm(r), g: pm(gr), b: pm(bl), a }
})
.collect();
Pixmap::from_parts(data, cols, rows)
}
/// Set dithered gradient `g` on box `bounds` (in the paint's space, which the context's current
/// transform maps to pixels) as the context paint. Returns false for an empty box.
pub(crate) fn set_dithered_paint(ctx: &mut RenderContext, g: &GradientPaint, bounds: Rect, ink: Ink) -> bool {
let Some(b) = painted_box(bounds) else { return false };
// Device pixels per unit along the box's sides.
let [m0, m1, m2, m3, _, _] = ctx.transform().as_coeffs();
let (sx, sy) = (m0.hypot(m1), m2.hypot(m3));
let (wpx, hpx) = (b.width() * sx, b.height() * sy);
if !(wpx.is_finite() && hpx.is_finite()) {
return false;
}
let (cols, rows) = (cells(wpx), cells(hpx));
let k = key(g, b, cols, rows, ink);
let pm = GRIDS
.with(|c| {
let mut c = c.borrow_mut();
let t = c.tick();
c.get(&k, t).cloned()
})
.unwrap_or_else(|| {
let pm = Arc::new(rasterize(g, b, cols, rows, ink));
GRIDS.with(|c| {
let mut c = c.borrow_mut();
let t = c.tick();
c.insert(k, pm.clone(), cols as usize * rows as usize * 4, t);
});
pm
});
// A cell per pixel or finer: each pixel takes one cell, its noise intact. Coarser (a box past
// the grid's largest size on screen): mixed bilinearly, so no cell shows as a block.
let fine = f64::from(cols) >= wpx && f64::from(rows) >= hpx;
let quality = if fine { ImageQuality::Low } else { ImageQuality::Medium };
let sampler = ImageSampler { x_extend: Extend::Pad, y_extend: Extend::Pad, quality, alpha: 1.0 };
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler });
ctx.set_paint_transform(Affine::translate(b.origin().to_vec2()) * Affine::scale_non_uniform(b.width() / cols as f64, b.height() / rows as f64));
true
}
+1 -1
View File
@@ -24,7 +24,7 @@ thread_local! {
static GRIDS: RefCell<FrameCache<u64, Arc<Pixmap>>> = RefCell::new(FrameCache::new(lru::FREEFORM_BUDGET, usize::MAX));
}
fn hash_color(c: &Color, h: &mut impl Hasher) {
pub(crate) fn hash_color(c: &Color, h: &mut impl Hasher) {
match *c {
Color::Rgb { r, g, b } => (0u8, r.to_bits(), g.to_bits(), b.to_bits()).hash(h),
Color::Cmyk { c, m, y, k } => (1u8, c.to_bits(), m.to_bits(), y.to_bits(), k.to_bits()).hash(h),
+6 -3
View File
@@ -8,6 +8,7 @@
mod brush_fx;
mod changed;
mod dither;
pub mod encode;
mod freeform;
mod fx;
@@ -1518,7 +1519,7 @@ impl Renderer {
ctx.push_layer(None, Some(blend_mode(fl.blend)), Some(fl.opacity), None, None);
}
ctx.set_transform(f.view);
if paint::set_paint(ctx, &fl.paint, bounds, f) {
if paint::set_fill_paint(ctx, &fl.paint, bounds, f) {
self.fold_alpha(ctx, f.ink, &fl.paint);
ctx.set_fill_rule(fill_rule(rule));
ctx.fill_path(bp);
@@ -1858,7 +1859,7 @@ impl Renderer {
}
ctx.set_transform(xf);
ctx.set_fill_rule(peniko::Fill::NonZero);
if paint::set_paint(ctx, &run.style.fill, g.bounds, f) {
if paint::set_fill_paint(ctx, &run.style.fill, g.bounds, f) {
self.fold_alpha(ctx, f.ink, &run.style.fill);
overprint(ctx, run.style.overprint_fill);
ctx.fill_path(path);
@@ -1889,7 +1890,7 @@ impl Renderer {
ctx.push_layer(None, Some(blend_mode(fl.blend)), Some(fl.opacity), None, None);
}
ctx.set_transform(f.view);
if paint::set_paint(ctx, &fl.paint, tb, f) {
if paint::set_fill_paint(ctx, &fl.paint, tb, f) {
ctx.set_fill_rule(peniko::Fill::NonZero);
ctx.fill_path(all);
}
@@ -2576,6 +2577,8 @@ mod tests_cmykblend;
#[cfg(test)]
mod tests_container;
#[cfg(test)]
mod tests_dither;
#[cfg(test)]
mod tests_fontchange;
#[cfg(test)]
mod tests_freeform;
+3
View File
@@ -43,6 +43,9 @@ pub(crate) const MESH_BUDGET: usize = 32 << 20;
pub(crate) const PATTERN_BUDGET: usize = 128 << 20;
/// Freeform gradient grids, per thread (`freeform::set_freeform_paint`).
pub(crate) const FREEFORM_BUDGET: usize = 32 << 20;
/// Dithered gradient grids, per thread (`dither::set_dithered_paint`): a grid is at most 4096²
/// pixels, 64 MiB.
pub(crate) const DITHER_BUDGET: usize = 64 << 20;
/// Frames an entry may go unused before [`FrameCache::sweep`] drops it.
const AGE: u64 = 3;
+11
View File
@@ -52,6 +52,17 @@ pub(crate) fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, f: &Fr
}
}
/// [`set_paint`] for a fill: a dithered gradient (dither applies to fills only) is drawn as
/// pixels with fine noise ([`crate::dither`]).
pub(crate) fn set_fill_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, f: &Frame) -> bool {
match p {
Paint::Gradient(g) if g.gradient.dither && g.gradient.kind != GradientKind::Freeform => {
crate::dither::set_dithered_paint(ctx, g, bounds, f.ink)
}
_ => set_paint(ctx, p, bounds, f),
}
}
/// The most memory decoding one image may take: print rasters run to gigabytes (a 300 ppi roll,
/// 6614 x 59055 px, is 1.56 GB as RGBA), past the decoder's 512 MB default. The web keeps it.
const MAX_DECODE_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 } else { 2 << 30 };
+100
View File
@@ -0,0 +1,100 @@
//! Dithered gradient fills: fine noise instead of bands, the same colours on average, the same
//! pixels on every render, and nothing changed without dither or on strokes.
use super::*;
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
use vectorcraft_doc::{Appearance, Node};
use vectorcraft_geom::shapes;
const W: u32 = 400;
const H: u32 = 40;
/// A shallow grey ramp, #404040 → #4a4a4a: ten levels over 400 pixels, bands 40 pixels wide.
fn shallow(kind: GradientKind, dither: bool) -> Paint {
let mut g =
Gradient::new(kind, vec![GradientStop::new(0.0, Color::rgb8(0x40, 0x40, 0x40)), GradientStop::new(1.0, Color::rgb8(0x4a, 0x4a, 0x4a))]);
g.dither = dither;
Paint::Gradient(Box::new(GradientPaint::new(g)))
}
/// A W × H document with a rectangle over it painted `fill`, stroked `stroke` 10 wide.
fn doc(fill: Paint, stroke: Paint) -> Document {
let mut d = Document::new(W as f64, H as f64);
let id = d.alloc_id();
let n = Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, W as f64, H as f64)), Appearance::basic(fill, stroke, 10.0));
d.insert(Some(d.layers[0].id), 0, n).unwrap();
d
}
fn render(d: &Document, xf: Affine) -> Rendered {
Renderer::new().render(d, W, H, xf, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() })
}
/// The longest run of identical pixels along row `y` (the stroke's 5 pixels at each end left out).
fn longest_run(r: &Rendered, y: u32) -> usize {
let row: Vec<[u8; 4]> = (5..W - 5).map(|x| r.pixel(x, y)).collect();
row.chunk_by(|a, b| a == b).map(<[_]>::len).max().unwrap_or(0)
}
#[test]
fn a_dithered_shallow_gradient_has_no_bands_where_the_plain_one_does() {
let plain = render(&doc(shallow(GradientKind::Linear, false), Paint::None), Affine::IDENTITY);
let dithered = render(&doc(shallow(GradientKind::Linear, true), Paint::None), Affine::IDENTITY);
for y in [3, 20, 36] {
assert!(longest_run(&plain, y) >= 30, "the plain ramp bands: row {y}, longest run {}", longest_run(&plain, y));
assert!(longest_run(&dithered, y) <= 12, "dithered: row {y}, longest run {}", longest_run(&dithered, y));
}
// The noise moves no colour: each 20-pixel stretch averages what the plain ramp does.
for x0 in (0..W).step_by(20) {
let mean = |r: &Rendered| {
(x0..x0 + 20).flat_map(|x| (0..H).map(move |y| (x, y))).map(|(x, y)| f64::from(r.pixel(x, y)[0])).sum::<f64>() / f64::from(20 * H)
};
assert!((mean(&plain) - mean(&dithered)).abs() < 0.35, "x {x0}: {} vs {}", mean(&plain), mean(&dithered));
}
// Noise of at most a level or so either way.
for (x, y) in [(10, 10), (200, 20), (390, 30)] {
let (a, b) = (i32::from(plain.pixel(x, y)[0]), i32::from(dithered.pixel(x, y)[0]));
assert!((a - b).abs() <= 2, "({x}, {y}): {a} vs {b}");
}
}
#[test]
fn dithered_renders_are_the_same_every_time() {
let d = doc(shallow(GradientKind::Radial, true), Paint::None);
let (a, b) = (render(&d, Affine::IDENTITY), render(&d, Affine::IDENTITY));
assert!((0..H).all(|y| (0..W).all(|x| a.pixel(x, y) == b.pixel(x, y))));
// And with the grid drawn afresh on another renderer, panned by a whole pixel.
let moved = Renderer::new().render(
&d,
W,
H,
Affine::translate((1.0, 0.0)),
&RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() },
);
assert!((0..H).all(|y| (1..W).all(|x| moved.pixel(x, y) == a.pixel(x - 1, y))), "the noise moves with the art");
}
#[test]
fn dither_leaves_strokes_and_undithered_fills_as_they_were() {
// Dither applies to fills only: a stroke draws its gradient as before.
let fill = Paint::solid(Color::WHITE);
let a = render(&doc(fill.clone(), shallow(GradientKind::Linear, false)), Affine::IDENTITY);
let b = render(&doc(fill, shallow(GradientKind::Linear, true)), Affine::IDENTITY);
assert!((0..H).all(|y| (0..W).all(|x| a.pixel(x, y) == b.pixel(x, y))), "a dithered gradient on a stroke draws as an undithered one");
// Off: the vector gradient, its bands where they always were.
let plain = render(&doc(shallow(GradientKind::Linear, false), Paint::None), Affine::IDENTITY);
assert_eq!(plain.pixel(0, 20)[0], 0x40);
assert_eq!(plain.pixel(W - 1, 20)[0], 0x4a);
}
#[test]
fn a_zoomed_or_rotated_dithered_gradient_keeps_its_colours() {
for xf in [Affine::scale(3.0), Affine::rotate_about(0.3, vectorcraft_geom::Point::new(200.0, 20.0)), Affine::scale(40.0)] {
let plain = render(&doc(shallow(GradientKind::Linear, false), Paint::None), xf);
let dithered = render(&doc(shallow(GradientKind::Linear, true), Paint::None), xf);
for (x, y) in [(50, 10), (150, 20), (250, 30)] {
let (a, b) = (plain.pixel(x, y), dithered.pixel(x, y));
assert!(a.iter().zip(b).all(|(p, q)| (i32::from(*p) - i32::from(q)).abs() <= 2), "{xf:?} ({x}, {y}): {a:?} vs {b:?}");
}
}
}
+13 -7
View File
@@ -1047,7 +1047,7 @@ impl Writer<'_> {
&& strokes <= 1
&& !items.iter().any(|i| has_raster(i.effects()))
&& items.iter().all(|i| i.blend() == BlendMode::Normal)
&& !items.iter().any(|i| pixel_gradient(i.paint()).is_some())
&& !items.iter().any(|i| pixel_gradient(i.paint(), matches!(i, AppearanceItem::Fill(_))).is_some())
&& plans.iter().flatten().all(|p| matches!(p, StrokePlan::Written(Written { shape: WrittenShape::Stroke { .. }, side: None })));
if simple {
let mut p = Props::new();
@@ -1081,7 +1081,7 @@ impl Writer<'_> {
match (it, plan) {
(AppearanceItem::Fill(f), _) => {
let blend = (f.blend != BlendMode::Normal).then(|| ("mix-blend-mode", blend_css(f.blend).to_string()));
if let (Some(g), Some(b)) = (pixel_gradient(&f.paint), bounds) {
if let (Some(g), Some(b)) = (pixel_gradient(&f.paint, true), bounds) {
p.extend((f.opacity < 1.0).then(|| ("opacity", fmt_num(f.opacity as f64, 3))));
p.extend(blend);
self.raster_paint(g, b, &[d.to_string()], rule, &p, "");
@@ -1113,7 +1113,7 @@ impl Writer<'_> {
let side = self.side_attr(w.side, d, rule, w.reach(s, bounds.unwrap_or_default()));
let blend = (s.blend != BlendMode::Normal).then(|| ("mix-blend-mode", blend_css(s.blend).to_string()));
let opacity = (s.opacity < 1.0).then(|| ("opacity", fmt_num(s.opacity as f64, 3)));
match (&w.shape, pixel_gradient(&s.paint).zip(paint_bounds(s))) {
match (&w.shape, pixel_gradient(&s.paint, false).zip(paint_bounds(s))) {
(WrittenShape::Stroke { width }, Some((g, b))) => {
// The stroke's outline clips the gradient's image.
let bp = bp.get_or_insert_with(|| doc_path(paths));
@@ -1776,6 +1776,12 @@ impl Writer<'_> {
// Character paints resolve in the text's space (`to_user` maps it into user space).
let (bounds, to_user) = space;
let saved = std::mem::replace(&mut self.xf, to_user);
if let Paint::Gradient(g) = &st.fill
&& g.gradient.dither
&& g.gradient.kind != GradientKind::Freeform
{
self.warn("dithered gradients on characters are written without dither");
}
let fill = self.paint(&st.fill, Some(bounds));
p.push(("fill", fill));
// An inside or outside stroke is drawn on the glyph outlines instead
@@ -2269,11 +2275,11 @@ const OUTLINE_TOLERANCE: f64 = 0.01;
/// Most pixels along a side of a linear or radial gradient's image.
const MAX_GRADIENT_PX: f64 = 4096.0;
/// The gradient a paint is, if it is drawn as pixels ([`GradientPaint::draws_as_pixels`]; SVG
/// has no freeform gradients, for one).
fn pixel_gradient(p: &Paint) -> Option<&GradientPaint> {
/// The gradient a fill (`fill`) or stroke paint is, if it is drawn as pixels
/// ([`GradientPaint::draws_as_pixels`]; SVG has no freeform gradients, for one).
fn pixel_gradient(p: &Paint, fill: bool) -> Option<&GradientPaint> {
match p {
Paint::Gradient(g) if g.draws_as_pixels() => Some(g),
Paint::Gradient(g) if g.draws_as_pixels(fill) => Some(g),
_ => None,
}
}
+5
View File
@@ -32,6 +32,11 @@
//! their colour field (sampled as the canvas samples it) clipped to the shape or the stroke's
//! outline; on characters they are written as linear gradients. Gradients along or across
//! strokes are written as slices of linear gradients clipped to the stroke.
//! * **Dithered gradient fills** are written as an `<image>` of the gradient with its noise, at
//! the document's raster effects resolution, clipped to the shape: Illustrator's documentation
//! describes dither as the gradient converted to pixels with subtle noise
//! (<https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-gradients/control-dither-in-gradients.html>).
//! On characters the gradient stays a vector gradient, without dither (with a warning).
//! * **Symbol instances** the shared `<symbol>` would paint differently from the canvas (stained,
//! scaled with strokes, turned or scaled with effects, brushes or live objects, or holding
//! pattern paints or unlinked masks, which stay on the page) are written as their own art.
+39
View File
@@ -104,3 +104,42 @@ fn linked_images_write_the_gradient_next_to_the_svg_and_characters_stay_linear()
assert!(o.svg.contains("linearGradient") && !o.svg.contains("<image"), "{}", o.svg);
assert!(o.warnings.iter().any(|w| w.contains("characters")), "{:?}", o.warnings);
}
/// A shallow grey ramp, dithered or not.
fn shallow(dither: bool) -> Paint {
let stops = vec![
vectorcraft_color::GradientStop::new(0.0, Color::rgb8(0x40, 0x40, 0x40)),
vectorcraft_color::GradientStop::new(1.0, Color::rgb8(0x4a, 0x4a, 0x4a)),
];
let mut g = Gradient::new(GradientKind::Linear, stops);
g.dither = dither;
Paint::Gradient(Box::new(GradientPaint::new(g)))
}
/// Illustrator's documentation describes dither as the gradient converted to pixels with subtle
/// noise, at the Document Raster Effects resolution
/// (https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-gradients/control-dither-in-gradients.html):
/// a dithered fill is written as an image of the gradient with its noise, clipped to the shape; a
/// stroke (dither applies to fills only) and an undithered fill stay vector gradients.
#[test]
fn a_dithered_fill_is_an_image_at_the_raster_effects_resolution() {
let shape = shapes::rectangle(Rect::new(20.0, 20.0, 180.0, 140.0));
let mut d = doc_with(Node::path(NodeId(0), shape.clone(), Appearance::basic(shallow(true), shallow(true), 4.0)));
d.raster_effects_ppi = 144.0;
let o = export_full(&d, &ExportOptions::default(), None);
let svg = &o.svg;
assert_eq!(svg.matches("<image ").count(), 1, "the fill: {svg}");
assert_eq!(svg.matches("<linearGradient").count(), 1, "the stroke keeps its gradient: {svg}");
assert!(o.warnings.iter().any(|w| w.contains("drawn as pixels are written as images")), "{:?}", o.warnings);
// Two pixels a point at 144 ppi, the levels around the ramp's, with noise along each row.
let png = png_of(svg);
assert_eq!(png.dimensions(), (320, 240));
let row: Vec<u8> = (0..320).map(|x| png.get_pixel(x, 120).0[0]).collect();
let longest = row.chunk_by(|a, b| a == b).map(<[_]>::len).max().unwrap();
assert!(longest <= 12, "no bands: longest run {longest}");
assert!(row.iter().all(|v| (0x3f..=0x4b).contains(v)), "{row:?}");
// Undithered: a vector gradient, no image.
let plain = doc_with(Node::path(NodeId(0), shape, Appearance::basic(shallow(false), Paint::None, 0.0)));
let svg = export_full(&plain, &ExportOptions::default(), None).svg;
assert!(!svg.contains("<image ") && svg.contains("<linearGradient"), "{svg}");
}
+3
View File
@@ -69,6 +69,9 @@ pub fn gradient_params(g: &GradientPaint) -> Value {
if let Some(f) = &g.freeform {
v["freeform"] = freeform_json(f);
}
if g.gradient.dither {
v["dither"] = json!(true);
}
v
}
+4
View File
@@ -5382,3 +5382,7 @@ axis Y Axis Y ос
Packaged {n} file(s) in {folder} Пакетирани са {n} файла в {folder}
Packaged {n} file(s) {note} Пакетирани са {n} файла {note}
{count} file(s) in {folder}. Show the package folder? {count} файл(а) в {folder}. Показване на папката на пакета?
Dither Дитъринг
Fine noise hides banding in the gradient fill Фин шум скрива ивиците в градиентната запълка
@msg `dither` applies to gradient fills (stroke: false) `dither` се прилага към градиентни запълки (stroke: false)
@msg `dither` must be true or false `dither` трябва да бъде true или false
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -5427,3 +5427,7 @@ axis Y Axis osa Y
Packaged {n} file(s) in {folder} Zabaleno souborů: {n} do {folder}
Packaged {n} file(s) {note} Zabaleno souborů: {n} {note}
{count} file(s) in {folder}. Show the package folder? Souborů: {count} v {folder}. Zobrazit složku balíčku?
Dither Rozptýlení
Fine noise hides banding in the gradient fill Jemný šum skryje pruhy ve výplni přechodem
@msg `dither` applies to gradient fills (stroke: false) `dither` platí pro výplně přechodem (stroke: false)
@msg `dither` must be true or false `dither` musí být true nebo false
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -5398,3 +5398,7 @@ axis Y Axis Y-akse
Packaged {n} file(s) in {folder} Pakkede {n} fil(er) i {folder}
Packaged {n} file(s) {note} Pakkede {n} fil(er) {note}
{count} file(s) in {folder}. Show the package folder? {count} fil(er) i {folder}. Vis pakkemappen?
Dither Dither
Fine noise hides banding in the gradient fill Fin støj skjuler striber i gradientfyldet
@msg `dither` applies to gradient fills (stroke: false) `dither` gælder for gradientfyld (stroke: false)
@msg `dither` must be true or false `dither` skal være true eller false
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -5009,3 +5009,7 @@ axis Y Axis Y-Achse
Packaged {n} file(s) in {folder} {n} Datei(en) in {folder} verpackt
Packaged {n} file(s) {note} {n} Datei(en) verpackt {note}
{count} file(s) in {folder}. Show the package folder? {count} Datei(en) in {folder}. Paketordner anzeigen?
Dither Dithering
Fine noise hides banding in the gradient fill Feines Rauschen verbirgt Streifen in der Verlaufsfläche
@msg `dither` applies to gradient fills (stroke: false) `dither` gilt für Verlaufsflächen (stroke: false)
@msg `dither` must be true or false `dither` muss true oder false sein
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -5379,3 +5379,7 @@ axis Y Axis Άξονας Y
Packaged {n} file(s) in {folder} Συσκευάστηκαν {n} αρχεία στον φάκελο {folder}
Packaged {n} file(s) {note} Συσκευάστηκαν {n} αρχεία {note}
{count} file(s) in {folder}. Show the package folder? {count} αρχεία στον φάκελο {folder}. Να εμφανιστεί ο φάκελος συσκευασίας;
Dither Πρόσθεση θορύβου
Fine noise hides banding in the gradient fill Ο λεπτός θόρυβος κρύβει τις λωρίδες στο γέμισμα ντεγκραντέ
@msg `dither` applies to gradient fills (stroke: false) Το `dither` ισχύει για γεμίσματα ντεγκραντέ (stroke: false)
@msg `dither` must be true or false Το `dither` πρέπει να είναι true ή false
Can't render this file because it contains an unexpected character in line 2045 and column 19.
+4
View File
@@ -5369,3 +5369,7 @@ axis Y Axis Eje Y
Packaged {n} file(s) in {folder} Se empaquetaron {n} archivo(s) en {folder}
Packaged {n} file(s) {note} Se empaquetaron {n} archivo(s) {note}
{count} file(s) in {folder}. Show the package folder? {count} archivo(s) en {folder}. ¿Mostrar la carpeta del paquete?
Dither Tramado
Fine noise hides banding in the gradient fill Un ruido fino oculta las bandas del relleno de degradado
@msg `dither` applies to gradient fills (stroke: false) `dither` se aplica a rellenos de degradado (stroke: false)
@msg `dither` must be true or false `dither` debe ser true o false
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -5458,3 +5458,7 @@ axis Y Axis Y-akseli
Packaged {n} file(s) in {folder} Paketoitiin {n} tiedosto(a) kansioon {folder}
Packaged {n} file(s) {note} Paketoitiin {n} tiedosto(a) {note}
{count} file(s) in {folder}. Show the package folder? {count} tiedosto(a) kansiossa {folder}. Näytetäänkö pakettikansio?
Dither Rasterointi
Fine noise hides banding in the gradient fill Hieno kohina piilottaa raidat liukuväritäytöstä
@msg `dither` applies to gradient fills (stroke: false) `dither` koskee liukuväritäyttöjä (stroke: false)
@msg `dither` must be true or false `dither` on oltava true tai false
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -5426,3 +5426,7 @@ axis Y Axis Axe Y
Packaged {n} file(s) in {folder} {n} fichier(s) assemblé(s) dans {folder}
Packaged {n} file(s) {note} {n} fichier(s) assemblé(s) {note}
{count} file(s) in {folder}. Show the package folder? {count} fichier(s) dans {folder}. Afficher le dossier de l’assemblage ?
Dither Simulation
Fine noise hides banding in the gradient fill Un bruit fin masque les bandes du fond en dégradé
@msg `dither` applies to gradient fills (stroke: false) `dither` s’applique aux fonds en dégradé (stroke: false)
@msg `dither` must be true or false `dither` doit valoir true ou false
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -5413,3 +5413,7 @@ axis Y Axis Y tengely
Packaged {n} file(s) in {folder} Becsomagolt fájlok: {n}, mappa: {folder}
Packaged {n} file(s) {note} Becsomagolt fájlok: {n} {note}
{count} file(s) in {folder}. Show the package folder? Fájlok: {count}, mappa: {folder}. Megnyitod a csomag mappáját?
Dither Árnyalatkeverés
Fine noise hides banding in the gradient fill A finom zaj elrejti a sávokat a színátmenetes kitöltésben
@msg `dither` applies to gradient fills (stroke: false) A `dither` színátmenetes kitöltésekre vonatkozik (stroke: false)
@msg `dither` must be true or false `dither`: true vagy false kell
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -5398,3 +5398,7 @@ axis Y Axis Sumbu Y
Packaged {n} file(s) in {folder} Berhasil mengemas {n} file di {folder}
Packaged {n} file(s) {note} Berhasil mengemas {n} file {note}
{count} file(s) in {folder}. Show the package folder? {count} file dalam {folder}. Tampilkan folder paket?
Dither Dither
Fine noise hides banding in the gradient fill Derau halus menyembunyikan pita warna pada isian gradien
@msg `dither` applies to gradient fills (stroke: false) `dither` berlaku untuk isian gradien (stroke: false)
@msg `dither` must be true or false `dither` harus true atau false
Can't render this file because it contains an unexpected character in line 3816 and column 19.
+4
View File
@@ -5043,3 +5043,7 @@ axis Y Axis Asse Y
Packaged {n} file(s) in {folder} Pacchetto creato con {n} file in {folder}
Packaged {n} file(s) {note} Pacchetto creato con {n} file {note}
{count} file(s) in {folder}. Show the package folder? {count} file in {folder}. Vuoi mostrare la cartella del pacchetto?
Dither Dithering
Fine noise hides banding in the gradient fill Un rumore fine nasconde le bande nel riempimento sfumato
@msg `dither` applies to gradient fills (stroke: false) `dither` si applica ai riempimenti sfumati (stroke: false)
@msg `dither` must be true or false `dither` deve essere true o false
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -5105,3 +5105,7 @@ axis Y Axis Y 軸
Packaged {n} file(s) in {folder} {n} 個のファイルを {folder} にパッケージしました
Packaged {n} file(s) {note} {n} 個のファイルをパッケージしました {note}
{count} file(s) in {folder}. Show the package folder? {folder} に {count} 個のファイル。パッケージフォルダーを表示しますか?
Dither ディザ
Fine noise hides banding in the gradient fill 細かいノイズでグラデーションの塗りの縞を目立たなくします
@msg `dither` applies to gradient fills (stroke: false) `dither` はグラデーションの塗りに適用されます (stroke: false)
@msg `dither` must be true or false `dither` には true または false を指定してください
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -5343,3 +5343,7 @@ axis Y Axis Y ღერძი
@msg this kind of brush has no options dialog yet ამ ტიპის ფუნჯს პარამეტრების დიალოგი ჯერ არ აქვს
Drag art, colors and text styles here to reuse them across documents. Libraries are stored on this machine — no account required. გადმოათრიეთ აქ გრაფიკა, ფერები და ტექსტის სტილები, რომ სხვა დოკუმენტებშიც გამოიყენოთ. ბიბლიოთეკები ამ კომპიუტერზე ინახება — ანგარიში არ არის საჭირო.
Choose a filter ფილტრის არჩევა
Dither დიზერინგი
Fine noise hides banding in the gradient fill წვრილი ხმაური მალავს ზოლებს გრადიენტულ შევსებაში
@msg `dither` applies to gradient fills (stroke: false) `dither` ვრცელდება გრადიენტულ შევსებებზე (stroke: false)
@msg `dither` must be true or false `dither` უნდა იყოს true ან false
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -5006,3 +5006,7 @@ axis Y Axis Y축
Packaged {n} file(s) in {folder} {folder}에 파일 {n}개를 패키지했습니다
Packaged {n} file(s) {note} 파일 {n}개를 패키지했습니다 {note}
{count} file(s) in {folder}. Show the package folder? {folder}에 파일 {count}개. 패키지 폴더를 표시하시겠습니까?
Dither 디더
Fine noise hides banding in the gradient fill 미세한 노이즈로 그레이디언트 칠의 띠를 감춥니다
@msg `dither` applies to gradient fills (stroke: false) `dither`는 그레이디언트 칠에 적용됩니다 (stroke: false)
@msg `dither` must be true or false `dither`은(는) true 또는 false여야 합니다
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -5441,3 +5441,7 @@ axis Y Axis Y-as
Packaged {n} file(s) in {folder} {n} bestand(en) verpakt in {folder}
Packaged {n} file(s) {note} {n} bestand(en) verpakt {note}
{count} file(s) in {folder}. Show the package folder? {count} bestand(en) in {folder}. De pakketmap openen?
Dither Dither
Fine noise hides banding in the gradient fill Fijne ruis verbergt banden in de verloopvulling
@msg `dither` applies to gradient fills (stroke: false) `dither` geldt voor verloopvullingen (stroke: false)
@msg `dither` must be true or false `dither` moet true of false zijn
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -5279,3 +5279,7 @@ axis Y Axis Oś Y
Packaged {n} file(s) in {folder} Spakowane pliki: {n} w {folder}
Packaged {n} file(s) {note} Spakowane pliki: {n} {note}
{count} file(s) in {folder}. Show the package folder? Pliki: {count} w {folder}. Pokazać folder pakietu?
Dither Rozpraszanie
Fine noise hides banding in the gradient fill Drobny szum ukrywa pasy w wypełnieniu gradientem
@msg `dither` applies to gradient fills (stroke: false) `dither` dotyczy wypełnień gradientem (stroke: false)
@msg `dither` must be true or false `dither` musi mieć wartość true lub false
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -5344,3 +5344,7 @@ axis Y Axis Eixo Y
Packaged {n} file(s) in {folder} {n} arquivo(s) empacotado(s) em {folder}
Packaged {n} file(s) {note} {n} arquivo(s) empacotado(s) {note}
{count} file(s) in {folder}. Show the package folder? {count} arquivo(s) em {folder}. Mostrar a pasta do pacote?
Dither Dither
Fine noise hides banding in the gradient fill Um ruído fino esconde as faixas do preenchimento em gradiente
@msg `dither` applies to gradient fills (stroke: false) `dither` se aplica a preenchimentos em gradiente (stroke: false)
@msg `dither` must be true or false `dither` deve ser true ou false
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -5039,3 +5039,7 @@ axis Y Axis Ось Y
Packaged {n} file(s) in {folder} Упаковано файлов: {n} в {folder}
Packaged {n} file(s) {note} Упаковано файлов: {n} {note}
{count} file(s) in {folder}. Show the package folder? Файлов: {count} в {folder}. Показать папку пакета?
Dither Дизеринг
Fine noise hides banding in the gradient fill Мелкий шум скрывает полосы в градиентной заливке
@msg `dither` applies to gradient fills (stroke: false) `dither` применяется к градиентным заливкам (stroke: false)
@msg `dither` must be true or false `dither` должно быть true или false
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -5383,3 +5383,7 @@ axis Y Axis Y-axel
Packaged {n} file(s) in {folder} Paketerade {n} fil(er) i {folder}
Packaged {n} file(s) {note} Paketerade {n} fil(er) {note}
{count} file(s) in {folder}. Show the package folder? {count} fil(er) i {folder}. Visa paketmappen?
Dither Dithering
Fine noise hides banding in the gradient fill Fint brus döljer bandningen i övertoningsfyllningen
@msg `dither` applies to gradient fills (stroke: false) `dither` gäller övertoningsfyllningar (stroke: false)
@msg `dither` must be true or false `dither` måste vara true eller false
Can't render this file because it contains an unexpected character in line 5 and column 25.
+4
View File
@@ -5391,3 +5391,7 @@ axis Y Axis Y ekseni
Packaged {n} file(s) in {folder} {folder} biçiminde paketlenmiş {n} dosyası/dosyaları
Packaged {n} file(s) {note} Paketlenmiş {n} dosyası/dosyaları {note}
{count} file(s) in {folder}. Show the package folder? {folder} içindeki {count} dosyası/dosyaları. Paket klasörü gösterilsin mi?
Dither Titreme
Fine noise hides banding in the gradient fill İnce gürültü, degrade dolgudaki bantları gizler
@msg `dither` applies to gradient fills (stroke: false) `dither` degrade dolgulara uygulanır (stroke: false)
@msg `dither` must be true or false "dither" doğru veya yanlış olmalıdır
Can't render this file because it contains an unexpected character in line 5 and column 25.
+4
View File
@@ -5023,3 +5023,7 @@ axis Y Axis Вісь Y
Packaged {n} file(s) in {folder} Упаковано файли: {n} {folder}
Packaged {n} file(s) {note} Упаковано файли: {n} {note}
{count} file(s) in {folder}. Show the package folder? файлів: {count} {folder}. Показати папку пакета?
Dither Дизеринг
Fine noise hides banding in the gradient fill Дрібний шум приховує смуги в градієнтній заливці
@msg `dither` applies to gradient fills (stroke: false) `dither` застосовується до градієнтних заливок (stroke: false)
@msg `dither` must be true or false `dither` має бути true або false
Can't render this file because it contains an unexpected character in line 733 and column 40.
+4
View File
@@ -5023,3 +5023,7 @@ axis Y Axis Trục Y
Packaged {n} file(s) in {folder} Đã đóng gói {n} tệp trong {folder}
Packaged {n} file(s) {note} Đã đóng gói {n} tệp {note}
{count} file(s) in {folder}. Show the package folder? {count} tệp trong {folder}. Hiện thư mục gói?
Dither Dither
Fine noise hides banding in the gradient fill Nhiễu mịn che dải màu trong vùng tô chuyển sắc
@msg `dither` applies to gradient fills (stroke: false) `dither` áp dụng cho vùng tô chuyển sắc (stroke: false)
@msg `dither` must be true or false `dither` phải là true hoặc false
Can't render this file because it contains an unexpected character in line 4028 and column 10.
+4
View File
@@ -5267,3 +5267,7 @@ axis Y Axis Y 轴
Packaged {n} file(s) in {folder} 已将 {n} 个文件打包到 {folder}
Packaged {n} file(s) {note} 已打包 {n} 个文件 {note}
{count} file(s) in {folder}. Show the package folder? {folder} 中有 {count} 个文件。要显示打包文件夹吗?
Dither 仿色
Fine noise hides banding in the gradient fill 细微杂色可隐藏渐变填色中的色带
@msg `dither` applies to gradient fills (stroke: false) `dither` 适用于渐变填色 (stroke: false)
@msg `dither` must be true or false `dither` 必须是 true 或 false
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -5062,3 +5062,7 @@ axis Y Axis Y 軸
Packaged {n} file(s) in {folder} 已將 {n} 個檔案封裝到 {folder}
Packaged {n} file(s) {note} 已封裝 {n} 個檔案 {note}
{count} file(s) in {folder}. Show the package folder? {folder} 中有 {count} 個檔案。要顯示封裝資料夾嗎?
Dither 仿色
Fine noise hides banding in the gradient fill 細微雜色可隱藏漸層填色中的色帶
@msg `dither` applies to gradient fills (stroke: false) `dither` 適用於漸層填色 (stroke: false)
@msg `dither` must be true or false `dither` 必須是 true 或 false
Can't render this file because it contains an unexpected character in line 6 and column 47.
+31
View File
@@ -322,6 +322,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
ramp(app, ui, &g.gradient, is_grad);
ui.add_space(4.0);
stop_fields(app, ui, &g.gradient, is_grad);
// Dither applies to gradient fills only: off and dimmed behind the Stroke proxy.
ui.add_space(4.0);
let fill = app.session.fill_active;
let dither = is_grad && fill && g.gradient.dither;
if widgets::check_tip(ui, tl!("Dither"), dither, is_grad && fill, tl!("Fine noise hides banding in the gradient fill")) {
edit(app, json!({"dither": !dither}), Live::Released);
}
if !is_grad {
widgets::dim_label(ui, tl!("Click the ramp or a type button to apply a gradient."));
}
@@ -968,6 +975,30 @@ mod tests {
assert_eq!(mode(&app), StrokeGradientMode::Across, "one undo step per click");
}
#[test]
fn the_dither_box_dithers_the_gradient_fill_and_is_off_for_strokes() {
let mut app = app(json!({"gradient": {}}));
app.run("paint.setStroke", json!({"gradient": {}, "focus": false})).unwrap();
let mut p = Panel::new();
let texts = p.frame(&mut app, vec![], Modifiers::NONE);
let at = texts.iter().find(|(t, _)| t == "Dither").expect("the Dither box").1;
p.click(&mut app, at);
assert!(fill(&app).gradient.dither, "the fill's gradient is dithered");
p.click(&mut app, at);
assert!(!fill(&app).gradient.dither, "a second click clears it");
app.run("edit.undo", json!({})).unwrap();
assert!(fill(&app).gradient.dither, "one undo step per click");
// Behind the Stroke proxy the box is dimmed and does nothing.
let stroke = p.widget("stroke-proxy");
p.click(&mut app, stroke.right_bottom() - vec2(2.0, 2.0));
assert!(!app.session.fill_active);
let undo = app.session.doc().unwrap().history.undo.len();
p.click(&mut app, at);
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo, "no edit on the stroke");
let Paint::Gradient(st) = app.session.shown_stroke().unwrap().paint.clone() else { panic!("a gradient stroke") };
assert!(!st.gradient.dither);
}
#[test]
fn a_selected_diamond_shows_and_sets_its_midpoint_in_location() {
let stops = json!([{"offset": 0, "color": "#ffffff", "midpoint": 0.3}, {"offset": 1, "color": "#000000"}]);
+10 -4
View File
File diff suppressed because one or more lines are too long
+8
View File
@@ -782,6 +782,14 @@ radial shadings:
{"name":"run_command","arguments":{"command":"paint.setGradientGeom","params":{"aspect":60,"focal":[130,140]}}}
```
Dither (the Gradient panel's box) draws a linear or radial gradient fill with fine noise that hides banding;
SVG and PDF exports write such a fill as an image at the Document Raster Effects resolution. It applies to fills
(`stroke: true` is refused):
```json
{"name":"run_command","arguments":{"command":"paint.editGradient","params":{"dither":true}}}
```
Freeform gradients: `paint.editGradient {kind: "freeform"}` places four or more colour points inside each selected
object (coloured along the stops; `mode: "points"|"lines"` is the Draw toggle). `paint.freeform.get` lists the points
(document coordinates), lines and the selected point; `paint.freeform.addPoint {at, color?, opacity?, spread?,
+1 -1
View File
@@ -198,7 +198,7 @@ type and `blob` but without their data, which follows the JSON in a saved file;
`paint`, `width` in points, `join` (`"miter"`, `"round"` or `"bevel"`), `cap` (`"butt"`, `"round"` or `"square"`)
and `miterLimit`. Paints use the
native file format's form: `{"type": "solid", "color", "swatch"?, "tint"?}`, `{"type": "gradient", "gradient":
{"kind": "Linear"|"Radial"|"Freeform", "stops": [{offset, color, opacity, midpoint}]}, "geom"?, "angle",
{"kind": "Linear"|"Radial"|"Freeform", "stops": [{offset, color, opacity, midpoint}], "dither"?}, "geom"?, "angle",
"freeform"?}`, `{"type": "pattern", "pattern", "xf"?}`. Colours are `{"model": "rgb", r, g, b}`, `{"model": "cmyk",
c, m, y, k}`, `{"model": "gray", k}` (ink: 0 white, 1 black) or `{"model": "lab", l, a, b}`; components 0–1 except
Lab's.
File diff suppressed because one or more lines are too long
+3 -1
View File
File diff suppressed because one or more lines are too long