From 5f77b65aa2b19f40728343e3fe561b1a615c451d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 06:05:41 -0800 Subject: [PATCH] Gradients: Hue Clockwise and Hue Counterclockwise mix stops around the hue wheel The Gradient panel's Method menu gets two methods of VectorCraft's own beside Classic and Perceptual. They mix a linear, radial, conic or diamond gradient's colors around the hue wheel: hue, saturation and brightness each change evenly from one stop to the next, and the hue angle keeps one direction, as the fixed-direction rules of https://www.w3.org/TR/css-color-4/#hue-interpolation do. Hue Clockwise lowers the angle (red, magenta, blue), which is clockwise on the color wheels, and Hue Counterclockwise raises it (red, yellow, green). Two stops whose hues are less than half a color level apart count as one hue and keep it, so a color and a tint or shade of it do not turn the whole wheel. A stop whose RGB numbers are less than half a color level apart is gray and takes the hue of the stop it mixes with, and black takes that stop's saturation too. - `paint.editGradient` and `paint.setFill` take `method: hue-clockwise` or `hue-counterclockwise`, in any case, with a space or an underscore for the hyphen or without it; other spellings are an error. The native file writes `HueClockwise` and `HueCounterclockwise`. - The canvas and raster exports draw the gradient through stops on its curve, within half a color level. - Reverse turns the method the other way with the stops, and a Reflect spread is repeated from the curve's stops, so both keep the colors. - SVG and PDF write the gradient as an RGB image, CMYK and spot color stops included, with the spot warning. EPS writes a shading in process colors with a warning for spot stops. A legacy .ai file and the .ai editing data get the colors between the stops as stops of their own, in process colors, at most 256, with a warning. CSS gets those stops too. - The `method` error names the four values, in every catalog. - The Method menu is as wide as its longest name in the interface language, within the panel. Docs: docs/mcp.md, docs/plugins.md, docs/target-app-parity.md, ROADMAP. --- ROADMAP.md | 1 + crates/color/src/gradient.rs | 160 +++++++++-- crates/color/src/gradient/spread.rs | 16 ++ crates/color/src/gradient/spread/tests.rs | 40 +++ crates/color/src/gradient/tests.rs | 277 ++++++++++++++++++++ crates/engine/src/cmd/expand.rs | 5 +- crates/engine/src/cmd/gradient.rs | 8 +- crates/engine/src/cmd/gradpresets.rs | 2 +- crates/engine/src/cmd/paint.rs | 2 +- crates/engine/src/cmd/swatch.rs | 2 + crates/engine/src/tests/css.rs | 22 ++ crates/engine/src/tests/gradient.rs | 74 +++++- crates/engine/src/tests/linked_stops.rs | 28 ++ crates/eps/src/ai_editing/tests.rs | 28 ++ crates/eps/src/ai_editing/writer.rs | 2 +- crates/eps/src/ai_editing/writer/paints.rs | 34 ++- crates/eps/src/legacy_ai.rs | 1 + crates/eps/src/legacy_ai/tests.rs | 40 +++ crates/eps/src/legacy_ai/writer.rs | 12 +- crates/eps/src/scene.rs | 15 +- crates/eps/src/tests.rs | 47 ++++ crates/pdf/src/export/paint.rs | 12 +- crates/pdf/src/lib.rs | 2 +- crates/pdf/src/tests/cmykimages.rs | 20 ++ crates/svg/src/export/paint.rs | 4 +- crates/svg/src/lib.rs | 4 + crates/svg/tests/it/raster_paints.rs | 33 +++ crates/tools/src/params.rs | 2 +- crates/ui-egui/src/i18n/bg.tsv | 4 +- crates/ui-egui/src/i18n/bs.tsv | 4 +- crates/ui-egui/src/i18n/ca.tsv | 4 +- crates/ui-egui/src/i18n/cs.tsv | 4 +- crates/ui-egui/src/i18n/da.tsv | 4 +- crates/ui-egui/src/i18n/de.tsv | 4 +- crates/ui-egui/src/i18n/el.tsv | 4 +- crates/ui-egui/src/i18n/es.tsv | 4 +- crates/ui-egui/src/i18n/fi.tsv | 4 +- crates/ui-egui/src/i18n/fr.tsv | 4 +- crates/ui-egui/src/i18n/hi.tsv | 4 +- crates/ui-egui/src/i18n/hu.tsv | 4 +- crates/ui-egui/src/i18n/id.tsv | 4 +- crates/ui-egui/src/i18n/it.tsv | 4 +- crates/ui-egui/src/i18n/ja.tsv | 4 +- crates/ui-egui/src/i18n/ka.tsv | 4 +- crates/ui-egui/src/i18n/ko.tsv | 4 +- crates/ui-egui/src/i18n/nl.tsv | 4 +- crates/ui-egui/src/i18n/pl.tsv | 4 +- crates/ui-egui/src/i18n/pt-br.tsv | 4 +- crates/ui-egui/src/i18n/ru.tsv | 4 +- crates/ui-egui/src/i18n/sv.tsv | 4 +- crates/ui-egui/src/i18n/tr.tsv | 4 +- crates/ui-egui/src/i18n/uk.tsv | 4 +- crates/ui-egui/src/i18n/vi.tsv | 4 +- crates/ui-egui/src/i18n/zh-hans.tsv | 4 +- crates/ui-egui/src/i18n/zh-hant.tsv | 4 +- crates/ui-egui/src/panels/gradient.rs | 23 +- crates/ui-egui/src/panels/gradient/tests.rs | 25 ++ docs/mcp.md | 22 +- docs/plugins.md | 2 +- docs/target-app-parity.md | 1 + 60 files changed, 987 insertions(+), 87 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b5a2f43c7a..fe325ff849 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -141,6 +141,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap - 2026-10-31: Export for Screens works as the reference app's dialog: Artboards and Assets tabs with a grid or list of thumbnails, Clear Selection and Prefix, Create Sub-folders by Scale or Format (on by default), Export PDFs As Single File (the chosen artboards as one PDF's pages, `pdfs: "single"`) or Multiple Files, iOS and Android rows, format presets in a ☰ menu, and a count of the files the export writes (#4895). About 55 community PRs and fixes landed the same day: Find Font, Replace Font, Outline Stroke and Expand leave hidden and locked art alone (with a warning); DXF export writes LINE and ELLIPSE entities and outlines only the strokes a CAD line can't draw; Blob Brush Angle and Roundness; Shift-constrained Perspective Selection; Grid Repeat Options Preview; a gradient mesh from a file budgeted at about a million quads; width-profile strokes keep their shape zoomed out; `.ai`/EPS import fidelity (area type, envelopes, transparency in dictionaries, character styles); PDF import finds hyphenated and CMap-suffixed font names; Windows renders follow the process's core affinity; Enter or Escape in a floating panel's field no longer reaches the canvas (#4803); Properties shows Blend Options for any selected blend (#4817); Artwork Cleanup finds doubled type (#4835) and joins stacked lines into one object (Join Point Text, #4826). Headline numbers unchanged. - 2026-10-31: Arrange Tools, a built-in add-on (Object › Plug-ins › Arrange Tools): Arrange in Grid lays the selected objects out in rows and columns, a row or a column with gaps, from the selection's top-left or centred on the artboard; Swap Objects swaps two objects' places (and, with Swap Objects and Sizes, their sizes); Replace with Top Object puts a copy of the topmost selected object in place of each of the others, kept, stretched or fitted in proportion; Set Size of Each gives every selected object an exact width and/or height about a reference point; Zoom to Selection fits the view to the selection. Each is one undo step with a previewed dialog where it has options. A VectorCraft addition: headline numbers unchanged. - 2026-11-11: Window › Swatch Libraries › Gradients › Organic Fades: fifteen soft fades (ten linear, five radial) through three to five unevenly spaced stops with off-center midpoints, mixed with the Perceptual method, that thin out to transparent at the end of a linear one and at the edge of a radial one; they also list in the Swatches panel's library menu, `swatch.library.list` and the Gradient panel's Presets. +- 2026-11-13: Gradient panel › Method: Hue Clockwise and Hue Counterclockwise mix a linear, radial, conic or diamond gradient's colors around the hue wheel in one direction, so red to blue passes through magenta one way and through yellow and green the other (`paint.editGradient` `method`: `hue-clockwise`, `hue-counterclockwise`); SVG and PDF exports write such a gradient as an image. - 2026-11-11: Window › Swatch Libraries › Gradients › Holographic: seven foils (opaque linear gradients of six narrow pastel and pearl bands with off-center midpoints) and seven glows (radial gradients from a bright center out to a clear edge), mixed with the Perceptual method; they also list in the Swatches panel's library menu, `swatch.library.list` and the Gradient panel's Presets. - 2026-10-30: Gradient panel presets: a Presets menu applies one of the built-in gradient libraries' presets (grouped by theme) or a saved one to the gradient behind the active proxy, keeping its placement, as one undo step; the panel menu saves the current gradient as a preset, kept with the preferences, and deletes saved ones (`gradient.presets.list/apply/save/delete`). - 2026-10-23: Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add a thin overprinting strip of the lighter colour inside the darker one where two solid colours meet, so misregistered plates leave no gap ([trapping](https://helpx.adobe.com/illustrator/using/trapping.html)), with Thickness, Height/Width, Tint Reduction, Traps with Process Color and Reverse Traps. Illustrator effects 49/50 (SVG Filters remains); Live vector effects hours 6–10 → 4–8; headline numbers unchanged. diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index e49478eab7..54cb50c197 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -65,24 +65,62 @@ pub enum GradientMethod { /// Mixed in a perceptual colour space (OKLab), so the steps look even to the eye: blue to /// yellow passes through a light blue-grey rather than a dull mid grey. Perceptual, + /// Mixed around the hue wheel, clockwise as the color wheels draw it: the hue angle falls + /// from each stop to the next (red, magenta, blue, cyan, green, yellow, red), and saturation + /// and brightness change evenly ([`hue_mix`]). + HueClockwise, + /// Mixed around the hue wheel the other way: the hue angle rises from each stop to the next + /// (red, yellow, green, cyan, blue, magenta, red). + HueCounterclockwise, } impl GradientMethod { - /// Parse a method name (`classic`, `perceptual`; any case). + /// Every method, in the order the Gradient panel lists them. + pub const ALL: [GradientMethod; 4] = [Self::Classic, Self::Perceptual, Self::HueClockwise, Self::HueCounterclockwise]; + /// Parse a method name (`classic`, `perceptual`, `hue-clockwise`, `hue-counterclockwise`; + /// any case, and the hyphen may be a space or an underscore, or left out). Any other text, + /// one with a separator elsewhere included, is `None`. pub fn parse(s: &str) -> Option { - [Self::Classic, Self::Perceptual].into_iter().find(|m| m.label().eq_ignore_ascii_case(s)) + let name = s.to_ascii_lowercase(); + Self::ALL.into_iter().find(|m| ["-", " ", "_", ""].iter().any(|sep| m.id().replace('-', sep) == name)) } pub fn label(self) -> &'static str { match self { Self::Classic => "Classic", Self::Perceptual => "Perceptual", + Self::HueClockwise => "Hue Clockwise", + Self::HueCounterclockwise => "Hue Counterclockwise", + } + } + /// The method's name in command params (`classic`, `perceptual`, `hue-clockwise`, + /// `hue-counterclockwise`). + pub fn id(self) -> &'static str { + match self { + Self::Classic => "classic", + Self::Perceptual => "perceptual", + Self::HueClockwise => "hue-clockwise", + Self::HueCounterclockwise => "hue-counterclockwise", } } pub fn is_classic(&self) -> bool { *self == Self::Classic } - /// `a` and `b` mixed at `t` (0..1): in display RGB (Classic), or in OKLab (Perceptual: an - /// RGB colour between the two). + /// Does it mix around the hue wheel (Hue Clockwise or Hue Counterclockwise)? The colors + /// between two stops then are not mixes of the stops' inks. + pub fn is_hue(&self) -> bool { + matches!(self, Self::HueClockwise | Self::HueCounterclockwise) + } + /// The method that mixes `b` to `a` through the colors this one mixes `a` to `b` through: a + /// hue method turned the other way, Classic and Perceptual as they are. + pub fn mirrored(self) -> Self { + match self { + Self::HueClockwise => Self::HueCounterclockwise, + Self::HueCounterclockwise => Self::HueClockwise, + m => m, + } + } + /// `a` and `b` mixed at `t` (0..1): in display RGB (Classic), in OKLab (Perceptual: an + /// RGB colour between the two), or around the hue wheel ([`hue_mix`]: an RGB colour). pub fn mix(self, a: &Color, b: &Color, t: f32) -> Color { match self { Self::Classic => Color::lerp(a, b, t), @@ -93,10 +131,68 @@ impl GradientMethod { let c = |v: f64| if v.is_finite() { v.clamp(0.0, 1.0) as f32 } else { 0.0 }; Color::rgb(c(r), c(g), c(bl)) } + Self::HueClockwise => hue_mix(a, b, t, false), + Self::HueCounterclockwise => hue_mix(a, b, t, true), } } } +/// The brightness (0..1) at or below which a color is black for [`hue_mix`], and the spread +/// between its largest and smallest RGB numbers (saturation × brightness) at or below which it +/// has no hue of its own: under half a color level. +const NO_HUE: f32 = 0.4 / 255.0; + +/// The hue difference (degrees) that moves an RGB number by half a color level in a color whose +/// largest and smallest RGB numbers are 1 apart; a color with a smaller spread takes +/// proportionally more degrees. [`hue_mix`] counts two hues closer than this as one hue. +const SAME_HUE: f32 = 60.0 * 0.5 / 255.0; + +/// `a` and `b` mixed at `t` (0..1) around the hue wheel, as display RGB: hue (degrees), +/// saturation and brightness each change evenly from `a`'s to `b`'s. The hue angle only rises +/// (`rising`) or only falls, by the fixed-direction rules of +/// , and two equal hues stay where they are. +/// Two hues that differ by less than half a color level ([`SAME_HUE`]) count as equal and take the +/// short way, so a color and a tint or shade of it, whose computed hues differ by rounding, do +/// not turn the whole wheel. A color whose RGB numbers are less than half a color level apart +/// ([`NO_HUE`]) has no hue and takes the other color's; black has no saturation either and takes +/// the other color's too. Two colors without a hue mix as grays. A `t` that is not a number +/// gives `a`. +pub fn hue_mix(a: &Color, b: &Color, t: f32, rising: bool) -> Color { + let t = if t.is_nan() { 0.0 } else { t.clamp(0.0, 1.0) }; + let ([mut h0, mut s0, v0], [mut h1, mut s1, v1]) = (a.to_hsb(), b.to_hsb()); + let (black0, black1) = (v0 <= NO_HUE, v1 <= NO_HUE); + // The spread between each color's own largest and smallest RGB numbers. + let (spread0, spread1) = (s0 * v0, s1 * v1); + // Black takes the other color's saturation, so it darkens that color rather than graying it. + if black0 && !black1 { + s0 = s1; + } else if black1 && !black0 { + s1 = s0; + } + let (gray0, gray1) = (black0 || spread0 <= NO_HUE, black1 || spread1 <= NO_HUE); + match (gray0, gray1) { + (true, _) => h0 = h1, + (false, true) => h1 = h0, + (false, false) => { + // The short way from the first hue to the second, -180..180. + let short = (h1 - h0 + 180.0).rem_euclid(360.0) - 180.0; + h1 = if short.abs() * spread0.min(spread1) <= SAME_HUE { + h0 + short + } else if rising { + h0 + short.rem_euclid(360.0) + } else { + h0 + short.rem_euclid(360.0) - 360.0 + }; + } + } + let at = |p: f32, q: f32| p + (q - p) * t; + let (h, s, v) = (at(h0, h1), at(s0, s1), at(v0, v1)); + if !(h.is_finite() && s.is_finite() && v.is_finite()) { + return Color::rgb(0.0, 0.0, 0.0); + } + Color::from_hsb(h.rem_euclid(360.0), s.clamp(0.0, 1.0), v.clamp(0.0, 1.0)) +} + /// What a linear, radial, conic or diamond gradient paints past the ends of its stops (the /// Gradient panel's Spread). With Repeat and Reflect, the stops run [`Gradient::repeats`] times /// along the vector (around the circle, for a conic), and keep going past both of its ends. @@ -192,10 +288,11 @@ const MAX_INK_TINTS: usize = 4096; const INK_MIX_STEPS: u32 = 64; /// The mix positions (0..1, after `v0`, before `v1`) between which display RGB mixed linearly -/// stays within [`PERCEPTUAL_TOLERANCE`] of the perceptual mix of `a` and `b`: the span halved -/// where a straight line strays, at most [`PERCEPTUAL_DEPTH`] times, in order. -fn perceptual_steps(a: &Color, b: &Color, (v0, v1): (f32, f32), depth: u32, out: &mut Vec) { - let at = |v: f32| GradientMethod::Perceptual.mix(a, b, v).to_rgb(); +/// stays within [`PERCEPTUAL_TOLERANCE`] of `method`'s mix of `a` and `b` (Perceptual, or around +/// the hue wheel): the span halved where a straight line strays, at most [`PERCEPTUAL_DEPTH`] +/// times, in order. +fn perceptual_steps(method: GradientMethod, a: &Color, b: &Color, (v0, v1): (f32, f32), depth: u32, out: &mut Vec) { + let at = |v: f32| method.mix(a, b, v).to_rgb(); let (c0, c1) = (at(v0), at(v1)); let stray = [0.25, 0.5, 0.75] .into_iter() @@ -206,19 +303,20 @@ fn perceptual_steps(a: &Color, b: &Color, (v0, v1): (f32, f32), depth: u32, out: .fold(0.0, f32::max); if depth < PERCEPTUAL_DEPTH && stray > PERCEPTUAL_TOLERANCE { let m = (v0 + v1) / 2.0; - perceptual_steps(a, b, (v0, m), depth + 1, out); + perceptual_steps(method, a, b, (v0, m), depth + 1, out); out.push(m); - perceptual_steps(a, b, (m, v1), depth + 1, out); + perceptual_steps(method, a, b, (m, v1), depth + 1, out); } } -/// The mix positions (`v`, 0..1) of the stops a Perceptual gradient's curve has between stops `a` -/// and `b`, in order: [`perceptual_steps`] on either side of the midpoint, and the midpoint (½). -fn perceptual_mixes(a: &GradientStop, b: &GradientStop) -> Vec { +/// The mix positions (`v`, 0..1) of the stops the curve of a gradient mixed by `method` +/// (Perceptual, or around the hue wheel) has between stops `a` and `b`, in order: +/// [`perceptual_steps`] on either side of the midpoint, and the midpoint (½). +fn perceptual_mixes(method: GradientMethod, a: &GradientStop, b: &GradientStop) -> Vec { let mut vs = Vec::new(); - perceptual_steps(&a.color, &b.color, (0.0, 0.5), 1, &mut vs); + perceptual_steps(method, &a.color, &b.color, (0.0, 0.5), 1, &mut vs); vs.push(0.5); - perceptual_steps(&a.color, &b.color, (0.5, 1.0), 1, &mut vs); + perceptual_steps(method, &a.color, &b.color, (0.5, 1.0), 1, &mut vs); vs } @@ -512,10 +610,11 @@ impl Gradient { }; (t, c, o, Some(s.midpoint), None) }); - // Perceptual: stops along the mix (`v`) where a straight line would stray from it, the - // midpoint (v = ½) among them, placed through the midpoint as [`Self::sample`] maps them. + // Perceptual and the hue methods: stops along the mix (`v`) where a straight line would + // stray from it, the midpoint (v = ½) among them, placed through the midpoint as + // [`Self::sample`] maps them. let perceptual = next.filter(|_| !self.method.is_classic()).map(|n| { - perceptual_mixes(s, n).into_iter().map(move |v| { + perceptual_mixes(self.method, s, n).into_iter().map(move |v| { let mid = v == 0.5; let t = curve_offset(s, n, v); let o = s.opacity + (n.opacity - s.opacity) * v; @@ -571,11 +670,12 @@ impl Gradient { /// position itself when the two colors' lightness differs by less than /// [`MIN_INK_LIGHTNESS_SPAN`], or when the display RGB mix's lightness turns back somewhere /// on the way from one color's lightness to the other's (checked at [`INK_MIX_STEPS`] even - /// steps), as from red to blue. `None` for a Classic gradient, and when the curve has more - /// than [`MAX_INK_TINTS`] stops ([`Self::fits_ink_tints`]). + /// steps), as from red to blue. `None` for a Classic gradient, for one mixed around the hue + /// wheel (its colors between two stops are not mixes of the stops' inks), and when the curve + /// has more than [`MAX_INK_TINTS`] stops ([`Self::fits_ink_tints`]). pub fn ink_mixes(&self) -> Option> { use crate::libraries::srgb_to_oklab; - if self.method.is_classic() || !self.fits_ink_tints() { + if self.method != GradientMethod::Perceptual || !self.fits_ink_tints() { return None; } let lightness = |c: &Color| srgb_to_oklab(c.to_rgb())[0]; @@ -592,7 +692,7 @@ impl Gradient { .collect::>() .windows(2) .all(|w| (w[1] - w[0]) * (l1 - l0) >= 0.0); - for v in perceptual_mixes(s, n) { + for v in perceptual_mixes(self.method, s, n) { let weight = if by_position { v } else { @@ -650,8 +750,10 @@ impl Gradient { Some(Ramp { stops }) } /// The same ramp the other way round: the stops in reverse order at mirrored offsets, each - /// segment's midpoint mirrored with it. + /// segment's midpoint mirrored with it, and a hue method turned the other way (red to blue + /// through magenta, reversed, is blue to red through magenta). pub fn reverse(&mut self) { + self.method = self.method.mirrored(); self.stops.reverse(); for i in 0..self.stops.len() { // A midpoint belongs to the segment after its stop: reversed, stop i's segment is the @@ -666,6 +768,13 @@ impl Gradient { pub fn sort(&mut self) { self.stops.sort_by(|a, b| a.offset.total_cmp(&b.offset)); } + /// The gradient's colors as a Classic gradient: its curve's stops ([`Self::expanded_stops`]) as + /// stops of their own, mixed in display RGB, within [`PERCEPTUAL_TOLERANCE`] of it. The stops + /// keep no swatch links, tints or process plates. + pub fn as_classic_curve(&self) -> Gradient { + let stops = self.expanded().map(|(t, color, opacity, _)| GradientStop { opacity, ..GradientStop::new(t, color) }).collect(); + Gradient { kind: self.kind, stops, dither: self.dither, method: GradientMethod::Classic, spread: self.spread, repeats: self.repeats } + } /// The part of the gradient from `from` to `to` (0..1, either way round) as a linear gradient /// of its own running 0 → 1: the same colours (midpoints become stops, as in /// [`Self::expanded_stops`]). One colour throughout when `from == to`. @@ -1261,8 +1370,9 @@ impl GradientPaint { } /// Is this gradient drawn as pixels rather than as a vector gradient when it paints a fill /// (`fill`) or a stroke (SVG and PDF draw such gradients as images): a freeform gradient, a - /// dithered fill (dither applies to fills only), or a Perceptual one (its colours don't mix - /// linearly between the stops, as SVG's and PDF's gradients do)? + /// dithered fill (dither applies to fills only), or a Perceptual one or one mixed around the + /// hue wheel (its colours don't mix linearly between the stops, as SVG's and PDF's gradients + /// do)? pub fn draws_as_pixels(&self, fill: bool) -> bool { self.gradient.kind == GradientKind::Freeform || (fill && self.gradient.dither) || !self.gradient.method.is_classic() } diff --git a/crates/color/src/gradient/spread.rs b/crates/color/src/gradient/spread.rs index 8f9933c40c..fa111586ba 100644 --- a/crates/color/src/gradient/spread.rs +++ b/crates/color/src/gradient/spread.rs @@ -59,6 +59,9 @@ impl Gradient { if !self.is_spread() || matches!(kind, GradientKind::Conic | GradientKind::Freeform) || self.stops.is_empty() { return None; } + if self.mirrors_a_hue_turn() { + return self.as_classic_curve().padded_within(geom, area, max_stops); + } let period = geom.period(kind, self.repeat_count()); let corners = [Point::new(area.x0, area.y0), Point::new(area.x1, area.y0), Point::new(area.x1, area.y1), Point::new(area.x0, area.y1)]; let ts = corners.map(|p| period.param_at(kind, p)); @@ -128,6 +131,9 @@ impl Gradient { if self.stops.is_empty() { return Cow::Owned(Gradient { spread: GradientSpread::Pad, repeats: 1, ..self.clone() }); } + if self.mirrors_a_hue_turn() { + return Cow::Owned(self.as_classic_curve().unrolled_within(max_stops).into_owned()); + } let n = self.repeat_count() as usize; let budget = max_stops.min(MAX_SPREAD_STOPS); let kept = n.min(budget / 2).max(1); @@ -159,12 +165,22 @@ impl Gradient { if !self.is_spread() || self.stops.is_empty() { return None; } + if self.mirrors_a_hue_turn() { + return self.as_classic_curve().cycle(); + } let one = whole_run(&self.stops); let runs = if self.spread == GradientSpread::Reflect { 2 } else { 1 }; let stops = self.runs(&one, false, 0.0, runs, runs as f64); Some((Gradient { stops, spread: GradientSpread::Pad, repeats: 1, ..self.clone() }, runs as u32)) } + /// Does the gradient reflect while mixing around the hue wheel? A mirrored run of its stops + /// would turn the wheel the other way, and one gradient has one method, so its runs are + /// written from its curve's stops ([`Self::as_classic_curve`]), which mirror as they are. + fn mirrors_a_hue_turn(&self) -> bool { + self.method.is_hue() && self.spread == GradientSpread::Reflect + } + /// `n` runs of `one` (one run of the stops, [`whole_run`]) on a line of `of` runs from 0 to 1, /// the first of them `start` runs along it and an odd-numbered run when `first_odd` (with /// Reflect, the odd-numbered runs are mirrored). diff --git a/crates/color/src/gradient/spread/tests.rs b/crates/color/src/gradient/spread/tests.rs index a88319ccaa..ab088293da 100644 --- a/crates/color/src/gradient/spread/tests.rs +++ b/crates/color/src/gradient/spread/tests.rs @@ -118,6 +118,46 @@ fn reflect_mirrors_every_other_run_and_its_midpoints() { assert!((p.gradient.stops[2].midpoint - 0.7).abs() < 1e-6, "{:?}", p.gradient.stops); } +/// A gradient mixed around the hue wheel that reflects: a mirrored run turns the wheel back the +/// way it came, so every point keeps its color when the runs are written out as a gradient that +/// pads, unrolled along the vector, or as one cycle, with a repeating one's too. +#[test] +fn reflected_and_repeated_hue_turns_keep_every_color() { + let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(10.0, 0.0), aspect: 1.0, focal: None }; + let stops = || { + let mut red = GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)); + red.midpoint = 0.3; + vec![red, GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))] + }; + let near = |a: Color, b: Color, what: &str| { + assert!(a.to_rgb().iter().zip(b.to_rgb()).all(|(x, y)| (x - y).abs() < 1.0 / 255.0), "{what}: {a:?} vs {b:?}"); + }; + for method in [GradientMethod::HueClockwise, GradientMethod::HueCounterclockwise] { + for spread in [GradientSpread::Reflect, GradientSpread::Repeat] { + let g = Gradient { method, spread, repeats: 2, ..Gradient::new(GradientKind::Linear, stops()) }; + let p = g.padded_over(&geom, Rect::new(-10.0, 0.0, 20.0, 10.0)).unwrap(); + assert_eq!((p.gradient.spread, p.gradient.repeats), (GradientSpread::Pad, 1)); + for k in 0..300 { + let q = Point::new(-10.0 + f64::from(k) * 0.1 + 0.013, 5.0); + let want = g.sample(g.spread_param(geom.param_at(GradientKind::Linear, q)) as f32).0; + let got = p.gradient.sample(p.geom.param_at(GradientKind::Linear, q) as f32).0; + near(want, got, &format!("{method:?} {spread:?} padded at {}", q.x)); + } + let unrolled = g.unrolled(); + let (cycle, runs) = g.cycle().unwrap(); + for k in 0..=200 { + let t = (k as f32 + 0.37) / 201.0; + let want = g.sample(g.spread_param(f64::from(t)) as f32).0; + near(want, unrolled.sample(t).0, &format!("{method:?} {spread:?} unrolled at {t}")); + // One cycle covers `runs` of the two runs between 0 and 1. + let along = t * runs as f32 / 2.0; + let want = g.sample(g.spread_param(f64::from(along)) as f32).0; + near(want, cycle.sample(t).0, &format!("{method:?} {spread:?} cycle at {t}")); + } + } + } +} + #[test] fn stops_inside_a_run_hold_their_colors_to_its_ends() { let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(10.0, 0.0), aspect: 1.0, focal: None }; diff --git a/crates/color/src/gradient/tests.rs b/crates/color/src/gradient/tests.rs index 0709d3068b..cd25753a05 100644 --- a/crates/color/src/gradient/tests.rs +++ b/crates/color/src/gradient/tests.rs @@ -514,6 +514,283 @@ fn perceptual_gradients_draw_as_pixels() { } } +fn two_stops(a: Color, b: Color, method: GradientMethod) -> Gradient { + let mut g = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, a), GradientStop::new(1.0, b)]); + g.method = method; + g +} + +/// The hue methods mix around the hue wheel in one direction: Hue Counterclockwise raises the +/// hue angle from each stop to the next and Hue Clockwise lowers it, by the fixed-direction rules +/// of (the angles below are that +/// section's own examples), with saturation and brightness changing evenly. +#[test] +fn hue_methods_turn_one_way_around_the_wheel() { + let (red, blue) = (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)); + let rising = two_stops(red, blue, GradientMethod::HueCounterclockwise); + assert_eq!(rising.sample(0.0).0.to_hex(), "#ff0000"); + assert_eq!(rising.sample(0.25).0.to_hex(), "#ffff00", "60°"); + assert_eq!(rising.sample(0.5).0.to_hex(), "#00ff00", "120°"); + assert_eq!(rising.sample(1.0).0.to_hex(), "#0000ff"); + let falling = two_stops(red, blue, GradientMethod::HueClockwise); + assert_eq!(falling.sample(0.0).0.to_hex(), "#ff0000"); + assert_eq!(falling.sample(0.5).0.to_hex(), "#ff00ff", "300°"); + assert_eq!(falling.sample(1.0).0.to_hex(), "#0000ff"); + assert_eq!(two_stops(red, blue, GradientMethod::Classic).sample(0.5).0.to_rgb(), [0.5, 0.0, 0.5], "classic: the RGB mix"); + // 30° to 190° and to 230°: halfway is 110° and 130° rising, 290° and 310° falling. + for (to, up, down) in [(190.0, 110.0, 290.0), (230.0, 130.0, 310.0)] { + let (a, b) = (Color::from_hsb(30.0, 0.5, 0.8), Color::from_hsb(to, 0.9, 0.4)); + for (method, want) in [(GradientMethod::HueCounterclockwise, up), (GradientMethod::HueClockwise, down)] { + let [h, s, v] = method.mix(&a, &b, 0.5).to_hsb(); + assert!((h - want).abs() < 0.05, "{method:?} to {to}: hue {h}, not {want}"); + assert!((s - 0.7).abs() < 1e-3 && (v - 0.6).abs() < 1e-3, "{method:?}: saturation {s}, brightness {v}"); + } + } + // The other way round, each method takes the arc the other took. + let back = two_stops(blue, red, GradientMethod::HueClockwise); + assert_eq!(back.sample(0.5).0.to_hex(), "#00ff00"); + // A position that is not a number gives the first color. + assert_eq!(hue_mix(&red, &blue, f32::NAN, true).to_hex(), "#ff0000"); +} + +/// A color without a hue does not turn the wheel: a gray takes the hue of the stop it mixes +/// with, black its saturation too, two grays mix as grays, and two stops of one hue keep that +/// hue, whichever way the method turns. +#[test] +fn hue_methods_keep_the_hue_where_a_stop_has_none() { + let red = Color::rgb(1.0, 0.0, 0.0); + let close = |c: Color, want: [f32; 3], what: &str| { + let got = c.to_rgb(); + assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-3), "{what}: {got:?}, not {want:?}"); + }; + let green = Color::rgb(0.0, 1.0, 0.0); + for method in [GradientMethod::HueClockwise, GradientMethod::HueCounterclockwise] { + close(method.mix(&Color::WHITE, &green, 0.5), [0.5, 1.0, 0.5], "white to green: a light green"); + close(method.mix(&green, &Color::WHITE, 0.5), [0.5, 1.0, 0.5], "green to white: a light green"); + close(method.mix(&Color::rgb(0.5, 0.5, 0.5), &green, 0.5), [0.375, 0.75, 0.375], "gray to green"); + close(method.mix(&Color::BLACK, &green, 0.5), [0.0, 0.5, 0.0], "black to green: a dark green, not a gray green"); + close(method.mix(&green, &Color::BLACK, 0.25), [0.0, 0.75, 0.0], "green to black"); + close(method.mix(&Color::BLACK, &Color::WHITE, 0.5), [0.5, 0.5, 0.5], "black to white: gray"); + close(method.mix(&Color::BLACK, &Color::BLACK, 0.5), [0.0, 0.0, 0.0], "black to black"); + let dark = Color::rgb(0.5, 0.0, 0.0); + close(method.mix(&red, &dark, 0.5), [0.75, 0.0, 0.0], "one hue: a red between the two"); + close(method.mix(&red, &red, 0.5), [1.0, 0.0, 0.0], "one color stays"); + // One hue with saturation and brightness both changing: the hue stays, and the color is + // the even mix of saturation and brightness, which is not Classic's RGB mix. + let pink = Color::rgb(1.0, 0.8, 0.8); + close(method.mix(&dark, &pink, 0.5), [0.75, 0.3, 0.3], "dark red to pink"); + assert!((Color::lerp(&dark, &pink, 0.5).to_rgb()[1] - 0.4).abs() < 1e-3, "classic differs"); + // A color whose RGB numbers are under half a color level apart has no hue of its own: a + // near gray leaning blue takes green's hue rather than turning from blue. + let near_gray = Color::rgb(0.5, 0.5, 0.5 + 0.3 / 255.0); + let [h, _, _] = method.mix(&near_gray, &green, 0.5).to_hsb(); + assert!((h - 120.0).abs() < 0.5, "{method:?}: near gray to green passes hue {h}"); + // One a whole color level apart has its hue (240°) and turns from it. + let bluish = Color::rgb(128.0 / 255.0, 128.0 / 255.0, 129.0 / 255.0); + let [h, _, _] = method.mix(&bluish, &green, 0.5).to_hsb(); + let want = if method == GradientMethod::HueClockwise { 180.0 } else { 0.0 }; + assert!(((h - want + 180.0).rem_euclid(360.0) - 180.0).abs() < 0.5, "{method:?}: bluish gray to green passes hue {h}, not {want}"); + } +} + +/// Hues that differ only by rounding are one hue: a color and a tint or shade of it, whose +/// computed hue angles differ in their last digits, mix without turning the wheel, with either +/// method, and a stop added to a ramp leaves the ramp's colors as they were. Hues a whole color +/// level apart still turn. +#[test] +fn hue_methods_take_hues_within_half_a_color_level_as_one() { + let level = |n: u8| f32::from(n) / 255.0; + let rgb8 = |r: u8, g: u8, b: u8| Color::rgb(level(r), level(g), level(b)); + let methods = [GradientMethod::HueClockwise, GradientMethod::HueCounterclockwise]; + // Colors against their 50% tint toward white and a shade at 40% brightness. At least one + // pair's computed hues differ, or the test checks nothing. + let colors = [rgb8(60, 253, 230), rgb8(241, 194, 107), rgb8(68, 32, 130), rgb8(13, 200, 77), rgb8(250, 3, 201), rgb8(90, 91, 200)]; + let mut uneven = 0; + for c in colors { + let [h, s, v] = c.to_hsb(); + let tint = Color::lerp(&c, &Color::WHITE, 0.5); + let shade = Color::from_hsb(h, s, v * 0.4); + for other in [tint, shade] { + let [h1, _, _] = other.to_hsb(); + assert!((h1 - h).abs() < 0.01, "{c:?} and {other:?} are one hue: {h} and {h1}"); + uneven += usize::from(h1 != h); + for method in methods { + for (a, b) in [(c, other), (other, c)] { + for k in 0..=20 { + let [hm, _, _] = method.mix(&a, &b, k as f32 / 20.0).to_hsb(); + let off = (hm - h + 180.0).rem_euclid(360.0) - 180.0; + assert!(off.abs() < 0.5, "{method:?} {a:?} → {b:?} at {k}/20: hue {hm}, not {h}"); + } + } + } + } + } + assert!(uneven > 0, "no pair's hues differ by rounding"); + // Across 0°: 359.99° and 0.01° are one hue too. + for method in methods { + let [h, _, _] = method.mix(&Color::from_hsb(359.99, 1.0, 1.0), &Color::from_hsb(0.01, 1.0, 1.0), 0.5).to_hsb(); + assert!(!(1.0..=359.0).contains(&h), "{method:?} across 0°: {h}"); + } + // A stop added to a ramp samples the ramp's color there, and the ramp keeps its colors: + // white to red, a color to its tint, and a turn from red to blue. + let ramps = [ + (Color::WHITE, rgb8(255, 0, 0)), + (rgb8(60, 253, 230), Color::lerp(&rgb8(60, 253, 230), &Color::WHITE, 0.5)), + (rgb8(255, 0, 0), rgb8(0, 0, 255)), + ]; + for method in methods { + for (a, b) in ramps { + let g = two_stops(a, b, method); + for at in [0.21, 0.5, 0.83] { + let (stops, i) = insert_stop(&g, at); + assert_eq!((stops.len(), i), (3, 1)); + let with = Gradient { stops, ..g.clone() }; + for k in 0..=200 { + let t = k as f32 / 200.0; + let (was, now) = (g.sample(t).0.to_rgb(), with.sample(t).0.to_rgb()); + let off = was.iter().zip(now).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max); + assert!(off < 1.0 / 255.0, "{method:?} {a:?} → {b:?}, a stop at {at}: {was:?} became {now:?} at {t}"); + } + } + } + } + // One color level of hue is a hue of its own: red to a red one level toward yellow turns + // nearly the whole wheel one way and stays put the other. + let (red, next) = (rgb8(255, 0, 0), rgb8(255, 1, 0)); + let [h, _, _] = GradientMethod::HueClockwise.mix(&red, &next, 0.5).to_hsb(); + assert!((h - 180.0).abs() < 1.0, "the long way: {h}"); + let [h, _, _] = GradientMethod::HueCounterclockwise.mix(&red, &next, 0.5).to_hsb(); + assert!(h < 1.0, "the short way: {h}"); +} + +/// Renderers mix linearly between stops: a hue segment becomes stops on its way around the +/// wheel, close enough that the linear ramp between them stays within half a color level of it, +/// through the midpoint and with the opacity changing evenly. +#[test] +fn hue_segments_expand_into_stops_on_the_curve() { + let pairs = [ + (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)), + (Color::rgb(1.0, 0.0, 0.0), Color::rgb(1.0, 0.02, 0.0)), + (Color::from_hsb(30.0, 0.5, 0.8), Color::from_hsb(190.0, 0.9, 0.4)), + (Color::cmyk(0.0, 0.9, 0.9, 0.0), Color::WHITE), + (Color::BLACK, Color::rgb(0.0, 1.0, 0.0)), + ]; + for method in [GradientMethod::HueClockwise, GradientMethod::HueCounterclockwise] { + for (a, b) in pairs { + let mut g = two_stops(a, b, method); + g.stops[0].midpoint = 0.3; + g.stops[1].opacity = 0.5; + let ex: Vec<_> = g.expanded().collect(); + assert!((3..=600).contains(&ex.len()), "{method:?} {a:?} → {b:?}: {} stops", ex.len()); + assert!(ex.windows(2).all(|w| w[0].0 < w[1].0), "in order"); + assert!(ex.iter().any(|s| s.3 == Some(0.3) && (s.0 - 0.3).abs() < 1e-6), "a stop stands for the midpoint"); + let ramp = Ramp::new(&g, &Color::to_rgb); + for k in 0..=4000 { + let t = k as f32 / 4000.0; + let (c, o) = g.sample(t); + let [r, gr, bl] = c.to_rgb(); + let off = [r, gr, bl, o].iter().zip(ramp.at(t)).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max); + assert!(off < 0.5 / 255.0, "{method:?} {a:?} → {b:?} at {t}: {off}"); + } + assert_eq!(g.sample_keep_model(0.4), g.sample(0.4), "one mix"); + } + } +} + +/// Reversing a gradient gives the same ramp the other way round: a hue method turns the other +/// way with the stops, so every color keeps its place mirrored, and reversing twice gives the +/// gradient back. Classic and Perceptual keep their method. A backward span and the curve as a +/// Classic gradient hold the same colors. +#[test] +fn reversing_a_hue_gradient_keeps_its_colors_mirrored() { + let near = |a: Color, b: Color, what: &str| { + assert!(a.to_rgb().iter().zip(b.to_rgb()).all(|(x, y)| (x - y).abs() < 1.0 / 255.0), "{what}: {a:?} vs {b:?}"); + }; + for (method, other) in + [(GradientMethod::HueClockwise, GradientMethod::HueCounterclockwise), (GradientMethod::HueCounterclockwise, GradientMethod::HueClockwise)] + { + let mut g = Gradient::new( + GradientKind::Linear, + vec![ + GradientStop { midpoint: 0.3, ..GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)) }, + GradientStop { opacity: 0.5, ..GradientStop::new(0.6, Color::rgb(0.0, 0.0, 1.0)) }, + GradientStop::new(1.0, Color::rgb(0.9, 0.8, 0.1)), + ], + ); + g.method = method; + let mut back = g.clone(); + back.reverse(); + assert_eq!(back.method, other); + let (span, curve) = (g.span(1.0, 0.0), g.as_classic_curve()); + assert!(span.method.is_classic() && curve.method.is_classic()); + assert!(curve.stops.len() > g.stops.len() && curve.stops.iter().all(|s| s.swatch.is_none() && s.midpoint == 0.5)); + for k in 0..=400 { + let t = k as f32 / 400.0; + let (want, opacity) = g.sample(1.0 - t); + near(want, back.sample(t).0, &format!("{method:?} reversed at {t}")); + assert!((opacity - back.sample(t).1).abs() < 1e-4, "{method:?} opacity at {t}"); + near(want, span.sample(t).0, &format!("{method:?} backward span at {t}")); + near(g.sample(t).0, curve.sample(t).0, &format!("{method:?} curve at {t}")); + } + back.reverse(); + assert_eq!(back.method, method); + assert_eq!(back.stops.len(), g.stops.len()); + } + for method in [GradientMethod::Classic, GradientMethod::Perceptual] { + assert_eq!(method.mirrored(), method); + let mut g = blue_yellow(method); + g.reverse(); + assert_eq!(g.method, method); + } +} + +/// The hue methods are written by name, read back, and parsed from their param names; their +/// gradients draw as pixels in SVG and PDF, and are not mixes of the stops' inks. +#[test] +fn hue_methods_save_parse_and_draw_as_pixels() { + for (method, name, id) in [ + (GradientMethod::HueClockwise, "HueClockwise", "hue-clockwise"), + (GradientMethod::HueCounterclockwise, "HueCounterclockwise", "hue-counterclockwise"), + ] { + let g = two_stops(Color::cmyk(0.0, 1.0, 1.0, 0.0), Color::cmyk(1.0, 1.0, 0.0, 0.0), method); + let v = serde_json::to_value(&g).unwrap(); + assert_eq!(v["method"], name); + assert_eq!(serde_json::from_value::(v).unwrap(), g); + assert_eq!(method.id(), id); + for written in [id.to_string(), id.to_uppercase(), id.replace('-', " "), id.replace('-', "_"), name.to_string(), method.label().to_string()] { + assert_eq!(GradientMethod::parse(&written), Some(method), "{written}"); + } + assert!(method.is_hue() && !method.is_classic()); + let paint = GradientPaint::new(g.clone()); + assert!(paint.draws_as_pixels(true) && paint.draws_as_pixels(false)); + assert!(g.ink_mixes().is_none(), "not mixes of the stops' inks"); + assert!( + paint.ink_pixels(Rect::new(0.0, 0.0, 10.0, 10.0), Rect::new(0.0, 0.0, 10.0, 10.0), 4, 4, true).is_none(), + "an RGB image, not an ink image" + ); + } + assert!(!GradientMethod::Perceptual.is_hue() && !GradientMethod::Classic.is_hue()); + for (id, method) in [("classic", GradientMethod::Classic), ("perceptual", GradientMethod::Perceptual)] { + assert_eq!(method.id(), id); + assert_eq!(GradientMethod::parse(id), Some(method)); + } + for bad in [ + "hue", + "clockwise", + "hue-", + "", + "hue clockwise wise", + "c-l-a-s-s-i-c", + "per ceptual", + "hue--clockwise", + "hue-clock_wise", + " classic", + "hue- clockwise", + ] { + assert_eq!(GradientMethod::parse(bad), None, "{bad}"); + } +} + /// A Perceptual gradient of an ink's tints as tints: its stops keep their tints, and each tint on /// the curve has the lightness of the color mixed there, so the tints between the stops are not /// a straight line. diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index 0f55386830..226742e56e 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -1360,8 +1360,9 @@ fn band_edges(lo: f64, hi: f64, n: usize, runs: Option<(f64, usize)>) -> Vec Vec<(f64, (Color, f32))> { let at = |t: f64| (t, g.sample_keep_model(t as f32)); let mut v = vec![at(t0)]; - // A Perceptual gradient curves between its stops: the mesh takes a column at each of the stops - // its expansion adds, so the linear mix between columns follows that curve. + // A Perceptual gradient, or one mixed around the hue wheel, curves between its stops: the mesh + // takes a column at each of the stops its expansion adds, so the linear mix between columns + // follows that curve. if !g.method.is_classic() { v.extend(g.expanded().map(|(o, ..)| f64::from(o)).filter(|o| *o > t0 && *o < t1).map(at)); } diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 090a7cb46c..18bb3469b9 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -24,7 +24,7 @@ pub fn specs() -> Vec { "Gradient", ["Window", "Gradient"], None, - "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual (how the colours between the stops mix: in display RGB, or perceptually (OKLab) so the steps look even; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), spread?: pad|repeat|reflect (linear, radial, conic and diamond: what the gradient paints past the ends of its stops: pad carries the end colors on; repeat and reflect run the stops `repeats` times along the vector (around the circle, for a conic) and on past both ends, reflect mirroring every other run; an error on a freeform gradient), repeats?: 1..100 (a whole number: how many times repeat and reflect run the stops along the vector; VectorCraft's own limit; pad keeps it unused), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), 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 counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool, dither?: bool (fills only: a linear, radial, conic or diamond 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", + "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (how the colours between the stops mix: in display RGB, perceptually (OKLab) so the steps look even, or around the hue wheel: hue, saturation and brightness each change evenly from one stop to the next, the hue angle falling with hue-clockwise (red, magenta, blue, cyan, green, yellow) and rising with hue-counterclockwise; two stops whose hues are less than half a color level apart keep that hue; a stop whose RGB numbers are less than half a color level apart is gray and takes the hue of the stop it mixes with, and black that stop's saturation too; these two methods are VectorCraft's own, and SVG and PDF exports draw their gradients as RGB images, CMYK and spot color stops included; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), spread?: pad|repeat|reflect (linear, radial, conic and diamond: what the gradient paints past the ends of its stops: pad carries the end colors on; repeat and reflect run the stops `repeats` times along the vector (around the circle, for a conic) and on past both ends, reflect mirroring every other run; an error on a freeform gradient), repeats?: 1..100 (a whole number: how many times repeat and reflect run the stops along the vector; VectorCraft's own limit; pad keeps it unused), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), 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 counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool (the same ramp the other way round: the stops in reverse order, and a hue method turned the other way unless `method` is given too), dither?: bool (fills only: a linear, radial, conic or diamond 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 ), @@ -126,7 +126,11 @@ fn parse_kind(k: &str) -> Parsed { fn method_param(p: &Value) -> Parsed> { match p.get("method") { None | Some(Value::Null) => Ok(None), - Some(v) => v.as_str().and_then(GradientMethod::parse).map(Some).ok_or_else(|| "`method` must be classic or perceptual".into()), + Some(v) => v + .as_str() + .and_then(GradientMethod::parse) + .map(Some) + .ok_or_else(|| "`method` must be classic, perceptual, hue-clockwise or hue-counterclockwise".into()), } } diff --git a/crates/engine/src/cmd/gradpresets.rs b/crates/engine/src/cmd/gradpresets.rs index 29de2210ae..037033ea8a 100644 --- a/crates/engine/src/cmd/gradpresets.rs +++ b/crates/engine/src/cmd/gradpresets.rs @@ -215,7 +215,7 @@ fn apply(s: &mut Session, p: &Value) -> Result { o.insert("stroke".into(), json!(stroke)); o.insert("kind".into(), json!(g.kind.label().to_lowercase())); o.insert("stops".into(), stops_json(&g.stops)); - o.insert("method".into(), json!(g.method.label().to_lowercase())); + o.insert("method".into(), json!(g.method.id())); // A freeform preset takes no spread (paint.editGradient refuses one). if g.kind != vectorcraft_color::GradientKind::Freeform { o.insert("spread".into(), json!(g.spread.label().to_lowercase())); diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index eaf1597423..81c9d49cfb 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "Fill", [], None, - "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), 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|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), 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 counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; 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; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond 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, radial, conic or diamond `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; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), 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|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), 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 counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; 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; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond 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, radial, conic or diamond `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; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", has_doc, |s, p| set_paint(s, p, true) ), diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index 22c1c0c929..d0713a243a 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -410,6 +410,8 @@ fn gradient_key(g: &Gradient) -> Option> { let method = match g.method { GradientMethod::Classic => 0, GradientMethod::Perceptual => 1, + GradientMethod::HueClockwise => 2, + GradientMethod::HueCounterclockwise => 3, }; Some((kind, method, g.dither, g.painted_spread(), g.stops.iter().map(stop_key).collect::>()?)) } diff --git a/crates/engine/src/tests/css.rs b/crates/engine/src/tests/css.rs index 1ce5b09e41..39fb6a5bc2 100644 --- a/crates/engine/src/tests/css.rs +++ b/crates/engine/src/tests/css.rs @@ -57,6 +57,28 @@ fn type_gives_font_properties_and_gradients_css_gradients() { assert!(text.contains("background-image: linear-gradient(90deg, "), "{text}"); } +/// CSS gradients mix linearly: a gradient mixed around the hue wheel is written through the stops +/// on its way around the wheel, so red to blue lists green one way and magenta the other. +#[test] +fn gradients_mixed_around_the_hue_wheel_give_css_the_stops_on_their_way() { + let mut s = session(); + run(&mut s, "shape.rectangle", json!({"x": 0, "y": 100, "width": 100, "height": 50})); + let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]); + let gradient = |s: &mut Session, method: &str| { + run(s, "paint.setFill", json!({"gradient": {"kind": "linear", "method": method, "stops": stops}})); + let text = css(&run(s, "css.selection", json!({}))).to_string(); + text.lines().find(|l| l.contains("linear-gradient(")).unwrap_or_else(|| panic!("a gradient in {text}")).to_string() + }; + let classic = gradient(&mut s, "classic"); + assert_eq!(classic.matches('%').count(), 2, "{classic}"); + for (method, middle, other) in [("hue-counterclockwise", "#00ff00 50%", "#ff00ff"), ("hue-clockwise", "#ff00ff 50%", "#00ff00")] { + let line = gradient(&mut s, method); + assert!(line.matches('%').count() > 2, "{method}: {line}"); + assert!(line.contains("#ff0000 0%") && line.contains("#0000ff 100%"), "{method}: {line}"); + assert!(line.contains(middle) && !line.contains(other), "{method}: {line}"); + } +} + #[test] fn generate_covers_the_document_and_an_empty_selection_has_none() { let mut s = session(); diff --git a/crates/engine/src/tests/gradient.rs b/crates/engine/src/tests/gradient.rs index a2aea6341d..b8c8bcd325 100644 --- a/crates/engine/src/tests/gradient.rs +++ b/crates/engine/src/tests/gradient.rs @@ -511,7 +511,7 @@ fn an_outlined_stroke_leaves_dither_behind() { /// The Gradient panel's Method (Illustrator 30): `method` set and cleared through the gradient /// commands, on fills and strokes, kept by the params, swatches and a native save and load; -/// anything but classic or perceptual is refused without editing. +/// anything but the four method names is refused without editing. #[test] fn method_is_a_gradient_option_that_saves_and_loads() { use vectorcraft_color::GradientMethod; @@ -548,7 +548,7 @@ fn method_is_a_gradient_option_that_saves_and_loads() { let before = node(&s, id).appearance.clone(); for bad in [json!("smooth"), json!(1), json!(true)] { let e = s.execute("paint.editGradient", &json!({"method": bad})).unwrap_err().to_string(); - assert!(e.contains("`method` must be classic or perceptual"), "{e}"); + assert!(e.contains("`method` must be classic, perceptual, hue-clockwise or hue-counterclockwise"), "{e}"); assert!(s.execute("paint.setFill", &json!({"gradient": {"method": bad}})).is_err()); } assert_eq!(node(&s, id).appearance, before); @@ -566,6 +566,76 @@ fn method_is_a_gradient_option_that_saves_and_loads() { assert!(back.gradient.dither && back.gradient.method == GradientMethod::Perceptual); } +/// The hue methods: `method` hue-clockwise and hue-counterclockwise set through the gradient +/// commands on fills and strokes, as one undo step, kept by the params, a swatch, a saved preset +/// and a native save and load, and written with or without the hyphen. +#[test] +fn the_hue_methods_are_set_saved_and_loaded() { + use vectorcraft_color::GradientMethod; + let mut s = session(); + let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]); + s.execute("paint.setFill", &json!({"gradient": {"stops": stops}})).unwrap(); + for (name, method, middle) in + [("hue-clockwise", GradientMethod::HueClockwise, "#ff00ff"), ("hue-counterclockwise", GradientMethod::HueCounterclockwise, "#00ff00")] + { + let undo = s.doc().unwrap().history.undo.len(); + s.execute("paint.editGradient", &json!({"method": name})).unwrap(); + let g = fill_gradient(&s, id); + assert_eq!(g.gradient.method, method); + assert_eq!(g.gradient.stops.len(), 2, "{name}: the stops stay as they were"); + assert_eq!(g.gradient.sample(0.5).0.to_hex(), middle, "{name}: halfway around the wheel"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{name}: one undo step"); + // The params carry it, so a swatch, a preset and a copied paint keep it. + let p = vectorcraft_tools::params::gradient_params(&g); + assert_eq!(p["method"], json!(name)); + let mut q = gradient_param(&g); + q["name"] = json!(format!("Wheel {name}")); + s.execute("swatch.new", &q).unwrap(); + let Some(Paint::Gradient(sw)) = s.doc().unwrap().doc.swatch(&format!("Wheel {name}")).map(|w| w.paint.clone()) else { + panic!("a gradient swatch") + }; + assert_eq!(sw.gradient.method, method, "{name}: the swatch"); + s.execute("gradient.presets.save", &json!({"name": format!("Preset {name}")})).unwrap(); + s.execute("paint.editGradient", &json!({"method": "classic"})).unwrap(); + s.execute("gradient.presets.apply", &json!({"name": format!("Preset {name}")})).unwrap(); + assert_eq!(fill_gradient(&s, id).gradient.method, method, "{name}: the preset"); + // 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_eq!(saved.gradient.method, method, "{name}: the native file keeps the method"); + } + // Strokes take it too, and the name reads without the hyphen and in any case. + for (name, method) in [("hue-clockwise", GradientMethod::HueClockwise), ("hue-counterclockwise", GradientMethod::HueCounterclockwise)] { + for written in [name.replace('-', " "), name.replace('-', "").to_uppercase()] { + s.execute("paint.setStroke", &json!({"gradient": {"method": "classic"}})).unwrap(); + s.execute("paint.editGradient", &json!({"method": written, "stroke": true})).unwrap(); + let Paint::Gradient(st) = node(&s, id).appearance.stroke_paint() else { panic!("a gradient stroke") }; + assert_eq!(st.gradient.method, method, "{written}"); + } + } + // Reverse gives the same ramp the other way round: the hue method turns with the stops, so + // the middle keeps its color, unless the edit names a method too. + s.execute("paint.editGradient", &json!({"method": "hue-clockwise", "stroke": false})).unwrap(); + let before = fill_gradient(&s, id).gradient; + s.execute("paint.editGradient", &json!({"reverse": true, "stroke": false})).unwrap(); + let after = fill_gradient(&s, id).gradient; + assert_eq!(after.method, GradientMethod::HueCounterclockwise); + assert_eq!((after.stops[0].color.to_hex(), after.stops[1].color.to_hex()), ("#0000ff".to_string(), "#ff0000".to_string())); + for t in [0.25, 0.5, 0.75] { + assert_eq!(after.sample(t).0.to_hex(), before.sample(1.0 - t).0.to_hex(), "reversed at {t}"); + } + s.execute("paint.editGradient", &json!({"reverse": true, "method": "hue-counterclockwise", "stroke": false})).unwrap(); + assert_eq!(fill_gradient(&s, id).gradient.method, GradientMethod::HueCounterclockwise, "a method in the edit wins"); + // Half a name is refused, nothing changed. + let before = node(&s, id).appearance.clone(); + for bad in ["hue", "clockwise", "hue-clock"] { + let e = s.execute("paint.editGradient", &json!({"method": bad})).unwrap_err().to_string(); + assert!(e.contains("`method` must be classic, perceptual, hue-clockwise or hue-counterclockwise"), "{bad}: {e}"); + } + assert_eq!(node(&s, id).appearance, before); +} + /// A conic gradient through `paint.setFill`, the Gradient panel's edits, transforms, the params /// agents and swatches read, and the native format. #[test] diff --git a/crates/engine/src/tests/linked_stops.rs b/crates/engine/src/tests/linked_stops.rs index 7f70d24ff0..9ed3db2724 100644 --- a/crates/engine/src/tests/linked_stops.rs +++ b/crates/engine/src/tests/linked_stops.rs @@ -416,6 +416,34 @@ fn spot_gradients_drawn_as_images_warn() { assert!(!spot_warning(&warnings), "{warnings:?}"); } +/// A spot gradient mixed around the hue wheel is an image in process colors with the warning, +/// where the same stops mixed by Perceptual are a Separation or DeviceN shading: the colors +/// between its stops are not tints or mixes of the stops' inks. +#[test] +fn hue_wheel_spot_gradients_export_as_images_and_warn() { + let (mut s, _) = setup(); + run(&mut s, "swatch.new", json!({"name": "Leaf", "color": {"c": 1, "m": 0, "y": 1, "k": 0}, "spot": true})); + let spot_warning = |w: &[String]| w.iter().any(|w| w.contains("spot colors in gradients exported as images")); + let ink_to_leaf = json!({"stops": [{"offset": 0, "swatch": "Ink"}, {"offset": 1, "swatch": "Leaf"}]}); + for method in ["hue-clockwise", "hue-counterclockwise"] { + for (what, stops) in [("Ink → paper", ink_to_white(80.0)), ("Ink → Leaf", ink_to_leaf.clone())] { + for kind in ["linear", "radial", "conic", "diamond"] { + run(&mut s, "paint.editGradient", with(stops.clone(), json!({"method": method, "kind": kind}))); + let (pdf, warnings) = pdf_of(&s); + assert!(pdf.contains("/Subtype/Image"), "{method} {kind} {what}: an image"); + assert!(!pdf.contains("/Separation") && !pdf.contains("/DeviceN"), "{method} {kind} {what}: no spot ink shading"); + assert!(spot_warning(&warnings), "{method} {kind} {what}: {warnings:?}"); + } + } + // Without spot inks: an image, without the warning. + let red_blue = json!({"stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}], "kind": "linear"}); + run(&mut s, "paint.editGradient", with(red_blue, json!({"method": method}))); + let (pdf, warnings) = pdf_of(&s); + assert!(pdf.contains("/Subtype/Image"), "{method}: a process gradient is an image"); + assert!(!spot_warning(&warnings), "{method}: {warnings:?}"); + } +} + #[test] fn freeform_gradients_without_points_warn_about_spot_stops() { let (mut s, id) = setup(); diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index b46b962ce4..a457c6750c 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -2634,6 +2634,34 @@ fn gradient_options_and_links_the_data_cant_hold_are_reported() { let back = read_back(&doc); let Paint::Gradient(g) = back.layers[0].children().unwrap()[0].appearance.items[0].paint() else { panic!("a gradient") }; assert_eq!((color(&g.gradient.stops[0].color), g.gradient.stops[0].swatch.as_deref()), (color(&BRAND), None)); + // A gradient mixed around the hue wheel, either way, is written as a classic one of the stops + // on its curve, no more than the reader reads, and opens with the colors it had. + for method in [vectorcraft_color::gradient::GradientMethod::HueClockwise, vectorcraft_color::gradient::GradientMethod::HueCounterclockwise] { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut g = sunset(); + g.method = method; + let want = g.clone(); + let n = painted( + &mut doc, + shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), + vec![AppearanceItem::Fill(FillLayer::new(gradient_paint(g, None, None)))], + ); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert_eq!(out.warnings, [writer::GRADIENT_HUE], "{method:?}"); + let back = read_back(&doc); + let Paint::Gradient(g) = back.layers[0].children().unwrap()[0].appearance.items[0].paint() else { panic!("a gradient") }; + assert!(g.gradient.method.is_classic(), "{method:?}"); + let written = g.gradient.stops.len(); + assert!((want.stops.len() + 1..=crate::import::MAX_GRADIENT_STOPS).contains(&written), "{method:?}: {written} stops"); + for k in 0..=100 { + let t = k as f32 / 100.0; + let ((a, ao), (b, bo)) = (want.sample(t), g.gradient.sample(t)); + let (a, b) = (a.to_rgb(), b.to_rgb()); + assert!(a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.03) && (ao - bo).abs() < 0.03, "{method:?} at {t}: {a:?} {ao} vs {b:?} {bo}"); + } + } } /// A pattern whose art reaches past its tile is written as its tiles cut to the shape: a definition diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index cd0455f10f..7db5d11896 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -36,7 +36,7 @@ pub(super) use images::PAYLOAD; #[cfg(test)] pub(super) use images::{IMAGE_DEPTH, IMAGES, LINKS_EMBEDDED}; #[cfg(test)] -pub(super) use paints::{GRADIENT_MIX, GRADIENT_SHAPES, GRADIENTS, STOP_LINKS, SWATCH_PLACEMENT, free_gradient_name}; +pub(super) use paints::{GRADIENT_HUE, GRADIENT_MIX, GRADIENT_SHAPES, GRADIENTS, STOP_LINKS, SWATCH_PLACEMENT, free_gradient_name}; #[cfg(test)] pub(super) use symbols::{MISSING_SYMBOL, SYMBOL_ART, SYMBOL_COUNT, SYMBOL_OPTIONS}; #[cfg(test)] diff --git a/crates/eps/src/ai_editing/writer/paints.rs b/crates/eps/src/ai_editing/writer/paints.rs index 0a7220faa5..f1b52dd91b 100644 --- a/crates/eps/src/ai_editing/writer/paints.rs +++ b/crates/eps/src/ai_editing/writer/paints.rs @@ -21,6 +21,7 @@ pub(in crate::ai_editing) const SWATCH_PLACEMENT: &str = "the placement of pattern swatches that move, turn or scale their pattern is left out of the editing data for other apps"; pub(in crate::ai_editing) const GRADIENT_MIX: &str = "gradients that mix their colors perceptually or dither are written as classic gradients in the editing data for other apps"; +pub(in crate::ai_editing) const GRADIENT_HUE: &str = "gradients mixed around the hue wheel are written in process colors, with the colors between their stops as stops of their own (at most 256), in the editing data for other apps"; pub(in crate::ai_editing) const CONIC: &str = "conic gradients are written as linear gradients along their vector, with the same stops, in the editing data for other apps"; pub(in crate::ai_editing) const STOP_LINKS: &str = @@ -56,6 +57,19 @@ fn writable_gradient(g: &Gradient) -> bool { g.kind != GradientKind::Freeform && !g.stops.is_empty() && g.stops.len() <= crate::import::MAX_GRADIENT_STOPS } +/// `g` as the data holds its colors: one mixed around the hue wheel (the data's gradients mix +/// their stops' colors directly) as a classic gradient of the stops on its curve +/// ([`Gradient::as_classic_curve`]), no more than the `.ai` reader reads when it pads +/// ([`GRADIENT_HUE`]); others as they are. +fn hue_as_stops(g: &Gradient) -> std::borrow::Cow<'_, Gradient> { + use std::borrow::Cow; + if !g.method.is_hue() { + return Cow::Borrowed(g); + } + let curve = g.as_classic_curve(); + Cow::Owned(if curve.is_spread() { curve } else { curve.fitted(crate::import::MAX_GRADIENT_STOPS).into_owned() }) +} + /// Is a gradient of `kind` written as a radial one: a radial gradient, or a diamond (the ellipse /// through its square's corners, [`DIAMOND`])? fn written_radial(kind: GradientKind) -> bool { @@ -86,9 +100,11 @@ impl Writer<'_> { pub(in crate::ai_editing) fn reserve_swatch_gradients(&mut self) { let doc = self.doc; for sw in doc.swatches_iter() { - // As `swatch_paint` writes it: the stops of one that repeats or reflects repeated. + // As `swatch_paint` writes it: one mixed around the hue wheel as the stops on its curve, + // and the stops of one that repeats or reflects repeated. if let Paint::Gradient(g) = &sw.paint - && let gradient = g.gradient.unrolled_within(crate::import::MAX_GRADIENT_STOPS) + && let stops = hue_as_stops(&g.gradient) + && let gradient = stops.unrolled_within(crate::import::MAX_GRADIENT_STOPS) && writable_gradient(&gradient) && !self.gradients.iter().any(|d| d.name == sw.name) { @@ -149,6 +165,10 @@ impl Writer<'_> { // The data has no spread: a gradient that repeats or reflects is written with its stops // repeated over `area` (a conic one, written linear, around its turn). let mut g: GradientPaint = (**g).clone(); + if g.gradient.method.is_hue() { + self.warn(GRADIENT_HUE); + g.gradient = hue_as_stops(&g.gradient).into_owned(); + } if g.gradient.is_spread() { self.warn(SPREAD); match g.gradient.padded_within(&geom, area, crate::import::MAX_GRADIENT_STOPS) { @@ -557,13 +577,17 @@ impl Writer<'_> { /// be written. pub(super) fn swatch_paint(&mut self, s: &mut String, sw: &vectorcraft_color::swatch::Swatch) -> bool { // A swatch has no placement: one that repeats or reflects holds its stops repeated along - // its vector. - let unrolled = match &sw.paint { - Paint::Gradient(g) => Some(g.gradient.unrolled_within(crate::import::MAX_GRADIENT_STOPS)), + // its vector, and one mixed around the hue wheel the stops on its curve. + let stops = match &sw.paint { + Paint::Gradient(g) => Some(hue_as_stops(&g.gradient)), _ => None, }; + let unrolled = stops.as_deref().map(|g| g.unrolled_within(crate::import::MAX_GRADIENT_STOPS)); match &sw.paint { Paint::Gradient(g) if unrolled.as_deref().is_some_and(writable_gradient) => { + if g.gradient.method.is_hue() { + self.warn(GRADIENT_HUE); + } if g.gradient.is_spread() { self.warn(SPREAD); } diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index 042a7ed2d2..00bb29d12f 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -115,6 +115,7 @@ const CONIC: &str = "conic gradients are written as solid wedges clipped to the const DIAMOND: &str = "diamond gradients are written as four linear gradients clipped to the shape (legacy .ai files have no diamond gradient)"; const SPREAD: &str = "gradients that repeat or reflect are written with their stops repeated across the shape (legacy .ai gradients only pad)"; const SPREAD_CAPPED: &str = "gradients that repeat or reflect are written with at most 256 stops; they pad past those"; +const HUE_STOPS: &str = "gradients mixed around the hue wheel are written in process colors, with the colors between their stops as stops of their own (at most 256; legacy .ai gradients mix their stops' colors directly)"; /// Wedges a conic gradient is written as: one a degree. const CONIC_WEDGES: usize = 360; const STROKES: &str = diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs index 76e96f5aa9..6b2f8335b8 100644 --- a/crates/eps/src/legacy_ai/tests.rs +++ b/crates/eps/src/legacy_ai/tests.rs @@ -198,6 +198,46 @@ fn many_repeats_are_written_within_the_stops_the_reader_reads() { assert_eq!(stops, Some(written), "every stop written is read"); } +/// The format's gradients mix their stops' colors directly: a gradient mixed around the hue wheel +/// is written with the colors on its way around the wheel as stops, no more than the reader +/// reads, with a warning, and opens with the colors it had. +#[test] +fn hue_wheel_gradients_are_written_with_the_stops_on_their_curve() { + use vectorcraft_color::GradientMethod; + for (method, middle) in [(GradientMethod::HueCounterclockwise, [0.0, 1.0, 0.0]), (GradientMethod::HueClockwise, [1.0, 0.0, 1.0])] { + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let g = GradientPaint::new(Gradient { method, ..Gradient::new(GradientKind::Linear, stops) }); + let want = g.gradient.clone(); + let mut doc = Document::new(200.0, 100.0); + let n = shape(&mut doc, shapes::rectangle(Rect::new(20.0, 10.0, 180.0, 90.0)), Paint::Gradient(Box::new(g)), Paint::None); + let l = doc.layers[0].id; + doc.insert(Some(l), 0, n).unwrap(); + let out = write(&doc); + assert!(out.warnings.iter().any(|w| w == HUE_STOPS), "{method:?}: {:?}", out.warnings); + let text = String::from_utf8_lossy(&out.bytes).into_owned(); + let def = text.split("%AI5_BeginGradient: (Gradient)\n").nth(1).and_then(|d| d.split("%AI5_EndGradient").next()).unwrap(); + let written = def.matches(" %_Bs\n").count(); + // The two ends and each sixth of the wheel between them, where the way turns in RGB. + assert!((3..=crate::import::MAX_GRADIENT_STOPS).contains(&written), "{method:?}: {written} stops"); + let read = crate::import(&out.bytes).unwrap().document; + let mut back = None; + read.walk(|n| { + if let (None, Paint::Gradient(g)) = (&back, n.appearance.fill_paint()) { + back = Some(g.gradient.clone()); + } + }); + let back = back.unwrap(); + assert!(back.method.is_classic(), "{method:?}"); + let mid = back.sample(0.5).0.to_rgb(); + assert!(mid.iter().zip(middle).all(|(a, b)| (a - b).abs() < 0.03), "{method:?}: the middle is {mid:?}"); + for k in 0..=100 { + let t = k as f32 / 100.0; + let (a, b) = (want.sample(t).0.to_rgb(), back.sample(t).0.to_rgb()); + assert!(a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.03), "{method:?} at {t}: {a:?} vs {b:?}"); + } + } +} + /// A radial gradient repeated out from a center far from the shape is written with its ramp points /// (percent) to five decimals, so its runs keep their places hundreds of runs out. #[test] diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index 06d9e0ddcc..20f8b32b96 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -9,7 +9,7 @@ use super::{ ROTATED_TYPE, Rect, STROKES, SUBLAYERS, Shape, StrokeLayer, SubPath, TITLE_AND_COLOR_NAMES, TRANSPARENCY, TYPE, TYPE_ATTRIBUTES, TextKind, TextObject, UNDECODED, Vec2, Write, ai_ops, latin, ps, stroke, }; -use super::{CONIC, CONIC_WEDGES, DIAMOND, SPREAD, SPREAD_CAPPED}; +use super::{CONIC, CONIC_WEDGES, DIAMOND, HUE_STOPS, SPREAD, SPREAD_CAPPED}; pub(super) struct Writer<'a> { doc: &'a Document, @@ -693,6 +693,16 @@ impl<'a> Writer<'a> { /// (its distance a fraction of the radius, at most 1, §3.5.5; `0 0 0 0 Bh` at the center, /// §3.5.6), `1 (name) x y angle length 1 0 0 1 0 0 Bg`. fn gradient(&mut self, g: &GradientPaint, bounds: Rect) -> String { + // A gradient mixed around the hue wheel (the format's gradients mix their stops' colors + // directly): the stops on its curve, no more than the reader reads, in a definition of + // its own. + if g.gradient.method.is_hue() { + self.warn(HUE_STOPS); + let curve = g.gradient.as_classic_curve(); + let gradient = if curve.is_spread() { curve } else { curve.fitted(crate::import::MAX_GRADIENT_STOPS).into_owned() }; + let p = GradientPaint { gradient, swatch: None, ..g.clone() }; + return self.gradient(&p, bounds); + } // A gradient that repeats or reflects (the format's gradients pad past their ends): its // stops repeated as many times as `bounds` needs, in a definition of its own, no more // stops than the reader reads. diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs index 0ec561dd31..c20c5847a2 100644 --- a/crates/eps/src/scene.rs +++ b/crates/eps/src/scene.rs @@ -42,6 +42,7 @@ const SPOT_GRADIENTS_L2: &str = "gradients mixing a spot color with other inks are written in process colors at PostScript Level 2 (Level 3 keeps the spot inks)"; const SPOT_GRADIENTS_SHARED: &str = "gradients whose spot inks can't share a DeviceN color space are written without the spot inks"; const SPOT_GRADIENTS_PERCEPTUAL: &str = "Perceptual gradients of spot colors are written in process colors"; +const SPOT_GRADIENTS_HUE: &str = "gradients of spot colors mixed around the hue wheel are written in process colors"; const CONIC_WEDGES: &str = "conic gradients are written as solid wedges (EPS has no conic shading)"; const DIAMOND_QUADRANTS: &str = "diamond gradients are written as four linear gradients (EPS has no diamond shading)"; const SPREAD_CAPPED: &str = @@ -913,8 +914,8 @@ impl<'a> Scene<'a> { /// them. Paper white adds no ink, a gray stop is black ink, and a spot stop's process plates /// print with its ink. An off-center midpoint is a stop /// with the inks halfway between its two stops. `None` for a gradient without a spot ink, and, - /// with a warning, for one whose inks can't be kept or that is mixed by the Perceptual method; - /// it is then written in process colors. + /// with a warning, for one whose inks can't be kept or that is mixed by the Perceptual method + /// or around the hue wheel; it is then written in process colors. fn ink_stops(&mut self, g: &vectorcraft_color::Gradient) -> Option<(GradientSpace, RawStops)> { let doc = self.doc; let spot_of = |s: &vectorcraft_color::GradientStop| -> Option { @@ -936,7 +937,7 @@ impl<'a> Scene<'a> { return None; } if !g.method.is_classic() { - self.warn(SPOT_GRADIENTS_PERCEPTUAL); + self.warn(if g.method.is_hue() { SPOT_GRADIENTS_HUE } else { SPOT_GRADIENTS_PERCEPTUAL }); return None; } if names.len() > MAX_DEVICE_N { @@ -1093,6 +1094,14 @@ impl<'a> Scene<'a> { /// both levels; a diamond: four linear gradients, [`Self::diamond_quadrants`]). fn gradient(&mut self, g: &GradientPaint, bounds: Rect, area: Rect) { let geom = g.resolve(bounds); + // One mixed around the hue wheel that reflects is repeated from its curve's stops, which + // keep no swatch links: the warning [`Self::ink_stops`] gives the others. + if g.gradient.method.is_hue() && g.gradient.is_spread() && g.gradient.spread == vectorcraft_color::GradientSpread::Reflect { + let doc = self.doc; + if g.gradient.stops.iter().any(|s| s.swatch.as_deref().is_some_and(|n| doc.swatch(n).is_some_and(|w| w.spot))) { + self.warn(SPOT_GRADIENTS_HUE); + } + } // A linear, radial or diamond gradient that repeats or reflects: PostScript's shadings // pad past their ends, so its stops are repeated as many times as the area needs. if let Some(padded) = g.gradient.padded_over(&geom, area) { diff --git a/crates/eps/src/tests.rs b/crates/eps/src/tests.rs index 0d5649ead5..c98cb9689a 100644 --- a/crates/eps/src/tests.rs +++ b/crates/eps/src/tests.rs @@ -673,6 +673,53 @@ fn perceptual_gradients_of_spot_inks_are_written_in_process_colors() { assert!(text(&export(&d, &opts(&d), None, None).unwrap()).contains("/ColorSpace VCdn0")); } +/// The colors between two stops mixed around the hue wheel are not mixes of the stops' inks: a +/// gradient of spot inks mixed that way is written in process colors, with its own warning, as +/// the same gradient without spot inks is. +#[test] +fn hue_wheel_gradients_of_spot_inks_are_written_in_process_colors() { + for method in [GradientMethod::HueClockwise, GradientMethod::HueCounterclockwise] { + let stops = vec![spot_stop(0.0, "Gold Ink", GOLD, 1.0), spot_stop(1.0, "Leaf Ink", LEAF, 1.0)]; + let mut hue = Gradient::new(GradientKind::Linear, stops.clone()); + hue.method = method; + // Reflecting, it is written from the stops on its curve, and warns all the same. + let reflected = Gradient { spread: vectorcraft_color::GradientSpread::Reflect, repeats: 2, ..hue.clone() }; + let r = spot_gradient_doc_of(reflected); + let reflected_out = export(&r, &opts(&r), None, None).unwrap(); + let reflected_page = checked_page(&text(&reflected_out)); + assert!(!reflected_page.contains("VCdn") && !reflected_page.contains("VCsp"), "{method:?} reflected: {reflected_page}"); + assert!( + reflected_out.warnings.iter().any(|w| w.contains("mixed around the hue wheel are written in process colors")), + "{method:?} reflected: {:?}", + reflected_out.warnings + ); + let d = spot_gradient_doc_of(hue); + let out = export(&d, &opts(&d), None, None).unwrap(); + let page = checked_page(&text(&out)); + assert!(!page.contains("VCdn") && !page.contains("VCsp"), "{method:?}: {page}"); + assert!(page.contains("/ColorSpace /DeviceCMYK"), "{method:?}: {page}"); + assert!( + out.warnings.iter().any(|w| w.contains("gradients of spot colors mixed around the hue wheel are written in process colors")), + "{method:?}: {:?}", + out.warnings + ); + assert!(!out.warnings.iter().any(|w| w.contains("Perceptual")), "{method:?}: {:?}", out.warnings); + let process = |c: Color| GradientStop::new(0.0, c); + let mut plain = Gradient::new(GradientKind::Linear, vec![process(GOLD), GradientStop { offset: 1.0, ..process(LEAF) }]); + plain.method = method; + let p = spot_gradient_doc_of(plain); + let plain_out = export(&p, &opts(&p), None, None).unwrap(); + assert!(!plain_out.warnings.iter().any(|w| w.contains("hue wheel")), "{method:?}: {:?}", plain_out.warnings); + let plain_page = checked_page(&text(&plain_out)); + let shading = |s: &str| -> Option { + let i = s.find("/ShadingType")?; + Some(s[i..i + s[i..].find("shfill")?].to_string()) + }; + assert!(shading(&page).is_some_and(|s| s.contains("/Bounds")), "{method:?}: {page}"); + assert_eq!(shading(&page), shading(&plain_page), "{method:?}"); + } +} + #[test] fn rgb_is_written_as_cmyk_when_asked_and_in_cmyk_documents() { let d = doc_with(|d| vec![red_rect(d)]); diff --git a/crates/pdf/src/export/paint.rs b/crates/pdf/src/export/paint.rs index acb96f1713..77815f93b3 100644 --- a/crates/pdf/src/export/paint.rs +++ b/crates/pdf/src/export/paint.rs @@ -463,7 +463,9 @@ impl Exporter<'_> { /// than an image? pub(super) fn perceptual_spot(&self, g: &vectorcraft_color::GradientPaint, fill: bool) -> bool { let gr = &g.gradient; - !gr.method.is_classic() + // Not one mixed around the hue wheel: its colors between two stops are not tints or + // mixes of the stops' inks, so it stays an image. + gr.method == vectorcraft_color::GradientMethod::Perceptual && gr.kind != GradientKind::Freeform && !(fill && gr.dither) && (self.one_spot_ink(gr).is_some() || self.device_n_inks_only(gr)) @@ -476,7 +478,7 @@ impl Exporter<'_> { /// other gradients, and for an ink too light for lightness to tell its tints apart, which /// [`Self::spot_stops`] then writes with the stops' own tints. fn perceptual_spot_stops(&mut self, g: &vectorcraft_color::Gradient) -> Option> { - if g.method.is_classic() { + if g.method != vectorcraft_color::GradientMethod::Perceptual { return None; } let ink = self.one_spot_ink(g)?; @@ -494,7 +496,7 @@ impl Exporter<'_> { let from_stops = g.gradient.kind != GradientKind::Freeform || !g.has_freeform_points(); if from_stops && self.has_spot_stop(&g.gradient) { self.warn( - "spot colors in gradients exported as images (freeform or dithered gradients, and Perceptual ones with RGB or Lab stops or too many stops) are written in process colors", + "spot colors in gradients exported as images (freeform or dithered gradients, ones mixed around the hue wheel, and Perceptual ones with RGB or Lab stops or too many stops) are written in process colors", ); } } @@ -623,9 +625,9 @@ impl Exporter<'_> { let field = freeform.then(|| g.freeform_on(b).model_field(spread_scale(b))); let cmyk_doc = self.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk; if g.gradient.kind == GradientKind::Conic { - self.warn("conic gradients drawn as pixels (dithered fills, and Perceptual ones other than spot ink shadings), in a PDF/A file or with more than 97 stops are exported as images"); + self.warn("conic gradients drawn as pixels (dithered fills, ones mixed around the hue wheel, and Perceptual ones other than spot ink shadings), in a PDF/A file or with more than 97 stops are exported as images"); } else if g.gradient.kind == GradientKind::Diamond { - self.warn("diamond gradients drawn as pixels (dithered fills, and Perceptual ones other than spot ink shadings), with translucent stops or without an extent are exported as images"); + self.warn("diamond gradients drawn as pixels (dithered fills, ones mixed around the hue wheel, and Perceptual ones other than spot ink shadings), with translucent stops or without an extent are exported as images"); } else if !freeform { self.warn("linear and radial gradients drawn as pixels are exported as images"); } diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 8caac7b0de..8f9959c77f 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -5,7 +5,7 @@ //! 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 and Perceptual gradients and dithered gradient fills as images at the raster +//! freeform and Perceptual gradients, gradients mixed around the hue wheel 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 diff --git a/crates/pdf/src/tests/cmykimages.rs b/crates/pdf/src/tests/cmykimages.rs index 5b86f2b895..45222bb04d 100644 --- a/crates/pdf/src/tests/cmykimages.rs +++ b/crates/pdf/src/tests/cmykimages.rs @@ -687,6 +687,26 @@ fn dithered_and_perceptual_gradients_of_cmyk_stops_are_written_as_cmyk_images() assert_ne!(w[0].space, "DeviceCMYK", "converted to RGB"); } +/// The colors between two stops mixed around the hue wheel are not mixes of the stops' inks: a +/// gradient of CMYK stops mixed that way is an RGB image, in a linear, radial, conic or diamond +/// gradient. +#[test] +fn hue_wheel_gradients_of_cmyk_stops_are_written_as_rgb_images() { + for method in [GradientMethod::HueClockwise, GradientMethod::HueCounterclockwise] { + for mode in [ColorMode::Cmyk, ColorMode::Rgb] { + for kind in [GradientKind::Linear, GradientKind::Radial, GradientKind::Conic, GradientKind::Diamond] { + let stops = vec![GradientStop::new(0.0, Color::cmyk(0.0, 1.0, 1.0, 0.0)), GradientStop::new(1.0, Color::cmyk(1.0, 1.0, 0.0, 0.0))]; + let d = gradient_doc(mode, Gradient { method, ..Gradient::new(kind, stops) }); + let out = export_with_report(&d, &opts(json!({}))).unwrap(); + let w = written(&out.bytes); + assert_eq!(w.len(), 1, "{method:?} {mode:?} {kind:?}: one opaque image: {w:?}"); + assert_eq!(w[0].space, "DeviceRGB", "{method:?} {mode:?} {kind:?}"); + assert!(out.warnings.iter().any(|m| m.contains("exported as images")), "{method:?} {mode:?} {kind:?}: {:?}", out.warnings); + } + } + } +} + /// A stop in another model, or linked to a spot swatch, keeps the RGB image. #[test] fn gradients_with_a_stop_that_is_not_process_cmyk_stay_rgb_images() { diff --git a/crates/svg/src/export/paint.rs b/crates/svg/src/export/paint.rs index 29c502cbdd..bedf456f11 100644 --- a/crates/svg/src/export/paint.rs +++ b/crates/svg/src/export/paint.rs @@ -529,9 +529,9 @@ impl Writer<'_> { self.warn(if freeform { "freeform gradients are written as images clipped to their shapes" } else if conic { - "conic gradients with translucent stops, dither or the Perceptual method are written as images clipped to their shapes" + "conic gradients with translucent stops, dither or a method other than Classic are written as images clipped to their shapes" } else if g.gradient.kind == GradientKind::Diamond { - "diamond gradients with translucent stops, dither, the Perceptual method or no extent are written as images clipped to their shapes" + "diamond gradients with translucent stops, dither, a method other than Classic or no extent are written as images clipped to their shapes" } else { "linear and radial gradients drawn as pixels are written as images clipped to their shapes" }); diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index b7013073d9..7e5d220fbb 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -47,6 +47,10 @@ //! rasterised in SVG //! (). //! On characters they are written as linear gradients through stops on the perceptual curve. +//! * **Gradients mixed around the hue wheel** (Hue Clockwise and Hue Counterclockwise, +//! VectorCraft's own methods) fill or stroke objects as an `` of the gradient too, at the +//! document's raster effects resolution. On characters they are written as gradients through +//! stops on their way around the wheel. //! * **Symbol instances** the shared `` 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. diff --git a/crates/svg/tests/it/raster_paints.rs b/crates/svg/tests/it/raster_paints.rs index 2ee1dd3bb5..a6bd69fc5a 100644 --- a/crates/svg/tests/it/raster_paints.rs +++ b/crates/svg/tests/it/raster_paints.rs @@ -214,6 +214,39 @@ fn perceptual_gradients_are_images_and_classic_ones_stay_vector() { assert!(!svg.contains(" Value { v["dither"] = json!(true); } if !g.gradient.method.is_classic() { - v["method"] = json!(g.gradient.method.label().to_lowercase()); + v["method"] = json!(g.gradient.method.id()); } // A freeform gradient has no ends to spread past (`paint.setFill` refuses a spread for one). if g.gradient.is_spread() { diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 3668f85971..cb7917c511 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5385,6 +5385,8 @@ lighting Light Светлина Pen Перо Pentagram Петограм Perceptual Перцептивен + Hue Clockwise Оттенък по часовниковата стрелка + Hue Counterclockwise Оттенък обратно на часовниковата стрелка Phone 360×800 Телефон 360×800 Phone 375×667 Телефон 375×667 Phone 375×812 Телефон 375×812 @@ -5954,7 +5956,7 @@ axis Y Axis Y ос @msg `dither` applies to gradient fills (stroke: false) `dither` се прилага към градиентни запълки (stroke: false) @msg `dither` applies to gradient fills (paint.setFill) `dither` се прилага към градиентни запълки (paint.setFill) @msg `dither` must be true or false `dither` трябва да бъде true или false -@msg `method` must be classic or perceptual `method` трябва да е classic или perceptual +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` трябва да е classic, perceptual, hue-clockwise или hue-counterclockwise # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Да се инсталира ли „{name}“, който иска голям изчислителен бюджет? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Всяко изпълнение може да продължи до {minutes} минути и да свърши {times} пъти повече работа, отколкото е позволено на другите приставки. То се изпълнява във фонов режим и можете да го откажете. diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 4cd635e4f8..d851a0eae8 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -5482,6 +5482,8 @@ imageTrace Outline Obris Vivid Živo Spectrum Spektar Perceptual Perceptivno + Hue Clockwise Nijansa u smjeru kazaljke na satu + Hue Counterclockwise Nijansa suprotno od kazaljke na satu Relative Colorimetric Relativna kolorimetrija Absolute Colorimetric Apsolutna kolorimetrija Exact Tačno @@ -5973,7 +5975,7 @@ axis Y Axis Y osa @msg `dither` applies to gradient fills (stroke: false) `dither` se primjenjuje na gradijentne ispune (stroke: false) @msg `dither` applies to gradient fills (paint.setFill) `dither` se primjenjuje na gradijentne ispune (paint.setFill) @msg `dither` must be true or false `dither` mora biti true ili false -@msg `method` must be classic or perceptual `method` mora biti classic ili perceptual +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` mora biti classic, perceptual, hue-clockwise ili hue-counterclockwise Install “{name}”, which asks for a heavy budget? Instalirati „{name}”, koji traži veliki budžet? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Svako pokretanje može trajati do {minutes} minuta i obaviti {times} puta više posla nego drugi dodaci. Radi u pozadini i možete ga otkazati. It may take minutes, so it runs in the background, where you can cancel it. Može trajati minutama, pa radi u pozadini, gdje ga možete otkazati. diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 44a49d45c9..a48bb69cf5 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -3138,6 +3138,8 @@ digits Malayalam Malaiàlam per slice per tall Percent: Percentatge: Perceptual Perceptual + Hue Clockwise To en sentit horari + Hue Counterclockwise To en sentit antihorari Perceptual Scales Escales perceptuals Performance Rendiment Permissions Permisos @@ -5177,7 +5179,7 @@ digits Malayalam Malaiàlam @msg `ids` must be an array of object ids, not {v} `ids` ha de ser una matriu d’identificadors d’objecte, no {v} @msg `colors` must be an array of colors, not {v} `colors` ha de ser una matriu de colors, no {v} @msg `library` is a library id or name (see {list}) `library` és un identificador o un nom de biblioteca (vegeu {list}) -@msg `method` must be classic or perceptual `method` ha de ser classic o perceptual +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` ha de ser classic, perceptual, hue-clockwise o hue-counterclockwise @msg `spread` must be pad, repeat or reflect `spread` ha de ser pad, repeat o reflect @msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` ha de ser un nombre enter d’1 a {MAX_REPEATS} @msg `spread` applies to linear, radial, conic and diamond gradients `spread` s’aplica als degradats lineals, radials, cònics i de rombe diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 00f43ce63e..7066ff5a8c 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5692,6 +5692,8 @@ imageTrace Outline Obrys Spectrum Spektrum # Rendering intents, recolor methods, gradient kinds Perceptual Percepční + Hue Clockwise Odstín ve směru hodinových ručiček + Hue Counterclockwise Odstín proti směru hodinových ručiček Relative Colorimetric Relativní kolorimetrický Absolute Colorimetric Absolutní kolorimetrický Exact Přesná @@ -6287,7 +6289,7 @@ axis Y Axis osa Y @msg `dither` applies to gradient fills (stroke: false) `dither` platí pro výplně přechodem (stroke: false) @msg `dither` applies to gradient fills (paint.setFill) `dither` platí pro výplně přechodem (paint.setFill) @msg `dither` must be true or false `dither` musí být true nebo false -@msg `method` must be classic or perceptual `method` musí být classic nebo perceptual +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` musí být classic, perceptual, hue-clockwise nebo hue-counterclockwise @msg `spread` must be pad, repeat or reflect `spread` musí být pad, repeat nebo reflect @msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` musí být celé číslo od 1 do {MAX_REPEATS} @msg `spread` applies to linear, radial, conic and diamond gradients `spread` platí pro lineární, kruhové, kuželové a kosočtverečné přechody diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 9cae1dd866..3c67a2c089 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5159,6 +5159,8 @@ imageTrace Outline Kontur Vivid Intens Spectrum Spektrum Perceptual Perceptuel + Hue Clockwise Farvetone med uret + Hue Counterclockwise Farvetone mod uret Relative Colorimetric Relativ kolorimetrisk Absolute Colorimetric Absolut kolorimetrisk Exact Eksakt @@ -5971,7 +5973,7 @@ axis Y Axis Y-akse @msg `dither` applies to gradient fills (stroke: false) `dither` gælder for gradientfyld (stroke: false) @msg `dither` applies to gradient fills (paint.setFill) `dither` gælder for gradientfyld (paint.setFill) @msg `dither` must be true or false `dither` skal være true eller false -@msg `method` must be classic or perceptual `method` skal være classic eller perceptual +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` skal være classic, perceptual, hue-clockwise eller hue-counterclockwise # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Installér »{name}«, som beder om et stort beregningsbudget? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Hver kørsel kan tage op til {minutes} minutter og udføre {times} gange så meget arbejde, som andre plug-ins må. Den kører i baggrunden, og du kan annullere den. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index a2f968fec3..617ad21e18 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5878,7 +5878,9 @@ axis Y Axis Y-Achse @msg `dither` applies to gradient fills (paint.setFill) `dither` gilt für Verlaufsflächen (paint.setFill) @msg `dither` must be true or false `dither` muss true oder false sein Perceptual Perzeptiv -@msg `method` must be classic or perceptual `method` muss classic oder perceptual sein + Hue Clockwise Farbton im Uhrzeigersinn + Hue Counterclockwise Farbton gegen den Uhrzeigersinn +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` muss classic, perceptual, hue-clockwise oder hue-counterclockwise sein @msg `spread` must be pad, repeat or reflect `spread` muss pad, repeat oder reflect sein @msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` muss eine ganze Zahl von 1 bis {MAX_REPEATS} sein @msg `spread` applies to linear, radial, conic and diamond gradients `spread` gilt für lineare, radiale, konische Verläufe und Rautenverläufe diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 85c04f996d..db9979f03e 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2742,6 +2742,8 @@ lighting Light Φως Oval Οβάλ Pen Πένα (Pen Tool) Perceptual Αντιληπτικό + Hue Clockwise Απόχρωση δεξιόστροφα + Hue Counterclockwise Απόχρωση αριστερόστροφα Pixel Εικονοστοιχείο Points: Σημεία: Preserve Διατήρηση @@ -6238,7 +6240,7 @@ axis Y Axis Άξονας Y @msg `dither` applies to gradient fills (stroke: false) Το `dither` ισχύει για γεμίσματα ντεγκραντέ (stroke: false) @msg `dither` applies to gradient fills (paint.setFill) Το `dither` ισχύει για γεμίσματα ντεγκραντέ (paint.setFill) @msg `dither` must be true or false Το `dither` πρέπει να είναι true ή false -@msg `method` must be classic or perceptual Το `method` πρέπει να είναι classic ή perceptual +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise Το `method` πρέπει να είναι classic, perceptual, hue-clockwise ή hue-counterclockwise @msg `spread` must be pad, repeat or reflect Το `spread` πρέπει να είναι pad, repeat ή reflect @msg `repeats` must be a whole number from 1 to {MAX_REPEATS} Το `repeats` πρέπει να είναι ακέραιος από 1 έως {MAX_REPEATS} @msg `spread` applies to linear, radial, conic and diamond gradients Το `spread` ισχύει για γραμμικά, ακτινικά, κωνικά και ρομβοειδή ντεγκραντέ diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 22a7fd3e16..02774de694 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5608,6 +5608,8 @@ effect Regular Regular High Contrast 3 Alto contraste 3 Pentagram Pentagrama Perceptual Perceptual + Hue Clockwise Tono en sentido horario + Hue Counterclockwise Tono en sentido antihorario Relative Colorimetric Colorimétrico relativo Absolute Colorimetric Colorimétrico absoluto Exact Exacto @@ -6229,7 +6231,7 @@ axis Y Axis Eje Y @msg `dither` applies to gradient fills (stroke: false) `dither` se aplica a rellenos de degradado (stroke: false) @msg `dither` applies to gradient fills (paint.setFill) `dither` se aplica a rellenos de degradado (paint.setFill) @msg `dither` must be true or false `dither` debe ser true o false -@msg `method` must be classic or perceptual `method` debe ser classic o perceptual +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` debe ser classic, perceptual, hue-clockwise o hue-counterclockwise @msg `spread` must be pad, repeat or reflect `spread` debe ser pad, repeat o reflect @msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` debe ser un número entero de 1 a {MAX_REPEATS} @msg `spread` applies to linear, radial, conic and diamond gradients `spread` se aplica a los degradados lineales, radiales, cónicos y de rombo diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 9b11a591cf..bdf47c2c82 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5585,6 +5585,8 @@ imageTrace Outline Ääriviiva Vivid Eloisa Spectrum Spektri Perceptual Havainnollinen + Hue Clockwise Sävy myötäpäivään + Hue Counterclockwise Sävy vastapäivään Relative Colorimetric Suhteellinen kolorimetrinen Absolute Colorimetric Absoluuttinen kolorimetrinen Exact Tarkka @@ -6317,7 +6319,7 @@ axis Y Axis Y-akseli @msg `dither` applies to gradient fills (stroke: false) `dither` koskee liukuväritäyttöjä (stroke: false) @msg `dither` applies to gradient fills (paint.setFill) `dither` koskee liukuväritäyttöjä (paint.setFill) @msg `dither` must be true or false `dither` on oltava true tai false -@msg `method` must be classic or perceptual `method` on oltava classic tai perceptual +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` on oltava classic, perceptual, hue-clockwise tai hue-counterclockwise @msg `spread` must be pad, repeat or reflect `spread` on oltava pad, repeat tai reflect @msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` on oltava kokonaisluku väliltä 1–{MAX_REPEATS} @msg `spread` applies to linear, radial, conic and diamond gradients `spread` koskee lineaarisia, säteittäisiä ja kartiomaisia liukuvärejä sekä vinoneliöliukuvärejä diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index b304c5cd07..9b04538758 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5753,6 +5753,8 @@ lighting Light Lumière Pen Plume Pentagram Pentagramme Perceptual Perceptif + Hue Clockwise Teinte en sens horaire + Hue Counterclockwise Teinte en sens antihoraire Phone 360×800 Téléphone 360×800 Phone 375×667 Téléphone 375×667 Phone 375×812 Téléphone 375×812 @@ -6282,7 +6284,7 @@ axis Y Axis Axe Y @msg `dither` applies to gradient fills (stroke: false) `dither` s’applique aux fonds en dégradé (stroke: false) @msg `dither` applies to gradient fills (paint.setFill) `dither` s’applique aux fonds en dégradé (paint.setFill) @msg `dither` must be true or false `dither` doit valoir true ou false -@msg `method` must be classic or perceptual `method` doit être classic ou perceptual +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` doit être classic, perceptual, hue-clockwise ou hue-counterclockwise @msg `spread` must be pad, repeat or reflect `spread` doit être pad, repeat ou reflect @msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` doit être un nombre entier de 1 à {MAX_REPEATS} @msg `spread` applies to linear, radial, conic and diamond gradients `spread` s’applique aux dégradés linéaires, radiaux, coniques et en losange diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 437573a065..02ad28dd4c 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3406,6 +3406,8 @@ antiAlias Sharp शार्प per slice प्रति स्लाइस Percent: प्रतिशत: Perceptual परसेप्चुअल + Hue Clockwise ह्यू घड़ी की दिशा में + Hue Counterclockwise ह्यू घड़ी की विपरीत दिशा में Perceptual Scales अवधारणात्मक स्केल Performance प्रदर्शन Permissions अनुमतियां @@ -6052,7 +6054,7 @@ antiAlias Sharp शार्प @msg `length` must be a number of at most 1000000 pt either way `length` को 1000000 pt में से एक होना चाहिए। @msg `library` is a library id or name (see {list}) `library` एक लाइब्रेरी आईडी या नाम है ( {list} देखें) @msg `line` must be true or false `line` को true या false होना चाहिए -@msg `method` must be classic or perceptual `method` क्लासिक या अवधारणात्मक होना चाहिए +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` classic, perceptual, hue-clockwise या hue-counterclockwise होना चाहिए @msg `spread` must be pad, repeat or reflect `spread` pad, repeat या reflect होना चाहिए @msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` 1 से {MAX_REPEATS} तक की पूर्ण संख्या होनी चाहिए @msg `spread` applies to linear, radial, conic and diamond gradients `spread` रैखिक, रेडियल, शंक्वाकार और डायमंड ग्रेडिएंट्स पर लागू होता है diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 315e660b55..4f2063df16 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3026,6 +3026,8 @@ file-filter Templates Sablonok per slice szeletenként Percent: Százalék: Perceptual Észlelési + Hue Clockwise Színárnyalat az óramutató járásával megegyezően + Hue Counterclockwise Színárnyalat az óramutató járásával ellentétesen Perceptual Scales Észlelési skálák Performance Teljesítmény diagnostics render renderelés @@ -6270,7 +6272,7 @@ axis Y Axis Y tengely @msg `dither` applies to gradient fills (stroke: false) A `dither` színátmenetes kitöltésekre vonatkozik (stroke: false) @msg `dither` applies to gradient fills (paint.setFill) A `dither` színátmenetes kitöltésekre vonatkozik (paint.setFill) @msg `dither` must be true or false `dither`: true vagy false kell -@msg `method` must be classic or perceptual a `method` értéke classic vagy perceptual lehet +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise a `method` értéke classic, perceptual, hue-clockwise vagy hue-counterclockwise lehet @msg `spread` must be pad, repeat or reflect a `spread` értéke pad, repeat vagy reflect lehet @msg `repeats` must be a whole number from 1 to {MAX_REPEATS} a `repeats` értéke 1 és {MAX_REPEATS} közötti egész szám lehet @msg `spread` applies to linear, radial, conic and diamond gradients a `spread` a lineáris, sugaras, kúpos és rombusz színátmenetekre vonatkozik diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index b94fd829db..af92584782 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5159,6 +5159,8 @@ imageTrace Outline Kontur Vivid Cerah Spectrum Spektrum Perceptual Perseptual + Hue Clockwise Rona searah jarum jam + Hue Counterclockwise Rona berlawanan arah jarum jam Relative Colorimetric Kolorimetrik relatif Absolute Colorimetric Kolorimetrik absolut Exact Eksak @@ -5971,7 +5973,7 @@ axis Y Axis Sumbu Y @msg `dither` applies to gradient fills (stroke: false) `dither` berlaku untuk isian gradien (stroke: false) @msg `dither` applies to gradient fills (paint.setFill) `dither` berlaku untuk isian gradien (paint.setFill) @msg `dither` must be true or false `dither` harus true atau false -@msg `method` must be classic or perceptual `method` harus classic atau perceptual +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` harus classic, perceptual, hue-clockwise atau hue-counterclockwise # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Instal “{name}”, yang meminta anggaran komputasi besar? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Setiap kali dijalankan, plug-in ini bisa memakan waktu hingga {minutes} menit dan melakukan {times} kali pekerjaan yang diizinkan untuk plug-in lain. Plug-in ini berjalan di latar belakang, dan Anda dapat membatalkannya. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index bc77dcc11a..d34c75ff0e 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5912,7 +5912,9 @@ axis Y Axis Asse Y @msg `dither` applies to gradient fills (paint.setFill) `dither` si applica ai riempimenti sfumati (paint.setFill) @msg `dither` must be true or false `dither` deve essere true o false Perceptual Percettivo -@msg `method` must be classic or perceptual `method` deve essere classic o perceptual + Hue Clockwise Tonalità in senso orario + Hue Counterclockwise Tonalità in senso antiorario +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` deve essere classic, perceptual, hue-clockwise o hue-counterclockwise @msg `spread` must be pad, repeat or reflect `spread` deve essere pad, repeat o reflect @msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` deve essere un numero intero da 1 a {MAX_REPEATS} @msg `spread` applies to linear, radial, conic and diamond gradients `spread` si applica alle sfumature lineari, radiali, coniche e a rombo diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index fb61d6969f..0657d1048c 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5977,7 +5977,9 @@ axis Y Axis Y 軸 @msg `dither` applies to gradient fills (paint.setFill) `dither` はグラデーションの塗りに適用されます (paint.setFill) @msg `dither` must be true or false `dither` には true または false を指定してください Perceptual 知覚的 -@msg `method` must be classic or perceptual `method` には classic または perceptual を指定してください + Hue Clockwise 時計回りの色相 + Hue Counterclockwise 反時計回りの色相 +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` には classic、perceptual、hue-clockwise または hue-counterclockwise を指定してください @msg `spread` must be pad, repeat or reflect `spread` には pad、repeat または reflect を指定してください @msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` には 1 から {MAX_REPEATS} までの整数を指定してください @msg `spread` applies to linear, radial, conic and diamond gradients `spread` は線形、円形、円錐およびひし形グラデーションに適用されます diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 10668a9e88..f8cce3e48b 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5007,6 +5007,8 @@ font-technology Variable ცვლადი High Contrast 3 მაღალი კონტრასტი 3 Pentagram პენტაგრამა Perceptual აღქმითი + Hue Clockwise ფერის ტონი საათის ისრის მიმართულებით + Hue Counterclockwise ფერის ტონი საათის ისრის საწინააღმდეგოდ Relative Colorimetric ფარდობითი კოლორიმეტრიული Absolute Colorimetric აბსოლუტური კოლორიმეტრიული Exact ზუსტი @@ -6238,7 +6240,7 @@ axis Y Axis Y ღერძი @msg `dither` applies to gradient fills (stroke: false) `dither` ვრცელდება გრადიენტულ შევსებებზე (stroke: false) @msg `dither` applies to gradient fills (paint.setFill) `dither` ვრცელდება გრადიენტულ შევსებებზე (paint.setFill) @msg `dither` must be true or false `dither` უნდა იყოს true ან false -@msg `method` must be classic or perceptual `method` უნდა იყოს classic ან perceptual +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` უნდა იყოს classic, perceptual, hue-clockwise ან hue-counterclockwise @msg `spread` must be pad, repeat or reflect `spread` უნდა იყოს pad, repeat ან reflect @msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` უნდა იყოს მთელი რიცხვი 1-დან {MAX_REPEATS}-მდე @msg `spread` applies to linear, radial, conic and diamond gradients `spread` ეხება წრფივ, რადიალურ, კონუსურ და რომბულ გრადიენტებს diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 0ec5ba8c60..8b316f36fd 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5873,7 +5873,9 @@ axis Y Axis Y축 @msg `dither` applies to gradient fills (paint.setFill) `dither`는 그레이디언트 칠에 적용됩니다 (paint.setFill) @msg `dither` must be true or false `dither`은(는) true 또는 false여야 합니다 Perceptual 지각적 -@msg `method` must be classic or perceptual `method`는 classic 또는 perceptual이어야 합니다 + Hue Clockwise 색조 시계 방향 + Hue Counterclockwise 색조 시계 반대 방향 +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method`는 classic, perceptual, hue-clockwise 또는 hue-counterclockwise여야 합니다 @msg `spread` must be pad, repeat or reflect `spread`는 pad, repeat 또는 reflect여야 합니다 @msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats`는 1에서 {MAX_REPEATS} 사이의 정수여야 합니다 @msg `spread` applies to linear, radial, conic and diamond gradients `spread`는 선형, 방사형, 원뿔형 및 다이아몬드 그레이디언트에 적용됩니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 558808ef15..12c97c2a77 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5674,6 +5674,8 @@ imageTrace Outline Contour Vivid Levendig Spectrum Spectrum Perceptual Perceptueel + Hue Clockwise Tint met de klok mee + Hue Counterclockwise Tint tegen de klok in Relative Colorimetric Relatief colorimetrisch Absolute Colorimetric Absoluut colorimetrisch Exact Exact @@ -6584,7 +6586,7 @@ axis Y Axis Y-as @msg `dither` applies to gradient fills (stroke: false) `dither` geldt voor verloopvullingen (stroke: false) @msg `dither` applies to gradient fills (paint.setFill) `dither` geldt voor verloopvullingen (paint.setFill) @msg `dither` must be true or false `dither` moet true of false zijn -@msg `method` must be classic or perceptual `method` moet classic of perceptual zijn +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` moet classic, perceptual, hue-clockwise of hue-counterclockwise zijn # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? „{name}” installeren, dat om een groot rekenbudget vraagt? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Elke run kan tot {minutes} minuten duren en {times} keer zoveel werk doen als andere plug-ins mogen. Hij draait op de achtergrond en je kunt hem annuleren. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 559ab82db0..0fadf6e8b0 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5543,6 +5543,8 @@ imageTrace Outline Kontur High Contrast 3 Wysoki kontrast 3 Pentagram Pentagram Perceptual Percepcyjna + Hue Clockwise Barwa zgodnie z ruchem wskazówek zegara + Hue Counterclockwise Barwa przeciwnie do ruchu wskazówek zegara Relative Colorimetric Kolorymetryczna względna Absolute Colorimetric Kolorymetryczna bezwzględna Exact Dokładne @@ -6141,7 +6143,7 @@ axis Y Axis Oś Y @msg `dither` applies to gradient fills (stroke: false) `dither` dotyczy wypełnień gradientem (stroke: false) @msg `dither` applies to gradient fills (paint.setFill) `dither` dotyczy wypełnień gradientem (paint.setFill) @msg `dither` must be true or false `dither` musi mieć wartość true lub false -@msg `method` must be classic or perceptual `method` musi mieć wartość classic lub perceptual +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` musi mieć wartość classic, perceptual, hue-clockwise lub hue-counterclockwise @msg `spread` must be pad, repeat or reflect `spread` musi mieć wartość pad, repeat lub reflect @msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` musi być liczbą całkowitą od 1 do {MAX_REPEATS} @msg `spread` applies to linear, radial, conic and diamond gradients `spread` dotyczy gradientów liniowych, radialnych, stożkowych i rombowych diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 91d227c2b2..636b95cb2b 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5619,6 +5619,8 @@ effect Regular Regular High Contrast 3 Alto Contraste 3 Pentagram Pentagrama Perceptual Perceptual + Hue Clockwise Tom em sentido horário + Hue Counterclockwise Tom em sentido anti-horário Relative Colorimetric Colorimétrico Relativo Absolute Colorimetric Colorimétrico Absoluto Exact Exato @@ -6202,7 +6204,7 @@ axis Y Axis Eixo Y @msg `dither` applies to gradient fills (stroke: false) `dither` se aplica a preenchimentos em gradiente (stroke: false) @msg `dither` applies to gradient fills (paint.setFill) `dither` se aplica a preenchimentos em gradiente (paint.setFill) @msg `dither` must be true or false `dither` deve ser true ou false -@msg `method` must be classic or perceptual `method` deve ser classic ou perceptual +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` deve ser classic, perceptual, hue-clockwise ou hue-counterclockwise @msg `spread` must be pad, repeat or reflect `spread` deve ser pad, repeat ou reflect @msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` deve ser um número inteiro de 1 a {MAX_REPEATS} @msg `spread` applies to linear, radial, conic and diamond gradients `spread` se aplica a gradientes lineares, radiais, cônicos e em losango diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index af80b8f23a..ff3ede5803 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5898,7 +5898,9 @@ axis Y Axis Ось Y @msg `dither` applies to gradient fills (paint.setFill) `dither` применяется к градиентным заливкам (paint.setFill) @msg `dither` must be true or false `dither` должно быть true или false Perceptual Перцепционный -@msg `method` must be classic or perceptual `method` должен быть classic или perceptual + Hue Clockwise Оттенок по часовой стрелке + Hue Counterclockwise Оттенок против часовой стрелки +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` должен быть classic, perceptual, hue-clockwise или hue-counterclockwise @msg `spread` must be pad, repeat or reflect `spread` должен быть pad, repeat или reflect @msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` должен быть целым числом от 1 до {MAX_REPEATS} @msg `spread` applies to linear, radial, conic and diamond gradients `spread` применяется к линейным, радиальным, коническим и ромбовидным градиентам diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index ee0066d62a..8ba219e234 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -5654,6 +5654,8 @@ lighting Light Ljus Pen Penna Pentagram Pentagram Perceptual Perceptuell + Hue Clockwise Nyans medurs + Hue Counterclockwise Nyans moturs Phone 360×800 Telefon 360×800 Phone 375×667 Telefon 375×667 Phone 375×812 Telefon 375×812 @@ -6309,7 +6311,7 @@ axis Y Axis Y-axel @msg `dither` applies to gradient fills (stroke: false) `dither` gäller gradientfyllningar (stroke: false) @msg `dither` applies to gradient fills (paint.setFill) `dither` gäller gradientfyllningar (paint.setFill) @msg `dither` must be true or false `dither` måste vara true eller false -@msg `method` must be classic or perceptual `method` måste vara classic eller perceptual +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` måste vara classic, perceptual, hue-clockwise eller hue-counterclockwise @msg `spread` must be pad, repeat or reflect `spread` måste vara pad, repeat eller reflect @msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` måste vara ett heltal från 1 till {MAX_REPEATS} @msg `spread` applies to linear, radial, conic and diamond gradients `spread` gäller för linjära, radiella och koniska gradienter och rombgradienter diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 19ef6ec609..fac8250c9d 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5449,6 +5449,8 @@ lighting Light Işık Pen Kalem Pentagram Pentagram Perceptual algısal + Hue Clockwise Renk tonu saat yönünde + Hue Counterclockwise Renk tonu saat yönünün tersine Phone 360×800 Telefon 360×800 Phone 375×667 Telefon 375×667 Phone 375×812 Telefon 375×812 @@ -6243,7 +6245,7 @@ axis Y Axis Y ekseni Optical Effects Optik Efektler Geometric Patterns Geometrik Desenler Grit Textures Grenli Dokular -@msg `method` must be classic or perceptual `method` classic veya perceptual olmalıdır +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` classic, perceptual, hue-clockwise veya hue-counterclockwise olmalıdır @msg `spread` must be pad, repeat or reflect `spread` pad, repeat veya reflect olmalıdır @msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` 1 ile {MAX_REPEATS} arasında bir tam sayı olmalıdır @msg `spread` applies to linear, radial, conic and diamond gradients `spread` doğrusal, radyal, konik ve elmas degradeler için geçerlidir diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 27553cde9d..d82e8a7f72 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5892,7 +5892,9 @@ axis Y Axis Вісь Y @msg `dither` applies to gradient fills (paint.setFill) `dither` застосовується до градієнтних заливок (paint.setFill) @msg `dither` must be true or false `dither` має бути true або false Perceptual Перцептивний -@msg `method` must be classic or perceptual `method` має бути classic або perceptual + Hue Clockwise Відтінок за годинниковою стрілкою + Hue Counterclockwise Відтінок проти годинникової стрілки +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` має бути classic, perceptual, hue-clockwise або hue-counterclockwise @msg `spread` must be pad, repeat or reflect `spread` має бути pad, repeat або reflect @msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` має бути цілим числом від 1 до {MAX_REPEATS} @msg `spread` applies to linear, radial, conic and diamond gradients `spread` застосовується до лінійних, радіальних, конічних і ромбоподібних градієнтів diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 531e00679e..7aa6c4cdb1 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5893,7 +5893,9 @@ axis Y Axis Trục Y @msg `dither` applies to gradient fills (paint.setFill) `dither` áp dụng cho vùng tô chuyển sắc (paint.setFill) @msg `dither` must be true or false `dither` phải là true hoặc false Perceptual Theo cảm nhận -@msg `method` must be classic or perceptual `method` phải là classic hoặc perceptual + Hue Clockwise Sắc độ theo chiều kim đồng hồ + Hue Counterclockwise Sắc độ ngược chiều kim đồng hồ +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` phải là classic, perceptual, hue-clockwise hoặc hue-counterclockwise @msg `spread` must be pad, repeat or reflect `spread` phải là pad, repeat hoặc reflect @msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` phải là số nguyên từ 1 đến {MAX_REPEATS} @msg `spread` applies to linear, radial, conic and diamond gradients `spread` áp dụng cho chuyển sắc tuyến tính, tỏa tròn, hình nón và hình thoi diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 40affa88eb..9e9a7bbb03 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5248,6 +5248,8 @@ imageTrace Outline 轮廓 Outlined Text 轮廓化的文字 All Rasterized Regions 所有栅格化区域 Perceptual 可感知 + Hue Clockwise 色相顺时针 + Hue Counterclockwise 色相逆时针 Relative Colorimetric 相对比色 Absolute Colorimetric 绝对比色 Simulate 模拟 @@ -6125,7 +6127,7 @@ axis Y Axis Y 轴 @msg `dither` applies to gradient fills (stroke: false) `dither` 适用于渐变填色 (stroke: false) @msg `dither` applies to gradient fills (paint.setFill) `dither` 适用于渐变填色 (paint.setFill) @msg `dither` must be true or false `dither` 必须是 true 或 false -@msg `method` must be classic or perceptual `method` 必须是 classic 或 perceptual +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` 必须是 classic、perceptual、hue-clockwise 或 hue-counterclockwise @msg `spread` must be pad, repeat or reflect `spread` 必须是 pad、repeat 或 reflect @msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` 必须是 1 到 {MAX_REPEATS} 之间的整数 @msg `spread` applies to linear, radial, conic and diamond gradients `spread` 适用于线性、径向、圆锥和菱形渐变 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index f51c3e9499..3afe3b2aae 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5930,7 +5930,9 @@ axis Y Axis Y 軸 @msg `dither` applies to gradient fills (paint.setFill) `dither` 適用於漸層填色 (paint.setFill) @msg `dither` must be true or false `dither` 必須是 true 或 false Perceptual 感知 -@msg `method` must be classic or perceptual `method` 必須是 classic 或 perceptual + Hue Clockwise 色相順時針 + Hue Counterclockwise 色相逆時針 +@msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` 必須是 classic、perceptual、hue-clockwise 或 hue-counterclockwise @msg `spread` must be pad, repeat or reflect `spread` 必須是 pad、repeat 或 reflect @msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` 必須是 1 到 {MAX_REPEATS} 之間的整數 @msg `spread` applies to linear, radial, conic and diamond gradients `spread` 適用於線性、放射狀、圓錐和菱形漸層 diff --git a/crates/ui-egui/src/panels/gradient.rs b/crates/ui-egui/src/panels/gradient.rs index 83f003e150..777db05811 100644 --- a/crates/ui-egui/src/panels/gradient.rs +++ b/crates/ui-egui/src/panels/gradient.rs @@ -380,20 +380,35 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } } +/// The narrowest the Method menu is drawn (points): the width it had for Classic and Perceptual. +const METHOD_MENU_MIN: f32 = 104.0; +/// The room the Method menu keeps beside its longest name for its arrow and margins (points). +const METHOD_MENU_PAD: f32 = 40.0; + /// The Method row (Illustrator 30): how a linear or radial gradient mixes the colours between its -/// stops, Classic or Perceptual. +/// stops, Classic or Perceptual. Hue Clockwise and Hue Counterclockwise, VectorCraft's own +/// methods, mix them around the hue wheel one way or the other. fn method_row(app: &mut VectorcraftApp, ui: &mut Ui, g: &GradientPaint, is_grad: bool) { - let methods = [(GradientMethod::Classic, tl!("Classic")), (GradientMethod::Perceptual, tl!("Perceptual"))]; + let methods = [ + (GradientMethod::Classic, tl!("Classic")), + (GradientMethod::Perceptual, tl!("Perceptual")), + (GradientMethod::HueClockwise, tl!("Hue Clockwise")), + (GradientMethod::HueCounterclockwise, tl!("Hue Counterclockwise")), + ]; let current = g.gradient.method; let shown = methods.iter().find(|(m, _)| *m == current).map_or("", |(_, l)| *l); ui.horizontal(|ui| { widgets::dim_label(ui, tl!("Method:")); let labels = methods.map(|(_, l)| l); - let chosen = ui.add_enabled_ui(is_grad, |ui| widgets::dropdown_names(ui, "grad-method", shown, &labels, 104.0)).inner; + // Wide enough for the longest method name in the interface language and the arrow beside + // it, within the room the row has. + let widest = labels.iter().map(|l| widgets::text_width(ui, egui::RichText::new(*l).size(12.0))).fold(0.0, f32::max); + let width = (widest + METHOD_MENU_PAD).clamp(METHOD_MENU_MIN, ui.available_width().max(METHOD_MENU_MIN)); + let chosen = ui.add_enabled_ui(is_grad, |ui| widgets::dropdown_names(ui, "grad-method", shown, &labels, width)).inner; if let Some((m, _)) = chosen.and_then(|i| methods.get(i)) && *m != current { - edit(app, json!({"method": m.label().to_lowercase()}), Live::Released); + edit(app, json!({"method": m.id()}), Live::Released); } }); } diff --git a/crates/ui-egui/src/panels/gradient/tests.rs b/crates/ui-egui/src/panels/gradient/tests.rs index 589dbc523a..d5362b8bab 100644 --- a/crates/ui-egui/src/panels/gradient/tests.rs +++ b/crates/ui-egui/src/panels/gradient/tests.rs @@ -328,6 +328,31 @@ fn the_method_menu_makes_the_gradient_perceptual() { assert_eq!(fill(&app).gradient.method, GradientMethod::Classic); } +/// The Method menu lists the two hue methods after Classic and Perceptual; choosing one mixes +/// the gradient around the hue wheel that way, as one undo step, and the menu then shows it. +#[test] +fn the_method_menu_mixes_the_gradient_around_the_hue_wheel() { + for (label, method) in [("Hue Clockwise", GradientMethod::HueClockwise), ("Hue Counterclockwise", GradientMethod::HueCounterclockwise)] { + let mut app = app(json!({"gradient": {}})); + let mut p = Panel::new(); + let texts = p.frame(&mut app, vec![], Modifiers::NONE); + let shown = texts.iter().find(|(t, _)| t == "Classic").expect("Classic shown").1; + let undo = app.session.doc().unwrap().history.undo.len(); + let texts = p.click(&mut app, shown); + for listed in ["Classic", "Perceptual", "Hue Clockwise", "Hue Counterclockwise"] { + assert!(texts.iter().any(|(t, _)| t == listed), "the menu lists {listed}: {texts:?}"); + } + let option = texts.iter().find(|(t, _)| t == label).expect("the menu lists the method").1; + p.click(&mut app, option); + assert_eq!(fill(&app).gradient.method, method, "{label}"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "{label}: one undo step"); + let texts = p.frame(&mut app, vec![], Modifiers::NONE); + assert!(texts.iter().any(|(t, _)| t == label), "the menu shows the method: {texts:?}"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(fill(&app).gradient.method, GradientMethod::Classic); + } +} + /// The Spread menu makes the gradient repeat (one undo step), and the Repeats field, dimmed while /// it pads, then sets the count. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 9930b33734..cc6c587801 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1251,7 +1251,7 @@ canvas draws it as pixels, out to the miters, caps and arrowheads of a stroke. P have no diamond gradient: one of opaque stops is written as four linear gradients, one for each quarter of the square, clipped to the shape (PDF as axial shadings). In PDF and SVG, one with translucent stops, a dithered fill or a square with no area is an image, and so is a Perceptual one, except in PDF when its stops are spot colors that the -Method paragraph below describes. SVG writes one on type characters as a radial gradient through the square's +Method paragraph below describes. One mixed around the hue wheel is always an image in PDF and SVG. SVG writes one on type characters as a radial gradient through the square's corners. PDF import reads a function-based shading (type 1) whose color is the same along each square about the origin of its space, with corners on its axes, as a diamond gradient. CSS writes a radial gradient through the square's corners. A swatch library saved as CSS gets four `linear-gradient` layers, one for each quarter of the box, @@ -1295,7 +1295,7 @@ change its colors least. Each run keeps its ends; sharp steps and swatch-linked but a run with more of them than its share of the limit loses some. The same applies to a gradient along or across a stroke, and to CSS's conic gradient, past 16,384 stops, and to Outline Stroke's mesh past 1,024. For CSS the 16,384 counts the gradient's stops before they are written: CSS then adds a stop for each midpoint, and for a Perceptual -gradient stops along each span's curve, so the written gradient can hold more. PDF +gradient or one mixed around the hue wheel stops along each span's curve, so the written gradient can hold more. PDF import finds a sharp step between two colors in an axial or radial shading by halving the spans between its samples, so the step reads back where it is in the file (to 1/64 × 2^-14 of the axis) instead of blurred across the samples around it. A shading takes at most 16,384 samples, so the steps past the point where that budget runs out stay @@ -1324,10 +1324,26 @@ stops whose other stops are all paper white or written in CMYK or gray, that is does not have too many stops (a diamond as four such shadings). An export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile. PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading. +`hue-clockwise` and `hue-counterclockwise` (VectorCraft's own methods) mix a linear, radial, conic or diamond +gradient's colors around the hue wheel: hue, saturation and brightness each change evenly from one stop to the next. +With `hue-clockwise` the hue angle falls (red, magenta, blue, cyan, green, yellow, red), which is clockwise on the +app's color wheels, and with `hue-counterclockwise` it rises. The angle keeps one direction between two stops, as the +fixed-direction rules of [CSS Color 4](https://www.w3.org/TR/css-color-4/#hue-interpolation) do, so red to blue passes +through green one way and through magenta the other. Two stops whose hues are less than half a color level apart +count as one hue and keep it, so a color and a tint or shade of it do not turn the wheel; their saturation and +brightness still change evenly, which is not the mix `classic` gives. A stop whose RGB numbers are less than half a +color level apart is gray: it takes the hue of the stop it mixes with, and a black stop takes that stop's saturation +too. SVG and PDF exports write such a gradient as an RGB image at the Document Raster Effects resolution, in a CMYK +document and with CMYK stops too, and with spot color stops (with a warning). EPS writes it as a shading in process +colors, and a legacy `.ai` file and the `.ai` editing data get the colors between its stops as stops of their own, in +process colors (at most 256, with a warning). `reverse` turns a hue method the other way with the stops, so the ramp +keeps its colors, and a Reflect spread mirrors the colors of each run. The names also read in any case, and with a +space or an underscore for the hyphen, or without it. `paint.setFill`/`paint.setStroke` take it in `gradient` too: ```json {"name":"run_command","arguments":{"command":"paint.editGradient","params":{"method":"perceptual"}}} +{"name":"run_command","arguments":{"command":"paint.editGradient","params":{"method":"hue-clockwise"}}} ``` Freeform gradients: `paint.editGradient {kind: "freeform"}` places four or more colour points inside each selected @@ -4519,7 +4535,7 @@ paper white, 0 %) exports to PDF as a Separation shading, and one that mixes spo inks exports as a DeviceN shading. A gradient that mixes a spot ink with colors written in RGB or Lab is written in process colors, with a warning; an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile. Freeform gradients and dithered fills export to PDF as images in process colors, and Method -above covers Perceptual gradients. +above covers Perceptual gradients and gradients mixed around the hue wheel. ```json {"name":"run_command","arguments":{"command":"paint.editGradient","params":{"stops":[{"offset":0,"swatch":"Ink"},{"offset":1,"swatch":"Ink","tint":20}]}}} diff --git a/docs/plugins.md b/docs/plugins.md index a932983acd..57226411c5 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -617,7 +617,7 @@ An **importer** gets the bytes of the file being opened, as they are (not JSON), `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}], "dither"?, "method"?: "Perceptual"}, "geom"?, "angle", "freeform"?}`, `{"type": "pattern", "pattern", + midpoint}], "dither"?, "method"?: "Perceptual"|"HueClockwise"|"HueCounterclockwise"}, "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. - `effects` (filters): the object's own effects in order, as its effect records: `id` (as `effect.list` lists it, or diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f9c6855f68..7efed9aa96 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1432,6 +1432,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The Gradient panel's Delete Stop sits at the right end of the ramp, level with it, so a panel too short to show the stop fields still shows it; it deletes the selected stop (never below two), as dragging a stop down off the ramp does (#5403). - Conic gradients, VectorCraft's own gradient type: the stops sweep once around the center from the vector, clockwise on the page (a negative aspect, or a reflection, turns the sweep the other way), with a Conic button in the Gradient panel's Type row and the Gradient tool's extent ellipse and aspect handle; they follow transforms, Expand into wedges or a mesh, open from `.af` files, and export to PDF as a function-based shading that PDF import reads back as a conic gradient; SVG, EPS and legacy `.ai` files get solid wedges, and CSS a `conic-gradient`. - Diamond gradients, VectorCraft's own gradient type: the stops run from the center out to a square with a corner at the end of the vector (the aspect stretches it into a rhombus), with a Diamond button in the Gradient panel's Type row and the Gradient tool's square and aspect handle; they follow transforms, Expand into nested squares or a mesh, and export to PDF, SVG, EPS and legacy `.ai` as four linear gradients, one for each quarter of the square (an image in PDF and SVG for translucent stops, a dithered fill, the Perceptual method other than spot color shadings in PDF, or a square with no area), and on type characters in SVG as a radial gradient; PDF import reads a function-based shading whose color is the same along each square about its origin as a diamond gradient. + - Hue Clockwise and Hue Counterclockwise, VectorCraft's own gradient methods, in the Gradient panel's Method menu and on `paint.editGradient` and `paint.setFill` (`method`: `hue-clockwise`, `hue-counterclockwise`): a linear, radial, conic or diamond gradient's colors mix around the hue wheel, with hue, saturation and brightness each changing evenly from one stop to the next and the hue angle keeping one direction, as the fixed-direction rules of [CSS Color 4](https://www.w3.org/TR/css-color-4/#hue-interpolation) do: falling with Hue Clockwise (red, magenta, blue), which is clockwise on the color wheels, and rising with Hue Counterclockwise (red, yellow, green). Hues less than half a color level apart count as one hue, so a color and its tint do not turn the wheel. A gray stop takes the hue of the stop it mixes with, and black its saturation too. The canvas and raster exports draw it through stops on that curve, within half a color level; Reverse Gradient turns the method the other way with the stops, so the ramp keeps its colors. SVG and PDF write it as an RGB image, EPS as a shading in process colors, and a legacy `.ai` file and the `.ai` editing data with the colors between its stops as stops of their own, with a warning. - Gradient spread, VectorCraft's own setting: Pad, Repeat or Reflect, with a Repeats count of 1 to 100, in the Gradient panel and on `paint.editGradient` and `paint.setFill`; Repeat and Reflect run a linear, radial, conic or diamond gradient's stops that many times along the vector and on past its ends, the Gradient tool marks where the runs meet, and SVG writes and reads `spreadMethod`, CSS writes repeating gradients, PDF writes a conic's sweep shading over one run and the other kinds with their stops repeated across the shape, as EPS, legacy `.ai` and the `.ai` editing data do. - 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)).