Convert the node catalog to rank-polymorphic Item and List kernels, materialize stored values as ranked wires, and display wire rank in the graph

This commit is contained in:
Keavon Chambers
2026-07-20 21:27:13 -07:00
committed by Dennis Kobert
parent ead622b969
commit 8bddd5680b
46 changed files with 1281 additions and 456 deletions

View File

@@ -6,7 +6,7 @@ use brush_nodes::brush_stroke::BrushStroke;
use core_types::color::SRGBA8;
use core_types::context::Context;
use core_types::gpoll::GPoll;
use core_types::list::List;
use core_types::list::{Item, List};
use core_types::registry::SourceHandle;
use core_types::transform::Footprint;
use core_types::uuid::NodeId;
@@ -125,7 +125,7 @@ macro_rules! tagged_value {
// =======================
// NON-SERIALIZED VARIANTS
// =======================
Self::NodeIdPath(path) => path.hash(state),
Self::NodeIdPath(path) => path.cache_hash(state),
Self::DocumentNode(node) => node.cache_hash(state),
Self::ContextModification(modification) => modification.cache_hash(state),
Self::RenderOutput(x) => x.cache_hash(state),
@@ -169,7 +169,7 @@ macro_rules! tagged_value {
// =======================
// AUTO-GENERATED VARIANTS
// =======================
$( Self::$identifier(x) => Box::new(x), )*
$( Self::$identifier(x) => Box::new(Item::new_from_element(x)), )*
// =======================
// NON-SERIALIZED VARIANTS
// =======================
@@ -178,7 +178,7 @@ macro_rules! tagged_value {
Self::DocumentNode(node) => Box::new(node),
Self::ContextModification(modification) => Box::new(modification),
Self::EditorApi(x) => Box::new(x),
Self::ResourceHash(x) => Box::new(x),
Self::ResourceHash(x) => Box::new(Item::new_from_element(x)),
}
}
@@ -213,7 +213,7 @@ macro_rules! tagged_value {
// =======================
// AUTO-GENERATED VARIANTS
// =======================
$( Self::$identifier(x) => Arc::new(x), )*
$( Self::$identifier(x) => Arc::new(Item::new_from_element(x)), )*
// =======================
// NON-SERIALIZED VARIANTS
// =======================
@@ -222,7 +222,7 @@ macro_rules! tagged_value {
Self::DocumentNode(node) => Arc::new(node),
Self::ContextModification(modification) => Arc::new(modification),
Self::EditorApi(x) => Arc::new(x),
Self::ResourceHash(x) => Arc::new(x),
Self::ResourceHash(x) => Arc::new(Item::new_from_element(x)),
}
}
@@ -397,10 +397,11 @@ macro_rules! tagged_value {
// AUTO-GENERATED VARIANTS
// =======================
$( x if x == TypeId::of::<$ty>() => Ok(TaggedValue::$identifier(*downcast(input).unwrap())), )*
$( x if x == TypeId::of::<Item<$ty>>() => Ok(TaggedValue::$identifier(downcast::<Item<$ty>>(input).unwrap().into_element())), )*
// =======================
// NON-SERIALIZED VARIANTS
// =======================
x if x == TypeId::of::<RenderOutput>() => Ok(TaggedValue::RenderOutput(*downcast(input).unwrap())),
x if x == TypeId::of::<Item<RenderOutput>>() => Ok(TaggedValue::RenderOutput(downcast::<Item<RenderOutput>>(input).unwrap().into_element())),
_ => Err(format!("Cannot convert {:?} to TaggedValue", DynAny::type_name(input.as_ref()))),
}
@@ -419,10 +420,11 @@ macro_rules! tagged_value {
// AUTO-GENERATED VARIANTS
// =======================
$( x if x == TypeId::of::<$ty>() => Ok(TaggedValue::$identifier(<$ty as Clone>::clone(input.downcast_ref().unwrap()))), )*
$( x if x == TypeId::of::<Item<$ty>>() => Ok(TaggedValue::$identifier(Item::<$ty>::clone(input.downcast_ref().unwrap()).into_element())), )*
// =======================
// NON-SERIALIZED VARIANTS
// =======================
x if x == TypeId::of::<RenderOutput>() => Ok(TaggedValue::RenderOutput(RenderOutput::clone(input.downcast_ref().unwrap()))),
x if x == TypeId::of::<Item<RenderOutput>>() => Ok(TaggedValue::RenderOutput(Item::<RenderOutput>::clone(input.downcast_ref().unwrap()).into_element())),
_ => Err(format!("Cannot convert {:?} to TaggedValue", std::any::type_name_of_val(input))),
}
}
@@ -546,8 +548,6 @@ tagged_value! {
LegacyOptionalDAffine2(Option<DAffine2>),
#[serde(alias = "FillGradient")]
LegacyGradient(graphic_types::migrations::legacy::LegacyGradient),
#[serde(alias = "Fill")]
LegacyFill(graphic_types::migrations::legacy::LegacyFill),
// ==========
// ENUM TYPES
// ==========
@@ -589,6 +589,9 @@ tagged_value! {
BooleanOperation(vector::misc::BooleanOperation),
TextAlign(text_nodes::TextAlign),
ScaleType(core_types::transform::ScaleType),
// Legacy
#[serde(alias = "Fill")]
LegacyFill(graphic_types::migrations::legacy::LegacyFill),
}
impl TaggedValue {
@@ -729,6 +732,9 @@ impl TaggedValue {
// The Fill and Stroke nodes' paint connectors default to `List<Graphic>`, their first registered implementation row
() if ty == TypeId::of::<List<Graphic>>() => to_color(string).map(TaggedValue::Color)?,
() if ty == TypeId::of::<List<Gradient>>() => to_gradient(string).map(TaggedValue::Gradient)?,
// A paint default also parses against the bare element forms, as a color or gradient literal
() if ty == TypeId::of::<Graphic>() => to_color(string).map(TaggedValue::Color)?,
() if ty == TypeId::of::<Gradient>() => to_gradient(string).map(TaggedValue::Gradient)?,
() if ty == TypeId::of::<ReferencePoint>() => to_reference_point(string).map(TaggedValue::ReferencePoint)?,
() if ty == TypeId::of::<DashPattern>() => TaggedValue::DashPattern(DashPattern::from(string)),
() if ty == TypeId::of::<BoxCorners>() => TaggedValue::BoxCorners(BoxCorners::from(string)),
@@ -1018,6 +1024,23 @@ mod record_defaults {
mod paint_default_parsing {
use super::*;
/// A Fill/Stroke paint wire carries `Graphic` elements, so its `Color::BLACK` default must parse
/// into a `Color` for a fresh Fill node's paint to resolve.
#[test]
fn paint_wire_parses_color_default_through_its_element() {
let black = Some(TaggedValue::Color(Color::BLACK));
assert_eq!(
TaggedValue::from_primitive_string("Color::BLACK", &concrete!(List<Graphic>)),
black,
"a `List<Graphic>` paint wire should resolve its color default"
);
assert_eq!(
TaggedValue::from_primitive_string("Color::BLACK", &concrete!(Graphic)),
black,
"a bare `Graphic` paint element should resolve its color default"
);
}
/// Table-era documents stored the red-slash "no paint" fill as an empty color table, which must keep
/// deserializing to [`TaggedValue::no_paint`] rather than collapsing to a transparent color.
#[test]

View File

@@ -1003,6 +1003,9 @@ impl TypingContext {
/// Returns the inferred types for a given node id.
pub fn infer(&mut self, node_id: NodeId, node: &ProtoNode) -> Result<NodeIOTypes, GraphErrors> {
if node_id == NodeId(3480994800604782060) {
log::error!("PROBE {node_id:?}: identifier={:?} args={:?}", node.identifier, node.construction_args);
}
// Return the inferred type if it is already known
if let Some(inferred) = self.inferred.get(&node_id) {
return Ok(inferred.clone());

View File

@@ -16,7 +16,7 @@ wgpu = ["dep:raster-types", "raster-types/wgpu"]
# Local dependencies
dyn-any = { workspace = true }
core-types = { workspace = true }
graphene-hash = { workspace = true }
graphene-hash = { workspace = true, features = ["derive"] }
vector-types = { workspace = true }
text-nodes = { workspace = true }
graphene-resource = { workspace = true }

View File

@@ -1,6 +1,7 @@
use core_types::transform::Footprint;
use dyn_any::{DynAny, StaticType, StaticTypeSized};
use glam::DVec2;
use graphene_hash::CacheHash;
use std::fmt::Debug;
use std::hash::{Hash, Hasher};
use std::ptr::addr_of;
@@ -61,7 +62,7 @@ pub trait GetEditorPreferences {
fn max_render_region_area(&self) -> u32;
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, graphene_hash::CacheHash)]
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, CacheHash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum ExportFormat {
#[default]
@@ -69,14 +70,14 @@ pub enum ExportFormat {
Raster,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, DynAny, graphene_hash::CacheHash)]
#[derive(Debug, Default, Clone, Copy, PartialEq, DynAny, CacheHash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct TimingInformation {
pub time: f64,
pub animation_time: Duration,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, DynAny, graphene_hash::CacheHash)]
#[derive(Debug, Default, Clone, Copy, PartialEq, DynAny, CacheHash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct RenderConfig {
pub viewport: Footprint,
@@ -136,7 +137,7 @@ impl<Io> Hash for EditorApi<Io> {
}
}
impl<Io> core_types::graphene_hash::CacheHash for EditorApi<Io> {
impl<Io> CacheHash for EditorApi<Io> {
fn cache_hash<H: core::hash::Hasher>(&self, state: &mut H) {
core::hash::Hash::hash(self, state);
}

View File

@@ -601,6 +601,11 @@ impl ItemAttributeValues {
self.0.iter().map(|(key, value)| (key.as_str(), &**value))
}
/// Returns a type-erased reference to the value of the attribute with the given key, if it exists.
pub fn get_any(&self, key: &str) -> Option<&dyn std::any::Any> {
self.0.iter().find_map(|(existing_key, value)| if existing_key == key { Some((**value).as_any()) } else { None })
}
/// Returns a debug-formatted string representation of the attribute value for the given key, if it exists.
/// The `overrides` function can provide custom formatting for specific type.
pub fn display_value(&self, key: &str, overrides: fn(&dyn std::any::Any) -> Option<String>) -> Option<String> {
@@ -1326,6 +1331,10 @@ impl<T> Item<T> {
}
}
unsafe impl<T: StaticTypeSized> StaticType for Item<T> {
type Static = Item<T::Static>;
}
// ===========
// ItemIter<T>
// ===========

View File

@@ -61,6 +61,27 @@ impl Clampable for DVec2 {
}
}
// Implement for ranked wires (element-wise clamping across the frame)
use crate::list::{Item, List};
impl<T: Clampable> Clampable for Item<T> {
fn clamp_hard_min(self, min: f64) -> Self {
let (element, attributes) = self.into_parts();
Item::from_parts(element.clamp_hard_min(min), attributes)
}
fn clamp_hard_max(self, max: f64) -> Self {
let (element, attributes) = self.into_parts();
Item::from_parts(element.clamp_hard_max(max), attributes)
}
}
impl<T: Clampable> Clampable for List<T> {
fn clamp_hard_min(self, min: f64) -> Self {
self.into_iter().map(|item| item.clamp_hard_min(min)).collect()
}
fn clamp_hard_max(self, max: f64) -> Self {
self.into_iter().map(|item| item.clamp_hard_max(max)).collect()
}
}
#[cfg(feature = "serde")]
#[derive(serde::Deserialize)]
struct LegacyTable<T> {

View File

@@ -77,8 +77,7 @@ impl Convert<DVec2, ()> for DVec2 {
}
/// Constructs `Self` from a single anchor point at the given position. Implemented by the vector crate's
/// path type so the `Convert` impl below can build a single-point path without core-types depending on
/// that crate (mirroring how [`ListConvert`] bridges per-item list conversions).
/// path type so a position wire can convert to a single-point path without core-types depending on that crate.
pub trait FromAnchorPosition {
fn from_anchor_position(position: DVec2) -> Self;
}

View File

@@ -217,6 +217,23 @@ impl From<()> for Footprint {
}
}
/// Consumes an item's `transform` attribute by baking it into the underlying value itself.
pub trait BakeTransform {
fn bake_transform(&mut self, transform: &DAffine2);
}
impl BakeTransform for DAffine2 {
fn bake_transform(&mut self, transform: &DAffine2) {
*self = *transform * *self;
}
}
impl BakeTransform for DVec2 {
fn bake_transform(&mut self, transform: &DAffine2) {
*self = transform.transform_point2(*self);
}
}
pub trait ApplyTransform {
fn apply_transform(&mut self, modification: &DAffine2);
fn left_apply_transform(&mut self, modification: &DAffine2);

View File

@@ -1767,7 +1767,7 @@ fn render_vector_vello<S: LaneSource<Element = Vector>>(source: &S, scene: &mut
}
fn collect_vector_metadata<S: LaneSource<Element = Vector>>(source: &S, metadata: &mut RenderMetadata, footprint: Footprint, caller_element_id: Option<NodeId>) {
// Aggregate all items' targets per element_id so multi-item lists (e.g. 'Text' node with "Separate Glyphs" active) produce hit areas for every glyph.
// Aggregate all items' targets per element_id so multi-item lists (e.g. the "Text to Vector Glyphs" node) produce hit areas for every glyph.
// Targets are baked relative to item 0's transform since `Graphic::collect_metadata` records that as `local_transforms[element_id]`.
let item_zero_transform: DAffine2 = if source.lane_count() > 0 { source.attr::<Transform>(0) } else { DAffine2::IDENTITY };
let item_zero_inverse = if transform_is_invertible(item_zero_transform) {

View File

@@ -63,6 +63,13 @@ impl core_types::ops::FromAnchorPosition for Vector {
}
}
// Lets a position wire feed a ranked vector connector through the input adapter's element conversion
impl From<DVec2> for Vector {
fn from(position: DVec2) -> Self {
<Self as core_types::ops::FromAnchorPosition>::from_anchor_position(position)
}
}
// Identity item conversion so `List<Vector>` satisfies the blanket `Convert<List<U>, ()> for List<T>`, letting its
// auto-inserted input wrapper be a `ConvertNode` (which also accepts a `DVec2` anchor position) rather than an `IntoNode`.
impl core_types::ops::ListConvert<Vector> for Vector {
@@ -71,6 +78,15 @@ impl core_types::ops::ListConvert<Vector> for Vector {
}
}
impl core_types::transform::BakeTransform for Vector {
fn bake_transform(&mut self, transform: &glam::DAffine2) {
for (_, point) in self.point_domain.positions_mut() {
*point = transform.transform_point2(*point);
}
self.segment_domain.transform(*transform);
}
}
impl Vector {
/// Add a subpath to this vector path.
pub fn append_subpath(&mut self, subpath: impl Borrow<Subpath<PointId>>, preserve_id: bool) {

View File

@@ -20,7 +20,7 @@ fn upload_to_texture(device: &wgpu::Device, queue: &wgpu::Queue, image: &Raster<
device.create_texture_with_data(
queue,
&TextureDescriptor {
label: Some("upload_texture node texture"),
label: Some("upload_to_texture staging texture"),
size: Extent3d {
width: image.width,
height: image.height,

View File

@@ -458,6 +458,7 @@ pub fn blend_stamp_closure(foreground: BrushStampGenerator<Color>, mut backgroun
#[cfg(test)]
mod test {
use super::*;
use crate::brush_stroke::BrushStroke;
use core_types::transform::Transform;
use glam::DAffine2;

View File

@@ -1,6 +1,7 @@
use core_types::CacheHash;
use core_types::blending::BlendMode;
use core_types::color::Color;
use core_types::list::{Item, List};
use core_types::math::bbox::AxisAlignedBbox;
use dyn_any::DynAny;
use glam::DVec2;
@@ -57,6 +58,22 @@ pub struct BrushStroke {
pub trace: Vec<BrushInputSample>,
}
/// One Brush layer's full sequence of strokes, treated as a single rank-0 value rather than a frame of independent strokes.
#[derive(Default, Debug, Clone, PartialEq, CacheHash, DynAny)]
pub struct BrushTrace(pub List<BrushStroke>);
impl From<List<BrushStroke>> for BrushTrace {
fn from(strokes: List<BrushStroke>) -> Self {
Self(strokes)
}
}
impl From<Vec<BrushStroke>> for BrushTrace {
fn from(strokes: Vec<BrushStroke>) -> Self {
Self(strokes.into_iter().map(Item::new_from_element).collect())
}
}
impl BrushStroke {
pub fn bounding_box(&self) -> AxisAlignedBbox {
let radius = self.style.diameter / 2.;

View File

@@ -74,15 +74,23 @@ fn quantize_real_time<T>(
Context -> DAffine2,
Context -> Footprint,
Context -> DVec2,
Context -> Vector,
Context -> Graphic,
Context -> Raster<CPU>,
Context -> Raster<GPU>,
Context -> Color,
Context -> Gradient,
Context -> Artboard,
Context -> List<String>,
Context -> List<f64>,
Context -> List<DVec2>,
Context -> List<Vector>,
Context -> List<Graphic>,
Context -> List<Raster<CPU>>,
Context -> List<Raster<GPU>>,
Context -> List<Color>,
Context -> List<Artboard>,
Context -> List<Gradient>,
Context -> List<String>,
Context -> List<f64>,
Context -> List<Artboard>,
Context -> (),
)]
value: impl Node<Context<'_>, Output = T>,
@@ -114,15 +122,23 @@ fn quantize_animation_time<T>(
Context -> DAffine2,
Context -> Footprint,
Context -> DVec2,
Context -> Vector,
Context -> Graphic,
Context -> Raster<CPU>,
Context -> Raster<GPU>,
Context -> Color,
Context -> Gradient,
Context -> Artboard,
Context -> List<String>,
Context -> List<f64>,
Context -> List<DVec2>,
Context -> List<Vector>,
Context -> List<Graphic>,
Context -> List<Raster<CPU>>,
Context -> List<Raster<GPU>>,
Context -> List<Color>,
Context -> List<Artboard>,
Context -> List<Gradient>,
Context -> List<String>,
Context -> List<f64>,
Context -> List<Artboard>,
Context -> (),
)]
value: impl Node<Context<'_>, Output = T>,

View File

@@ -9,6 +9,7 @@ fn passthrough<T: Send>(_: impl Ctx, content: T) -> T {
content
}
/// Shifts a whole wire value onto a connector's type through the std `Into` trait, serving the whole-`List` erasure onto `ListDyn` under the input adapter identifier.
#[node_macro::node(category(""), skip_impl)]
fn into<T: Send + Into<O>, O: Send>(_: impl Ctx, value: T, #[data] _out_ty: PhantomData<O>) -> O {
value.into()

View File

@@ -673,12 +673,16 @@ pub fn flatten_gradient<T: IntoGraphicList>(_: impl Ctx, #[implementations(List<
}
/// Constructs a gradient from a `Color[]`, where the colors are evenly distributed as gradient stops across the range from 0 to 1.
#[node_macro::node(category("Color"))]
fn colors_to_gradient(_: impl Ctx, colors: IList<Color>) -> Gradient {
#[node_macro::node(category("Color"), name("Colors to Gradient"))]
fn colors_to_gradient<T: IntoGraphicList>(_: impl Ctx, #[implementations(List<Graphic>, List<Color>)] colors: T) -> Gradient {
let colors = colors.into_flattened_list::<Color>();
let stop = |position: f64, color: Color| GradientStop { position, midpoint: 0.5, color };
match colors.len() {
0 => Gradient::new(vec![stop(0., Color::BLACK), stop(1., Color::BLACK)]),
1 => Gradient::new(vec![stop(0., colors.get(0)), stop(1., colors.get(0))]),
total => Gradient::new((0..total).map(|index| stop(index as f64 / (total - 1) as f64, colors.get(index)))),
1 => Gradient::new(vec![
stop(0., colors.element(0).copied().unwrap_or(Color::BLACK)),
stop(1., colors.element(0).copied().unwrap_or(Color::BLACK)),
]),
total => Gradient::new(colors.into_iter().enumerate().map(|(index, row)| stop(index as f64 / (total - 1) as f64, row.into_element()))),
}
}

View File

@@ -41,7 +41,7 @@ fn math<T: num_traits::float::Float>(
#[implementations(f64, f32)]
operand_a: T,
/// A math expression that may incorporate "A" and/or "B", such as `sqrt(A + B) - B^2`.
#[default(A + B)]
#[default("A + B")]
expression: String,
/// The value of "B" when calculating the expression.
#[implementations(f64, f32)]
@@ -517,10 +517,10 @@ fn absolute_value<T: AbsoluteValue>(
fn min<T: std::cmp::PartialOrd>(
_: impl Ctx,
/// One of the two numbers, of which the lesser is returned.
#[implementations(f64, f32, u32, &str)]
#[implementations(f64, f32, u32, String)]
value: T,
/// The other of the two numbers, of which the lesser is returned.
#[implementations(f64, f32, u32, &str)]
#[implementations(f64, f32, u32, String)]
other_value: T,
) -> T {
if value < other_value { value } else { other_value }
@@ -531,10 +531,10 @@ fn min<T: std::cmp::PartialOrd>(
fn max<T: std::cmp::PartialOrd>(
_: impl Ctx,
/// One of the two numbers, of which the greater is returned.
#[implementations(f64, f32, u32, &str)]
#[implementations(f64, f32, u32, String)]
value: T,
/// The other of the two numbers, of which the greater is returned.
#[implementations(f64, f32, u32, &str)]
#[implementations(f64, f32, u32, String)]
other_value: T,
) -> T {
if value > other_value { value } else { other_value }
@@ -545,13 +545,13 @@ fn max<T: std::cmp::PartialOrd>(
fn clamp<T: std::cmp::PartialOrd>(
_: impl Ctx,
/// The number to be clamped, which is restricted to the range between the minimum and maximum values.
#[implementations(f64, f32, u32, &str)]
#[implementations(f64, f32, u32, String)]
value: T,
/// The left (smaller) side of the range. The output is never less than this number.
#[implementations(f64, f32, u32, &str)]
#[implementations(f64, f32, u32, String)]
min: T,
/// The right (greater) side of the range. The output is never greater than this number.
#[implementations(f64, f32, u32, &str)]
#[implementations(f64, f32, u32, String)]
#[default(1)]
max: T,
) -> T {
@@ -678,10 +678,10 @@ fn greater_than<T: std::cmp::PartialOrd<T>>(
fn equals<T: std::cmp::PartialEq<T>>(
_: impl Ctx,
/// One of the two values to compare for equality.
#[implementations(f64, f32, u32, DVec2, bool, &str, String)]
#[implementations(f64, f32, u32, DVec2, bool, String)]
value: T,
/// The other of the two values to compare for equality.
#[implementations(f64, f32, u32, DVec2, bool, &str, String)]
#[implementations(f64, f32, u32, DVec2, bool, String)]
other_value: T,
) -> bool {
other_value == value
@@ -692,10 +692,10 @@ fn equals<T: std::cmp::PartialEq<T>>(
fn not_equals<T: std::cmp::PartialEq<T>>(
_: impl Ctx,
/// One of the two values to compare for inequality.
#[implementations(f64, f32, u32, DVec2, bool, &str)]
#[implementations(f64, f32, u32, DVec2, bool, String)]
value: T,
/// The other of the two values to compare for inequality.
#[implementations(f64, f32, u32, DVec2, bool, &str)]
#[implementations(f64, f32, u32, DVec2, bool, String)]
other_value: T,
) -> bool {
other_value != value
@@ -767,7 +767,7 @@ fn vec2_value(_: impl Ctx, _primary: (), x: f64, y: f64) -> DVec2 {
DVec2::new(x, y)
}
/// Constructs a color value which may be set to any color, or no color.
/// Constructs a color value which may be set to any color.
#[node_macro::node(category("Value"))]
fn color_value(_: impl Ctx, _primary: (), #[default(Color::BLACK)] color: Color) -> Color {
color

View File

@@ -14,37 +14,44 @@ fn format_json(
_: impl Ctx,
/// The JSON string to reformat.
#[name("JSON")]
json: String,
json: Item<String>,
/// Removes optional spaces within curly brackets and after colons and commas.
compact: bool,
compact: Item<bool>,
/// Break arrays and objects across multiple lines when they exceed the line break length.
#[default(true)]
#[name("Multi-Line")]
multi_line: bool,
multi_line: Item<bool>,
/// The indentation string used for each nesting level. Escape sequences like `\t` (the tab character) are supported. Two or four spaces are also common choices.
#[default("\\t")]
indent: String,
indent: Item<String>,
/// The maximum line length before a container (array or object) is broken across lines. Set this to 0 to always break containers. (Requires *Multi-Line* to take effect.)
///
/// This is not a maximum line length guarantee. Deep nesting and long keys or values may exceed this length.
#[default(120)]
break_length: u32,
break_length: Item<u32>,
/// Always break a container (array or object) across lines if it holds another container, even if it would fit within the break length. (Requires *Multi-Line* to take effect.)
#[default(true)]
break_nested: bool,
) -> String {
let cleaned = strip_trailing_commas(&json);
break_nested: Item<bool>,
) -> Item<String> {
let mut json = json;
let (compact, multi_line, break_length, break_nested) = (*compact.element(), *multi_line.element(), *break_length.element(), *break_nested.element());
let indent = indent.element().clone();
let cleaned = strip_trailing_commas(json.element());
let Ok(value) = serde_json::from_str::<serde_json::Value>(&cleaned) else { return json };
let indent = unescape_string(indent);
let colon = if compact { ":" } else { ": " };
let comma_space = if compact { "," } else { ", " };
let line_width = break_length as usize;
if multi_line {
let result = if multi_line {
format_value(&value, 0, &indent, colon, comma_space, compact, break_nested, line_width)
} else {
format_inline(&value, colon, comma_space, compact)
}
};
*json.element_mut() = result;
json
}
/// Strips trailing commas before `]` and `}` to accept JSON-with-trailing-commas input.
@@ -188,7 +195,7 @@ fn query_json(
_: impl Ctx,
/// The JSON string to extract a value from.
#[name("JSON")]
json: String,
json: Item<String>,
/// Determines which contained value to extract from within the JSON.
///
/// The path syntax is like JavaScript's accessor syntax that follows an array/object value. It also supports negative indexing to count backwards from the end. Additionally, `[]` accesses all array and object values instead of just one.
@@ -198,19 +205,29 @@ fn query_json(
/// Use `.size` or `["size"]` to get the `size` property of `{ "size": 10 }`. The latter form is required if the key contains spaces or special characters like `["this key with spaces!"]`.
/// Use chained accessors like `.fonts[0].name` to query deeper.
/// Use the `[]` accessor to query all elements, like `.fonts[].weights[]` to get every weight of every font.
path: String,
path: Item<String>,
/// Strips the surrounding double quotes from string values, returning the raw text. Other types are never wrapped in quotes.
#[default(true)]
unquote_strings: bool,
) -> String {
let cleaned = strip_trailing_commas(&json);
let Ok(value): Result<Value, _> = serde_json::from_str(&cleaned) else { return String::new() };
let Some(segments) = parse_json_path(path.trim()) else { return String::new() };
unquote_strings: Item<bool>,
) -> Item<String> {
let mut json = json;
let path = path.element().clone();
let unquote_strings = *unquote_strings.element();
let mut results = Vec::new();
resolve_all(&value, &segments, !unquote_strings, &mut results);
let cleaned = strip_trailing_commas(json.element());
results.into_iter().next().map(|(text, _ty)| text).unwrap_or_default()
let result = match (serde_json::from_str::<Value>(&cleaned), parse_json_path(path.trim())) {
(Ok(value), Some(segments)) => {
let mut results = Vec::new();
resolve_all(&value, &segments, !unquote_strings, &mut results);
results.into_iter().next().map(|(text, _ty)| text).unwrap_or_default()
}
_ => String::new(),
};
*json.element_mut() = result;
json
}
/// Extracts every matched value from a JSON string using a path expression (see that parameter's description for its syntax). A list of zero or more resultant strings is produced. The `[]` path accessor is used to read more than one value.
@@ -226,7 +243,7 @@ fn query_json_all(
_: impl Ctx,
/// The JSON string to extract values from.
#[name("JSON")]
json: String,
json: Item<String>,
/// Determines which contained values to extract from within the JSON.
///
/// The path syntax is like JavaScript's accessor syntax that follows an array/object value. It also supports negative indexing to count backwards from the end. Additionally, `[]` accesses all array and object values instead of just one.
@@ -236,17 +253,17 @@ fn query_json_all(
/// Use `.size` or `["size"]` to get the `size` property of `{ "size": 10 }`. The latter form is required if the key contains spaces or special characters like `["this key with spaces!"]`.
/// Use chained accessors like `.fonts[0].name` to query deeper.
/// Use the `[]` accessor to query all elements, like `.fonts[].weights[]` to get every weight of every font.
path: String,
path: Item<String>,
/// Strips the surrounding double quotes from string values, returning the raw text. Other types are never wrapped in quotes.
#[default(true)]
unquote_strings: bool,
unquote_strings: Item<bool>,
) -> List<String> {
let cleaned = strip_trailing_commas(&json);
let cleaned = strip_trailing_commas(json.element());
let Ok(value): Result<Value, _> = serde_json::from_str(&cleaned) else { return List::new() };
let Some(segments) = parse_json_path(path.trim()) else { return List::new() };
let Some(segments) = parse_json_path(path.element().trim()) else { return List::new() };
let mut results = Vec::new();
resolve_all(&value, &segments, !unquote_strings, &mut results);
resolve_all(&value, &segments, !*unquote_strings.element(), &mut results);
results.into_iter().map(|(text, ty)| Item::new_from_element(text).with_attribute(ATTR_TYPE, ty.to_string())).collect()
}

View File

@@ -187,34 +187,43 @@ pub enum StringCapitalization {
/// Constructs a string value which may be set to any plain text.
#[node_macro::node(category("Value"))]
fn string_value(_: impl Ctx, _primary: (), string: TextArea) -> String {
fn string_value(_: impl Ctx, _primary: (), string: Item<TextArea>) -> Item<String> {
string
}
/// Type-asserts a value to be a string.
#[node_macro::node(category("Debug"))]
fn as_string(_: impl Ctx, value: String) -> String {
fn as_string(_: impl Ctx, value: Item<String>) -> Item<String> {
value
}
/// Joins two strings together.
#[node_macro::node(category("Text"))]
fn string_concatenate(_: impl Ctx, #[implementations(String)] first: String, second: TextArea) -> String {
first + &second
fn string_concatenate(_: impl Ctx, #[implementations(String)] first: Item<String>, second: Item<TextArea>) -> Item<String> {
let mut first = first;
first.element_mut().push_str(second.element());
first
}
/// Replaces all occurrences of "From" with "To" in the input string.
#[node_macro::node(category("Text"))]
fn string_replace(_: impl Ctx, string: String, from: TextArea, to: TextArea) -> String {
string.replace(&from, &to)
fn string_replace(_: impl Ctx, string: Item<String>, from: Item<TextArea>, to: Item<TextArea>) -> Item<String> {
let mut string = string;
let result = string.element().replace(from.element().as_str(), to.element());
*string.element_mut() = result;
string
}
/// Extracts a substring from the input string, starting at "Start" and ending before "End".
///
/// Negative indices count from the end of the string. If the index of "Start" equals or exceeds "End", the result is an empty string.
#[node_macro::node(category("Text"))]
fn string_slice(_: impl Ctx, string: String, start: SignedInteger, end: SignedInteger) -> String {
let total_graphemes = string.graphemes(true).count();
fn string_slice(_: impl Ctx, string: Item<String>, start: Item<SignedInteger>, end: Item<SignedInteger>) -> Item<String> {
let mut string = string;
let (start, end) = (*start.element(), *end.element());
let total_graphemes = string.element().graphemes(true).count();
let start = if start < 0. {
total_graphemes.saturating_sub(start.abs() as usize)
@@ -227,11 +236,14 @@ fn string_slice(_: impl Ctx, string: String, start: SignedInteger, end: SignedIn
(end as usize).min(total_graphemes)
};
if start >= end {
return String::new();
}
let result = if start >= end {
String::new()
} else {
string.element().graphemes(true).skip(start).take(end - start).collect()
};
string.graphemes(true).skip(start).take(end - start).collect()
*string.element_mut() = result;
string
}
/// Clips the string to a maximum character length, optionally appending a suffix (like "…") when truncation occurs. Strings already within the limit are not modified.
@@ -239,27 +251,30 @@ fn string_slice(_: impl Ctx, string: String, start: SignedInteger, end: SignedIn
fn string_truncate(
_: impl Ctx,
/// The string to truncate.
string: String,
string: Item<String>,
/// The maximum number of characters allowed, including the suffix if one is appended.
#[default(80)]
length: u32,
length: Item<u32>,
/// A suffix appended to indicate truncation occurred, unless empty. Its length counts towards the character budget.
#[default("")]
suffix: String,
) -> String {
let max_length = length as usize;
let grapheme_count = string.graphemes(true).count();
suffix: Item<String>,
) -> Item<String> {
let mut string = string;
let max_length = *length.element() as usize;
let grapheme_count = string.element().graphemes(true).count();
if grapheme_count <= max_length {
return string;
}
let suffix: String = suffix.graphemes(true).take(max_length).collect();
let suffix: String = suffix.element().graphemes(true).take(max_length).collect();
let keep = max_length - suffix.graphemes(true).count();
let mut truncated: String = string.graphemes(true).take(keep).collect();
let mut truncated: String = string.element().graphemes(true).take(keep).collect();
truncated.push_str(&suffix);
truncated
*string.element_mut() = truncated;
string
}
/// Formats a number as a string with control over decimal places, decimal separator, and thousands grouping.
@@ -267,25 +282,31 @@ fn string_truncate(
fn format_number(
_: impl Ctx,
/// The number to format as a string.
number: f64,
number: Item<f64>,
/// The amount of digits after the decimal point. The value is rounded to fit. Set to 0 to show only whole numbers.
#[default(2)]
decimal_places: u32,
decimal_places: Item<u32>,
/// The character(s) used as the decimal point.
#[default(".")]
decimal_separator: String,
decimal_separator: Item<String>,
/// Always show the exact number of decimal places, even if they are trailing zeros.
#[default(true)]
fixed_decimals: bool,
fixed_decimals: Item<bool>,
/// Whether to group digits with a thousands separator.
use_thousands_separator: bool,
use_thousands_separator: Item<bool>,
/// The character(s) inserted between digit groups.
#[default(",")]
thousands_separator: String,
thousands_separator: Item<String>,
/// Don't group 4-digit numbers with a thousands separator (only start grouping at 10,000 and above).
#[name("Start at 10,000")]
start_at_10000: bool,
) -> String {
start_at_10000: Item<bool>,
) -> Item<String> {
let (number, attributes) = number.into_parts();
let (decimal_places, fixed_decimals, use_thousands_separator, start_at_10000) =
(*decimal_places.element(), *fixed_decimals.element(), *use_thousands_separator.element(), *start_at_10000.element());
let decimal_separator = decimal_separator.element().clone();
let thousands_separator = thousands_separator.element().clone();
// Find the maximum meaningful decimal precision by detecting where float noise begins.
// This works correctly whether the value originated as f32 or f64, since we find the
// shortest decimal representation that round-trips back to the same f64 value.
@@ -340,36 +361,38 @@ fn format_number(
};
// Build the final string
let Some(decimal_string) = decimal_string else {
if fixed_decimals && requested_places > 0 {
let result = match decimal_string {
None if fixed_decimals && requested_places > 0 => {
let zeros = "0".repeat(requested_places);
return format!("{sign}{grouped_whole}{decimal_separator}{zeros}");
format!("{sign}{grouped_whole}{decimal_separator}{zeros}")
}
None => format!("{sign}{grouped_whole}"),
Some(decimal_string) if fixed_decimals => format!("{sign}{grouped_whole}{decimal_separator}{decimal_string}"),
Some(decimal_string) => {
let trimmed = decimal_string.trim_end_matches('0');
if trimmed.is_empty() {
format!("{sign}{grouped_whole}")
} else {
format!("{sign}{grouped_whole}{decimal_separator}{trimmed}")
}
}
return format!("{sign}{grouped_whole}");
};
if fixed_decimals {
format!("{sign}{grouped_whole}{decimal_separator}{decimal_string}")
} else {
let trimmed = decimal_string.trim_end_matches('0');
if trimmed.is_empty() {
format!("{sign}{grouped_whole}")
} else {
format!("{sign}{grouped_whole}{decimal_separator}{trimmed}")
}
}
Item::from_parts(result, attributes)
}
/// Parses a string into a number. Falls back to the chosen value if the string is not a valid number.
#[node_macro::node(category("Text"))]
#[node_macro::node(category("Text"), name("String to Number"))]
fn string_to_number(
_: impl Ctx,
/// The string containing a number. Surrounding whitespace is ignored, a decimal point (.) may be included, sign prefixes (+/-) are respected, and scientific notation (e.g. "1e-3") is supported.
string: String,
string: Item<String>,
/// The value of the result if the string cannot be parsed as a valid number.
fallback: f64,
) -> f64 {
string.trim().parse::<f64>().unwrap_or(fallback)
fallback: Item<f64>,
) -> Item<f64> {
let (string, attributes) = string.into_parts();
Item::from_parts(string.trim().parse::<f64>().unwrap_or(*fallback.element()), attributes)
}
/// Removes leading and/or trailing whitespace from a string. Common whitespace characters include spaces, tabs, and newlines.
@@ -377,20 +400,26 @@ fn string_to_number(
fn string_trim(
_: impl Ctx,
/// The string that may contain leading and trailing whitespace that should be removed.
string: String,
string: Item<String>,
/// Whether the start of the string should have its whitespace removed.
#[default(true)]
start: bool,
start: Item<bool>,
/// Whether the end of the string should have its whitespace removed.
#[default(true)]
end: bool,
) -> String {
match (start, end) {
(true, true) => string.trim().to_string(),
(true, false) => string.trim_start().to_string(),
(false, true) => string.trim_end().to_string(),
(false, false) => string,
}
end: Item<bool>,
) -> Item<String> {
let mut string = string;
let (start, end) = (*start.element(), *end.element());
let result = match (start, end) {
(true, true) => string.element().trim().to_string(),
(true, false) => string.element().trim_start().to_string(),
(false, true) => string.element().trim_end().to_string(),
(false, false) => return string,
};
*string.element_mut() = result;
string
}
/// Converts between literal escape sequences and their corresponding control characters within a string.
@@ -401,12 +430,18 @@ fn string_trim(
fn string_escape(
_: impl Ctx,
/// The string that contains either literal escape sequences or control characters to be converted to the opposite representation.
string: String,
string: Item<String>,
/// Convert the control characters back into their escape sequence representations.
#[default(true)]
unescape: bool,
) -> String {
if unescape { unescape_string(string) } else { escape_string(string) }
unescape: Item<bool>,
) -> Item<String> {
let mut string = string;
let input = std::mem::take(string.element_mut());
let result = if *unescape.element() { unescape_string(input) } else { escape_string(input) };
*string.element_mut() = result;
string
}
/// Reverses the sequence of characters making up the string so it reads back-to-front. ("Backwards text" becomes "txet sdrawkcaB".)
@@ -414,9 +449,13 @@ fn string_escape(
fn string_reverse(
_: impl Ctx,
/// The string to be reversed.
string: String,
) -> String {
string.graphemes(true).rev().collect()
string: Item<String>,
) -> Item<String> {
let mut string = string;
let result: String = string.element().graphemes(true).rev().collect();
*string.element_mut() = result;
string
}
/// Repeats the string a given number of times, optionally with a separator between each repetition.
@@ -424,31 +463,35 @@ fn string_reverse(
fn string_repeat(
_: impl Ctx,
/// The string to be repeated.
string: String,
string: Item<String>,
/// The number of times the string should appear in the output.
#[default(2)]
#[hard(1..)]
count: u32,
count: Item<u32>,
/// The string placed between each repetition.
#[default("\\n")]
separator: String,
separator: Item<String>,
/// Whether to convert escape sequences found in the separator into their corresponding characters:
/// "\n" (newline), "\r" (carriage return), "\t" (tab), "\0" (null), and "\\" (backslash).
#[default(true)]
separator_escaping: bool,
) -> String {
let separator = if separator_escaping { unescape_string(separator) } else { separator };
separator_escaping: Item<bool>,
) -> Item<String> {
let mut string = string;
let separator = separator.element().clone();
let separator = if *separator_escaping.element() { unescape_string(separator) } else { separator };
let count = count as usize;
let count = *count.element() as usize;
let mut result = String::with_capacity((string.len() + separator.len()) * count);
let mut result = String::with_capacity((string.element().len() + separator.len()) * count);
for i in 0..count {
if i > 0 {
result.push_str(&separator);
}
result.push_str(&string);
result.push_str(string.element());
}
result
*string.element_mut() = result;
string
}
/// Pads the string to a target length by filling with the given repeated substring. If the string already meets or exceeds the target length, it is returned unchanged.
@@ -456,21 +499,25 @@ fn string_repeat(
fn string_pad(
_: impl Ctx,
/// The string to be padded to a target length.
string: String,
string: Item<String>,
/// The target character length after padding. When "Up To" is set, this length concerns only the portion before (or after) that substring.
#[default(10)]
length: u32,
length: Item<u32>,
/// The repeated substring used to fill the remaining space. A multi-charcter substring may end partway through its final repetition.
#[default("#")]
padding: String,
padding: Item<String>,
/// Pad only the length of the string encountered before the start of the first (or after the end of the last) occurrence of this substring, if given and present (otherwise the full string is considered).
///
/// For example, this can pad numbers with leading zeros to align them before the decimal point.
up_to: String,
up_to: Item<String>,
/// Pad at the end of the string instead of the start.
from_end: bool,
) -> String {
let target_length = length as usize;
from_end: Item<bool>,
) -> Item<String> {
let mut string = string;
let target_length = *length.element() as usize;
let padding = padding.element().clone();
let up_to = up_to.element().clone();
let from_end = *from_end.element();
if padding.is_empty() {
return string;
@@ -478,9 +525,9 @@ fn string_pad(
// Split the string at the "up to" substring if provided, and only pad that portion
if !up_to.is_empty()
&& let Some(position) = if from_end { string.rfind(&*up_to) } else { string.find(&*up_to) }
&& let Some(position) = if from_end { string.element().rfind(&*up_to) } else { string.element().find(&*up_to) }
{
let (before, after) = string.split_at(position);
let (before, after) = string.element().split_at(position);
if from_end {
// Pad the portion after the substring
@@ -491,7 +538,10 @@ fn string_pad(
}
let pad_length = target_length - current_length;
let padding: String = padding.graphemes(true).cycle().take(pad_length).collect();
return format!("{before}{up_to}{after_substring}{padding}");
let result = format!("{before}{up_to}{after_substring}{padding}");
*string.element_mut() = result;
return string;
} else {
// Pad the portion before the substring
let current_length = before.graphemes(true).count();
@@ -500,11 +550,14 @@ fn string_pad(
}
let pad_length = target_length - current_length;
let padding: String = padding.graphemes(true).cycle().take(pad_length).collect();
return format!("{padding}{before}{after}");
let result = format!("{padding}{before}{after}");
*string.element_mut() = result;
return string;
}
}
let current_length = string.graphemes(true).count();
let current_length = string.element().graphemes(true).count();
if current_length >= target_length {
return string;
}
@@ -512,7 +565,10 @@ fn string_pad(
let pad_length = target_length - current_length;
let padding: String = padding.graphemes(true).cycle().take(pad_length).collect();
if from_end { string + &padding } else { padding + &string }
let result = if from_end { string.element().clone() + &padding } else { padding + string.element() };
*string.element_mut() = result;
string
}
/// Checks whether the string contains the given substring. Optionally restricts the match to only the start and/or end of the string.
@@ -520,20 +576,26 @@ fn string_pad(
fn string_contains(
_: impl Ctx,
/// The string to search within.
string: String,
string: Item<String>,
/// The substring to search for.
substring: String,
substring: Item<String>,
/// Only match if the substring appears at the start of the string.
at_start: bool,
at_start: Item<bool>,
/// Only match if the substring appears at the end of the string.
at_end: bool,
) -> bool {
match (at_start, at_end) {
(true, true) => string.starts_with(&*substring) && string.ends_with(&*substring),
(true, false) => string.starts_with(&*substring),
(false, true) => string.ends_with(&*substring),
(false, false) => string.contains(&*substring),
}
at_end: Item<bool>,
) -> Item<bool> {
let (string, attributes) = string.into_parts();
let substring = substring.element().as_str();
let (at_start, at_end) = (*at_start.element(), *at_end.element());
let result = match (at_start, at_end) {
(true, true) => string.starts_with(substring) && string.ends_with(substring),
(true, false) => string.starts_with(substring),
(false, true) => string.ends_with(substring),
(false, false) => string.contains(substring),
};
Item::from_parts(result, attributes)
}
/// Similar to the **String Contains** node, this searches within the input string for the first (or last) occurrence of a substring and returns the index of where that begins, or -1 if not found.
@@ -541,28 +603,35 @@ fn string_contains(
fn string_find_index(
_: impl Ctx,
/// The string to search within.
string: String,
string: Item<String>,
/// The substring to search for.
substring: String,
substring: Item<String>,
/// Find the start index of the last occurrence instead of the first.
from_end: bool,
) -> f64 {
from_end: Item<bool>,
) -> Item<f64> {
let (string, attributes) = string.into_parts();
let substring = substring.element().as_str();
let from_end = *from_end.element();
if substring.is_empty() {
return if from_end { string.graphemes(true).count() as f64 } else { 0. };
let result = if from_end { string.graphemes(true).count() as f64 } else { 0. };
return Item::from_parts(result, attributes);
}
if from_end {
let result = if from_end {
// Search backwards by finding all byte-level matches and taking the last one
string
.rmatch_indices(&*substring)
.rmatch_indices(substring)
.next()
.map_or(-1., |(byte_index, _)| string[..byte_index].graphemes(true).count() as f64)
} else {
string
.match_indices(&*substring)
.match_indices(substring)
.next()
.map_or(-1., |(byte_index, _)| string[..byte_index].graphemes(true).count() as f64)
}
};
Item::from_parts(result, attributes)
}
/// Counts the number of occurrences of a substring within the string.
@@ -570,22 +639,25 @@ fn string_find_index(
fn string_occurrences(
_: impl Ctx,
/// The string to search within.
string: String,
string: Item<String>,
/// The substring to count occurrences of.
substring: String,
substring: Item<String>,
/// Whether to count overlapping occurrences, using the substring as a sliding window.
///
/// For example, "aa" occurs twice in "aaaa" without overlapping but three times with overlapping.
overlapping: bool,
) -> f64 {
overlapping: Item<bool>,
) -> Item<f64> {
let (string, attributes) = string.into_parts();
let substring = substring.element().as_str();
if substring.is_empty() {
return 0.;
return Item::from_parts(0., attributes);
}
// NON-OVERLAPPING: Simple linear scan.
// O(n), where n = string length
if !overlapping {
return string.matches(&*substring).count() as f64;
if !*overlapping.element() {
return Item::from_parts(string.matches(substring).count() as f64, attributes);
}
// OVERLAPPING: KMP (Knuth-Morris-Pratt) algorithm.
@@ -631,7 +703,7 @@ fn string_occurrences(
}
}
count as f64
Item::from_parts(count as f64, attributes)
}
/// Converts a string's capitalization style to another of the common upper and lower case patterns, optionally joining words with a chosen separator.
@@ -639,47 +711,49 @@ fn string_occurrences(
fn string_capitalization(
_: impl Ctx,
/// The string to have its letter capitalization converted.
string: String,
string: Item<String>,
/// The capitalization style to apply.
capitalization: StringCapitalization,
capitalization: Item<StringCapitalization>,
/// Whether to split the string into words and reconnect with the chosen joiner. When disabled, the existing word structure separators are preserved.
use_joiner: bool,
use_joiner: Item<bool>,
/// The string placed between each word.
joiner: String,
) -> String {
joiner: Item<String>,
) -> Item<String> {
let mut string = string;
let capitalization = *capitalization.element();
let use_joiner = *use_joiner.element();
let joiner = joiner.element().clone();
let input = std::mem::take(string.element_mut());
// When the joiner is enabled, apply word-level casing and optionally reconnect words with the selected joiner
if use_joiner {
let result = if use_joiner {
match capitalization {
// Simple case mappings that preserve the string's existing structure
StringCapitalization::LowerCase => string.to_lowercase(),
StringCapitalization::UpperCase => string.to_uppercase(),
StringCapitalization::LowerCase => input.to_lowercase(),
StringCapitalization::UpperCase => input.to_uppercase(),
// Word-aware capitalizations that split on word boundaries and rejoin with the joiner
StringCapitalization::CapitalCase => Converter::new().set_boundaries(&Boundary::defaults()).set_pattern(pattern::capital).set_delim(&joiner).convert(&string),
StringCapitalization::CapitalCase => Converter::new().set_boundaries(&Boundary::defaults()).set_pattern(pattern::capital).set_delim(&joiner).convert(&input),
StringCapitalization::HeadlineCase => {
// First split into words with convert_case so word boundaries like "AlphaNumeric" are detected consistently with other modes,
// then apply the titlecase crate for smart capitalization (lowercasing short words like "of", "the", etc.),
// then rejoin with the custom joiner without mangling the capitalization
let spaced = Converter::new().set_boundaries(&Boundary::defaults()).set_pattern(pattern::capital).set_delim(" ").convert(&string);
let spaced = Converter::new().set_boundaries(&Boundary::defaults()).set_pattern(pattern::capital).set_delim(" ").convert(&input);
let headline = titlecase::titlecase(&spaced);
Converter::new().set_boundaries(&[Boundary::SPACE]).set_pattern(pattern::noop).set_delim(&joiner).convert(&headline)
}
StringCapitalization::SentenceCase => Converter::new()
.set_boundaries(&Boundary::defaults())
.set_pattern(pattern::sentence)
.set_delim(&joiner)
.convert(&string),
StringCapitalization::CamelCase => Converter::new().set_boundaries(&Boundary::defaults()).set_pattern(pattern::camel).set_delim(&joiner).convert(&string),
StringCapitalization::SentenceCase => Converter::new().set_boundaries(&Boundary::defaults()).set_pattern(pattern::sentence).set_delim(&joiner).convert(&input),
StringCapitalization::CamelCase => Converter::new().set_boundaries(&Boundary::defaults()).set_pattern(pattern::camel).set_delim(&joiner).convert(&input),
}
}
// When the joiner is disabled, apply only character-level casing while preserving the string's existing structure
else {
match capitalization {
StringCapitalization::LowerCase => string.to_lowercase(),
StringCapitalization::UpperCase => string.to_uppercase(),
StringCapitalization::LowerCase => input.to_lowercase(),
StringCapitalization::UpperCase => input.to_uppercase(),
StringCapitalization::CapitalCase => {
let mut capitalize_next = true;
string.chars().fold(String::with_capacity(string.len()), |mut result, c| {
input.chars().fold(String::with_capacity(input.len()), |mut result, c| {
if c.is_whitespace() || c == '_' || c == '-' {
capitalize_next = true;
result.push(c);
@@ -692,9 +766,9 @@ fn string_capitalization(
result
})
}
StringCapitalization::HeadlineCase => titlecase::titlecase(&string),
StringCapitalization::HeadlineCase => titlecase::titlecase(&input),
StringCapitalization::SentenceCase => {
let mut chars = string.chars();
let mut chars = input.chars();
match chars.next() {
Some(first) => first.to_uppercase().to_string() + &chars.as_str().to_lowercase(),
None => String::new(),
@@ -702,7 +776,7 @@ fn string_capitalization(
}
StringCapitalization::CamelCase => {
let mut capitalize_next = false;
string.chars().fold(String::with_capacity(string.len()), |mut result, c| {
input.chars().fold(String::with_capacity(input.len()), |mut result, c| {
if c.is_whitespace() || c == '_' || c == '-' {
capitalize_next = true;
result.push(c);
@@ -716,15 +790,20 @@ fn string_capitalization(
})
}
}
}
};
*string.element_mut() = result;
string
}
// TODO: Return u32, u64, or usize instead of f64 after #1621 is resolved and has allowed us to implement automatic type conversion in the node graph for nodes with generic type inputs.
// TODO: (Currently automatic type conversion only works for concrete types, via the Graphene preprocessor and not the full Graphene type system.)
/// Counts the number of characters in a string.
#[node_macro::node(category("Text"))]
fn string_length(_: impl Ctx, string: String) -> f64 {
string.graphemes(true).count() as f64
fn string_length(_: impl Ctx, string: Item<String>) -> Item<f64> {
let (string, attributes) = string.into_parts();
Item::from_parts(string.graphemes(true).count() as f64, attributes)
}
/// Splits a string into a list of substrings based on the specified delimiter. This is the inverse of the **String Join** node.
@@ -734,18 +813,19 @@ fn string_length(_: impl Ctx, string: String) -> f64 {
fn string_split(
_: impl Ctx,
/// The string to split into substrings.
string: String,
string: Item<String>,
/// The character(s) that separate the substrings. These are not included in the outputs.
#[default("\\n")]
delimiter: String,
delimiter: Item<String>,
/// Whether to convert escape sequences found in the delimiter into their corresponding characters:
/// "\n" (newline), "\r" (carriage return), "\t" (tab), "\0" (null), and "\\" (backslash).
#[default(true)]
delimiter_escaping: bool,
delimiter_escaping: Item<bool>,
) -> List<String> {
let delimiter = if delimiter_escaping { unescape_string(delimiter) } else { delimiter };
let delimiter = delimiter.element().clone();
let delimiter = if *delimiter_escaping.element() { unescape_string(delimiter) } else { delimiter };
string.split(&delimiter).map(str::to_string).map(Item::new_from_element).collect()
string.element().split(&delimiter).map(str::to_string).map(Item::new_from_element).collect()
}
/// Joins a list of strings together with a separator between each pair. This is the inverse of the **String Split** node.
@@ -758,15 +838,18 @@ fn string_join(
strings: List<String>,
/// The text placed between each pair of strings.
#[default(", ")]
separator: String,
separator: Item<String>,
/// Whether to convert escape sequences found in the separator into their corresponding characters:
/// "\n" (newline), "\r" (carriage return), "\t" (tab), "\0" (null), and "\\" (backslash).
#[default(true)]
separator_escaping: bool,
) -> String {
separator_escaping: Item<bool>,
) -> Item<String> {
let (separator, separator_escaping) = (separator.into_element(), separator_escaping.into_element());
let separator = if separator_escaping { unescape_string(separator) } else { separator };
strings.iter_element_values().map(|s| s.as_str()).collect::<Vec<_>>().join(&separator)
let joined = strings.iter_element_values().map(|s| s.as_str()).collect::<Vec<_>>().join(&separator);
Item::new_from_element(joined)
}
/// Iterates over a list of strings, evaluating the mapped operation for each one. Use the **Read String** node to access the current string inside the loop.
@@ -794,11 +877,11 @@ fn map_string(
/// Reads the current string from within a **Map String** node's loop.
#[node_macro::node(category("Context"))]
fn read_string(ctx: impl Ctx + ExtractVarArgs) -> String {
let Ok(var_arg) = ctx.vararg(0) else { return String::new() };
fn read_string(ctx: impl Ctx + ExtractVarArgs) -> Item<String> {
let Ok(var_arg) = ctx.vararg(0) else { return Item::new_from_element(String::new()) };
let var_arg = var_arg as &dyn std::any::Any;
var_arg.downcast_ref::<String>().cloned().unwrap_or_default()
var_arg.downcast_ref::<Item<String>>().cloned().unwrap_or_default()
}
/// Converts a value to a JSON string representation.

View File

@@ -202,7 +202,7 @@ impl PathBuilder {
}
}
// "Separate Glyphs" off: widen the accumulated AABBs and bundle as one override `Vector`
// Glyph separation off: widen the accumulated AABBs and bundle as one override `Vector`
if !self.merged_click_target_bboxes.is_empty() {
let mut bboxes = self.merged_click_target_bboxes;
widen_horizontal_gaps(&mut bboxes, &self.merged_click_target_baselines);

View File

@@ -7,18 +7,22 @@ use core_types::{ATTR_END, ATTR_NAME, ATTR_START, Ctx};
fn regex_contains(
_: impl Ctx,
/// The string to search within.
string: String,
string: Item<String>,
/// The regular expression pattern to search for.
pattern: String,
pattern: Item<String>,
/// Match letters regardless of case.
case_insensitive: bool,
case_insensitive: Item<bool>,
/// Make `^` and `$` match the start and end of each line, not just the whole string.
multiline: bool,
multiline: Item<bool>,
/// Only match if the pattern appears at the start of the string.
at_start: bool,
at_start: Item<bool>,
/// Only match if the pattern appears at the end of the string.
at_end: bool,
) -> bool {
at_end: Item<bool>,
) -> Item<bool> {
let (string, attributes) = string.into_parts();
let pattern = pattern.element();
let (case_insensitive, multiline, at_start, at_end) = (*case_insensitive.element(), *multiline.element(), *at_start.element(), *at_end.element());
let flags = match (case_insensitive, multiline) {
(false, false) => "",
(true, false) => "(?i)",
@@ -34,29 +38,34 @@ fn regex_contains(
let Ok(regex) = fancy_regex::Regex::new(&anchored_pattern) else {
log::error!("Invalid regex pattern: {pattern}");
return false;
return Item::from_parts(false, attributes);
};
regex.is_match(&string).unwrap_or(false)
Item::from_parts(regex.is_match(&string).unwrap_or(false), attributes)
}
/// Replaces matches of a regular expression pattern in the string. The replacement string can reference captures: `$0` for the whole match and `$1`, `$2`, etc. for capture groups.
#[node_macro::node(category("Text: Regex"))]
fn regex_replace(
_: impl Ctx,
string: String,
string: Item<String>,
/// The regular expression pattern to search for.
pattern: String,
pattern: Item<String>,
/// The replacement string. Use `$0` for the whole match and `$1`, `$2`, etc. for capture groups.
replacement: String,
replacement: Item<String>,
/// Replace all matches. When disabled, only the first match is replaced.
#[default(true)]
replace_all: bool,
replace_all: Item<bool>,
/// Match letters regardless of case.
case_insensitive: bool,
case_insensitive: Item<bool>,
/// Make `^` and `$` match the start and end of each line, not just the whole string.
multiline: bool,
) -> String {
multiline: Item<bool>,
) -> Item<String> {
let mut string = string;
let pattern = pattern.element().clone();
let replacement = replacement.element().clone();
let (replace_all, case_insensitive, multiline) = (*replace_all.element(), *case_insensitive.element(), *multiline.element());
let flags = match (case_insensitive, multiline) {
(false, false) => "",
(true, false) => "(?i)",
@@ -70,11 +79,14 @@ fn regex_replace(
return string;
};
if replace_all {
regex.replace_all(&string, replacement.as_str()).into_owned()
let result = if replace_all {
regex.replace_all(string.element(), replacement.as_str()).into_owned()
} else {
regex.replace(&string, replacement.as_str()).into_owned()
}
regex.replace(string.element(), replacement.as_str()).into_owned()
};
*string.element_mut() = result;
string
}
/// Finds a regex match in the string and returns its components. The result is a list where the first item is the whole match (`$0`) and subsequent items are the capture groups (`$1`, `$2`, etc., if any).
@@ -87,16 +99,20 @@ fn regex_replace(
fn regex_find(
_: impl Ctx,
/// The string to search within.
string: String,
string: Item<String>,
/// The regular expression pattern to search for.
pattern: String,
pattern: Item<String>,
/// Which non-overlapping occurrence of the pattern to return, starting from 0 for the first match. Negative indices count backwards from the last match.
match_index: SignedInteger,
match_index: Item<SignedInteger>,
/// Match letters regardless of case.
case_insensitive: bool,
case_insensitive: Item<bool>,
/// Make `^` and `$` match the start and end of each line, not just the whole string.
multiline: bool,
multiline: Item<bool>,
) -> List<String> {
let string = string.element();
let pattern = pattern.element();
let (match_index, case_insensitive, multiline) = (*match_index.element(), *case_insensitive.element(), *multiline.element());
if pattern.is_empty() {
return List::new();
}
@@ -118,7 +134,7 @@ fn regex_find(
let capture_names: Vec<Option<String>> = regex.capture_names().map(|name| name.map(str::to_string)).collect();
// Collect all matches since we need to support negative indexing
let matches: Vec<_> = regex.captures_iter(&string).filter_map(|c| c.ok()).collect();
let matches: Vec<_> = regex.captures_iter(string).filter_map(|c| c.ok()).collect();
let match_index = match_index as i32;
let resolved_index = if match_index < 0 {
@@ -158,14 +174,18 @@ fn regex_find(
fn regex_find_all(
_: impl Ctx,
/// The string to search within.
string: String,
string: Item<String>,
/// The regular expression pattern to search for.
pattern: String,
pattern: Item<String>,
/// Match letters regardless of case.
case_insensitive: bool,
case_insensitive: Item<bool>,
/// Make `^` and `$` match the start and end of each line, not just the whole string.
multiline: bool,
multiline: Item<bool>,
) -> List<String> {
let string = string.element();
let pattern = pattern.element();
let (case_insensitive, multiline) = (*case_insensitive.element(), *multiline.element());
if pattern.is_empty() {
return List::new();
}
@@ -184,7 +204,7 @@ fn regex_find_all(
};
regex
.find_iter(&string)
.find_iter(string)
.filter_map(|m| m.ok())
.map(|m| {
Item::new_from_element(m.as_str().to_string())
@@ -201,16 +221,19 @@ fn regex_find_all(
fn regex_split(
_: impl Ctx,
/// The string to split into substrings.
string: String,
string: Item<String>,
/// The regular expression pattern to split on. Matches are consumed and not included in the output.
pattern: String,
pattern: Item<String>,
/// Match letters regardless of case.
case_insensitive: bool,
case_insensitive: Item<bool>,
/// Make `^` and `$` match the start and end of each line, not just the whole string.
multiline: bool,
multiline: Item<bool>,
) -> List<String> {
let pattern = pattern.element().clone();
let (case_insensitive, multiline) = (*case_insensitive.element(), *multiline.element());
if pattern.is_empty() {
return List::new_from_element(string);
return List::new_from_item(string);
}
let flags = match (case_insensitive, multiline) {
@@ -223,8 +246,8 @@ fn regex_split(
let Ok(regex) = fancy_regex::Regex::new(&full_pattern) else {
log::error!("Invalid regex pattern: {pattern}");
return List::new_from_element(string);
return List::new_from_item(string);
};
regex.split(&string).filter_map(|s| s.ok()).map(|s| s.to_string()).map(Item::new_from_element).collect()
regex.split(string.element()).filter_map(|s| s.ok()).map(|s| s.to_string()).map(Item::new_from_element).collect()
}

View File

@@ -6,9 +6,8 @@ use core_types::gpoll::{Extent, GPoll, Interrupt};
use core_types::transform::{ApplyTransform, ScaleType, Transform};
use core_types::{CacheHash, Context, Ctx, DeriveCtx, InjectFootprint, ModifyFootprint};
use glam::{DAffine2, DMat2, DVec2};
use graphic_types::Graphic;
use graphic_types::Vector;
use graphic_types::raster_types::{CPU, GPU, Raster};
use graphic_types::{Artboard, Graphic, Vector};
use vector_types::Gradient;
/// Applies the specified transform to each lane of the input, composing onto the lane's transform attribute.
@@ -97,7 +96,10 @@ fn replace_transform<T>(_: impl Ctx + InjectFootprint, (element, _content_transf
// TODO: Figure out how this node should behave once #2982 is implemented.
/// Obtains the transform of the first lane of the input, if present.
#[node_macro::node(category("Math: Transform"), path(core_types::vector))]
fn extract_transform<T: Clone + Send + Sync + CacheHash + 'static>(_: impl Ctx, #[implementations(Graphic, Vector, Raster<CPU>, Raster<GPU>, Color, Gradient)] content: IList<T>) -> DAffine2 {
fn extract_transform<T: Clone + Send + Sync + CacheHash + 'static>(
_: impl Ctx,
#[implementations(Graphic, Vector, Raster<CPU>, Raster<GPU>, Color, Gradient, String, Artboard)] content: IList<T>,
) -> DAffine2 {
match content.len() {
0 => DAffine2::default(),
_ => content.lane(0).attr::<TransformAttr>(),

View File

@@ -1,5 +1,6 @@
use core_types::attribute::{Attr, EditorLayerPath, RemoveAttr, Transform as TransformAttr};
use core_types::gpoll::{GraphError, Interrupt};
use core_types::transform::BakeTransform;
use core_types::uuid::NodeId;
use core_types::{Ctx, ExtractIndex, InjectIndex};
use glam::DAffine2;
@@ -38,14 +39,12 @@ fn path_modify<'e>(
Ok((element, Attr(parked.as_slice()), RemoveAttr::new()))
}
/// Applies the vector path's local transformation to its geometry and resets the transform to the identity.
/// Bakes the content's transform attribute into its underlying value, resetting the attribute to the identity.
#[node_macro::node(category("Vector"))]
fn apply_transform(_ctx: impl Ctx, (mut vector, transform): (Vector, Attr<TransformAttr>)) -> (Vector, Attr<TransformAttr>) {
// Monomorphic on Vector: our macro cannot yet read a record element through an open generic, so master's DAffine2 and DVec2 rows have no node here.
fn bake_transform(_ctx: impl Ctx, (mut content, transform): (Vector, Attr<TransformAttr>)) -> (Vector, Attr<TransformAttr>) {
let transform: DAffine2 = *transform;
for (_, point) in vector.point_domain.positions_mut() {
*point = transform.transform_point2(*point);
}
vector.segment_domain.transform(transform);
content.bake_transform(&transform);
(vector, Attr(DAffine2::IDENTITY))
(content, Attr(DAffine2::IDENTITY))
}