Rework Gradient into a newtype of List<Color> with optional position and midpoint attributes (#4397)

* Rework Gradient into a newtype of List<Color> with optional position and midpoint attributes

* Fix Vello stopless-gradient fallback coverage, empty legacy gradient tables, the node docs gradient swatch, NaN position elision, and wired setter input overwrites
This commit is contained in:
Keavon Chambers
2026-08-03 04:05:02 -07:00
committed by GitHub
parent e52a442504
commit 2f24459344
34 changed files with 1121 additions and 475 deletions

View File

@@ -25,7 +25,6 @@ use std::marker::PhantomData;
use std::str::FromStr;
pub use std::sync::Arc;
use text_nodes::Font;
use text_nodes::vector_types::GradientStop;
use vector::VectorModification;
pub struct TaggedValueTypeError;
@@ -83,7 +82,7 @@ macro_rules! tagged_value {
/// (Old documents stored a bare `TypeDescriptor` payload, routed to this shape by `deserialize_tagged_value_with_legacy_migration`.)
TypeDefault(Type),
/// Stored compactly as a `Vec<f64>`, materializes as `List<f64>` at runtime via `to_dynany`/`to_any`. Aliases recover legacy on-disk shapes.
#[serde(deserialize_with = "core_types::misc::migrate_to_f64_array")] // TODO: Eventually remove this migration document upgrade code
#[serde(deserialize_with = "core_types::misc::migrate_to_f64_array")] // TODO: Eventually remove this document upgrade code
#[serde(alias = "F64Table", alias = "VecF64", alias = "VecF32", alias = "F64Array4")]
F64Array(Vec<f64>),
/// Stored compactly as a `Vec<f64>` of dash lengths, materializes as an `Item<DashPattern>` at runtime via `to_dynany`/`to_any`.
@@ -92,16 +91,15 @@ macro_rules! tagged_value {
BoxCorners(Vec<f64>),
/// A plain, always-present color. Aliases recover legacy on-disk shapes; a legacy `null` payload (the old "no color")
/// is routed to [`TaggedValue::no_paint`] by `deserialize_tagged_value_with_legacy_migration`.
#[serde(deserialize_with = "core_types::misc::migrate_to_color")] // TODO: Eventually remove this migration document upgrade code
#[serde(deserialize_with = "core_types::misc::migrate_to_color")] // TODO: Eventually remove this document upgrade code
#[serde(alias = "ColorTable", alias = "OptionalColor", alias = "ColorNotInTable")]
Color(Color),
/// Stored compactly as a `Gradient`, materializing as an `Item<Gradient>` at runtime. Aliases recover legacy on-disk shapes.
/// Stored as the `{ color, position?, midpoint? }` stops struct, materializing as an `Item<Gradient>` at runtime. Aliases recover legacy on-disk shapes.
/// (Old documents that stored a full `Gradient` struct under this same `"Gradient"` tag are routed to `LegacyGradient` by `deserialize_tagged_value_with_legacy_migration`.)
#[serde(deserialize_with = "graphic_types::vector_types::gradient::migrate_to_gradient")] // TODO: Eventually remove this migration document upgrade code
#[serde(alias = "GradientTable", alias = "GradientPositions", alias = "GradientStops")]
Gradient(Gradient),
/// Stored compactly as a `Vec<BrushStroke>`, materializes as the single-value `Item<BrushTrace>` at runtime via `to_dynany`/`to_any`. Aliases recover legacy on-disk shapes.
#[serde(deserialize_with = "brush_nodes::migrations::migrate_to_brush_strokes")] // TODO: Eventually remove this migration document upgrade code
#[serde(deserialize_with = "brush_nodes::migrations::migrate_to_brush_strokes")] // TODO: Eventually remove this document upgrade code
#[serde(alias = "BrushStrokeTable")]
BrushStrokes(Vec<BrushStroke>),
// =======================
@@ -637,29 +635,13 @@ impl TaggedValue {
fn to_gradient(input: &str) -> Option<Gradient> {
// String syntax: (e.g. "000000ff, ff0000ff")
let stops = input.split(',').filter_map(|s| to_color(s.trim())).collect::<Vec<_>>();
if stops.len() == 1 {
Some(Gradient::new(vec![
GradientStop {
position: 0.,
midpoint: 0.5,
color: stops[0],
},
GradientStop {
position: 1.,
midpoint: 0.5,
color: stops[0],
},
]))
} else if stops.len() >= 2 {
let step = 1. / (stops.len() - 1) as f64;
Some(Gradient::new(stops.into_iter().enumerate().map(|(i, color)| GradientStop {
position: i as f64 * step,
midpoint: 0.5,
color,
})))
} else {
log::error!("Invalid default value gradient string: {input}");
None
match stops.len() {
0 => {
log::error!("Invalid default value gradient string: {input}");
None
}
1 => Some(Gradient::from(vec![stops[0], stops[0]])),
_ => Some(Gradient::from(stops)),
}
}
@@ -755,10 +737,11 @@ impl TaggedValue {
/// - non-empty → `TaggedValue::VectorModification(<built from first element>)` (the document_migration's Path pass disambiguates this between SVG-import legacy and a discardable modern baked value via the input's `exposed` flag)
/// - empty → `TaggedValue::TypeDefault(list!(Vector))`
/// - `FillChoice` → `TaggedValue::Color` (solid), `TaggedValue::Gradient` (gradient), or `TaggedValue::no_paint()` (none)
/// - `Gradient` (or alias `GradientTable`/`GradientPositions`/`GradientStops`) → `TaggedValue::LegacyGradient` (ancient full struct) or `TaggedValue::Gradient` (stops shapes, unwrapped from the legacy table form)
/// - `TypeDefault` with the old bare-`TypeDescriptor` payload → the same variant wrapping a `Type` (name-encoded `List` normalized to structural)
///
/// All other tags (including ones with the modern shape) fall through to the standard derived `Deserialize` for `TaggedValue`.
// TODO: Eventually remove this migration document upgrade code
// TODO: Eventually remove this document upgrade code
#[cfg(feature = "loading")]
pub fn deserialize_tagged_value_with_legacy_migration<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<MemoHash<TaggedValue>, D::Error> {
use serde::Deserialize;
@@ -822,11 +805,30 @@ pub fn deserialize_tagged_value_with_legacy_migration<'de, D: serde::Deserialize
}
return Ok(MemoHash::new(TaggedValue::no_paint()));
}
// The `Gradient` tag was reused: it used to carry a full `Gradient` struct (now `LegacyGradient`), and now carries an `Option<Gradient>`.
// Disambiguate by payload shape: a Gradient struct has `start`/`end` keys; a `Gradient` has none of those (it has `position`/`midpoint`/`color`).
"Gradient" if content.as_object().is_some_and(|c| c.contains_key("start") && c.contains_key("end")) => {
let gradient: graphic_types::migrations::legacy::LegacyGradient = serde_json::from_value(content.clone()).map_err(serde::de::Error::custom)?;
return Ok(MemoHash::new(TaggedValue::LegacyGradient(gradient)));
// The gradient tags carried several shapes over time, disambiguated here: the ancient full struct (`start`/`end` keys) becomes `LegacyGradient`,
// while the current stops struct, the old tuple list, and the legacy one-element table wrapper all parse as the stops value directly
"Gradient" | "GradientTable" | "GradientPositions" | "GradientStops" => {
let table_element = content
.as_object()
.and_then(|c| c.get("element").or_else(|| c.get("instance")).or_else(|| c.get("instances")))
.and_then(|element| element.as_array());
// An empty legacy table wrapper carries no gradient, degrading to the default rather than failing the document load
if let Some(array) = table_element
&& array.is_empty()
{
return Ok(MemoHash::new(TaggedValue::Gradient(Gradient::default())));
}
let payload = table_element.and_then(|array| array.first()).unwrap_or(content);
if payload.as_object().is_some_and(|c| c.contains_key("start") && c.contains_key("end")) {
let gradient: graphic_types::migrations::legacy::LegacyGradient = serde_json::from_value(payload.clone()).map_err(serde::de::Error::custom)?;
return Ok(MemoHash::new(TaggedValue::LegacyGradient(gradient)));
}
let gradient: Gradient = serde_json::from_value(payload.clone()).map_err(serde::de::Error::custom)?;
return Ok(MemoHash::new(TaggedValue::Gradient(gradient)));
}
_ => {}
}