Add String[] as a graphic type for typography (#4141)

* feat: Render List<String> as raw paths in SVG and Vell mode

* chore: code review

* chore: change the hardcoded layout bounds to parley's

* chore: code review

* feat: Split text node to text_layer and text_to_vector node

* fix: CI fail because of difference in nature of Mac and github action

* chore: fix

* chore: replace FontStack as it got removed in parley 0.9

* chore: fmt

* chore: migrate the rendering as of new resource architechture

* chore: add text_layer node to text tool for testing

* code review

* Make boolean ops support the Text type

* chore: Move fallback_font_resource authority from editor to text node

* Fix 'Text Layer' node missing font dropdown

* Change node doc comments from Vec<T> to T[]

* Add migrations from the old Text node to Text -> Text to Vector

* Consolidate

* Rename the text attributes and reorder tilt to come before max_width/height

* Detect legacy Text nodes in the split migration by their trailing separate_glyphs input

* Code review

* Frame Text layer thumbnails by laying out their text for bounds

* Give Text layers click targets and selection outlines via collect_metadata

* Route Text tool through Text to Vector with fill, fixing editing-preview placement

---------

Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
Jatin Bharti
2026-06-20 01:09:52 +05:30
committed by GitHub
parent 13abf9fa8c
commit 5f100946f2
39 changed files with 967 additions and 204 deletions

View File

@@ -53,6 +53,11 @@ impl MultiplyAlpha for List<GradientStops> {
multiply_list_attribute(self, ATTR_OPACITY, factor);
}
}
impl MultiplyAlpha for List<String> {
fn multiply_alpha(&mut self, factor: f64) {
multiply_list_attribute(self, ATTR_OPACITY, factor);
}
}
pub(crate) trait MultiplyFill {
fn multiply_fill(&mut self, factor: f64);
@@ -87,6 +92,11 @@ impl MultiplyFill for List<GradientStops> {
multiply_list_attribute(self, ATTR_OPACITY_FILL, factor);
}
}
impl MultiplyFill for List<String> {
fn multiply_fill(&mut self, factor: f64) {
multiply_list_attribute(self, ATTR_OPACITY_FILL, factor);
}
}
trait SetBlendMode {
fn set_blend_mode(&mut self, blend_mode: BlendMode);
@@ -123,6 +133,11 @@ impl SetBlendMode for List<GradientStops> {
set_list_blend_mode(self, blend_mode);
}
}
impl SetBlendMode for List<String> {
fn set_blend_mode(&mut self, blend_mode: BlendMode) {
set_list_blend_mode(self, blend_mode);
}
}
trait SetClip {
fn set_clip(&mut self, clip: bool);
@@ -159,6 +174,11 @@ impl SetClip for List<GradientStops> {
set_list_clip(self, clip);
}
}
impl SetClip for List<String> {
fn set_clip(&mut self, clip: bool) {
set_list_clip(self, clip);
}
}
/// Applies the blend mode to the input graphics. Setting this allows for customizing how overlapping content is composited together.
#[node_macro::node(category("Blending"))]
@@ -171,6 +191,7 @@ fn blend_mode<T: SetBlendMode>(
List<Raster<CPU>>,
List<Color>,
List<GradientStops>,
List<String>,
)]
mut content: T,
/// The choice of equation that controls how brightness and color blends between overlapping pixels.
@@ -194,6 +215,7 @@ fn opacity<T: MultiplyAlpha + MultiplyFill>(
List<Raster<CPU>>,
List<Color>,
List<GradientStops>,
List<String>,
)]
mut content: T,
/// Whether the *Opacity* property is enabled, multiplying the existing opacity by the chosen percentage.
@@ -235,6 +257,7 @@ fn clipping_mask<T: SetClip>(
List<Raster<CPU>>,
List<Color>,
List<GradientStops>,
List<String>,
)]
mut content: T,
/// Whether the content inherits the alpha of the content beneath it.

View File

@@ -7,7 +7,7 @@ use graphic_types::{Artboard, Vector};
use raster_types::{CPU, GPU, Raster};
use vector_types::GradientStops;
/// Constructs a single-row `List<Artboard>` with the given content and metadata stored as row attributes.
/// Constructs a single-element `Artboard[]` with the given content and metadata stored as row attributes.
#[node_macro::node(category(""))]
pub async fn create_artboard<T: IntoGraphicList + 'n>(
ctx: impl ExtractAll + CloneVarArgs + Ctx,
@@ -15,6 +15,7 @@ pub async fn create_artboard<T: IntoGraphicList + 'n>(
#[implementations(
Context -> List<Graphic>,
Context -> List<Vector>,
Context -> List<String>,
Context -> List<Raster<CPU>>,
Context -> List<Raster<GPU>>,
Context -> List<Color>,

View File

@@ -116,6 +116,7 @@ async fn map<Item: AnyHash + Send + Sync + CacheHash>(
List<Raster<CPU>>,
List<Color>,
List<GradientStops>,
List<String>,
)]
content: List<Item>,
#[implementations(
@@ -124,6 +125,7 @@ async fn map<Item: AnyHash + Send + Sync + CacheHash>(
Context -> List<Raster<CPU>>,
Context -> List<Color>,
Context -> List<GradientStops>,
Context -> List<String>,
)]
mapped: impl Node<Context<'static>, Output = List<Item>>,
) -> List<Item> {
@@ -146,6 +148,7 @@ async fn mirror<T: 'n + Send + Clone>(
#[implementations(
List<Graphic>,
List<Vector>,
List<String>,
List<Raster<CPU>>,
List<Color>,
List<GradientStops>,
@@ -280,7 +283,7 @@ fn attach_attribute<T: AnyHash + Clone + Send + Sync + CacheHash>(
List<GradientSpreadMethod>,
)]
mut content: List<T>,
/// The source values to attach. Any `List<U>` wired here is type-erased via an auto-inserted convert.
/// The source values to attach.
#[expose]
source: AttributeDyn,
/// The name to assign to the new destination attribute.
@@ -293,7 +296,7 @@ fn attach_attribute<T: AnyHash + Clone + Send + Sync + CacheHash>(
content
}
/// Reads a named `Vector` attribute from the input list, outputting each value as an element of a new `List<Vector>`.
/// Reads a named `Vector` attribute from the input list, outputting each value as an element of a new `Vector[]`.
#[node_macro::node(category("Attributes: Read"))]
fn read_attribute_vector(
_: impl Ctx,
@@ -309,7 +312,7 @@ fn read_attribute_vector(
result
}
/// Reads a named numeric attribute (`f64`, `u64`, or `u32`) from the input list, outputting each value as an element of a new `List<f64>`. Integer values are converted to `f64`.
/// Reads a named numeric attribute (`f64`, `u64`, or `u32`) from the input list, outputting each value as an element of a new `f64[]`. Integer values are converted to `f64`.
#[node_macro::node(category("Attributes: Read"))]
fn read_attribute_number(
_: impl Ctx,
@@ -330,7 +333,7 @@ fn read_attribute_number(
result
}
/// Reads a named `bool` attribute from the input list, outputting each value as an element of a new `List<bool>`.
/// Reads a named `bool` attribute from the input list, outputting each value as an element of a new `bool[]`.
#[node_macro::node(category("Attributes: Read"))]
fn read_attribute_bool(
_: impl Ctx,
@@ -346,7 +349,7 @@ fn read_attribute_bool(
result
}
/// Reads a named `String` attribute from the input list, outputting each value as an element of a new `List<String>`.
/// Reads a named `String` attribute from the input list, outputting each value as an element of a new `String[]`.
#[node_macro::node(category("Attributes: Read"))]
fn read_attribute_string(
_: impl Ctx,
@@ -362,7 +365,7 @@ fn read_attribute_string(
result
}
/// Reads a named `DAffine2` transform attribute from the input list, outputting each value as an element of a new `List<DAffine2>`.
/// Reads a named `DAffine2` transform attribute from the input list, outputting each value as an element of a new `DAffine2[]`.
#[node_macro::node(category("Attributes: Read"))]
fn read_attribute_transform(
_: impl Ctx,
@@ -378,7 +381,7 @@ fn read_attribute_transform(
result
}
/// Reads a named `Color` attribute from the input list, outputting each value as an element of a new `List<Color>`.
/// Reads a named `Color` attribute from the input list, outputting each value as an element of a new `Color[]`.
#[node_macro::node(category("Attributes: Read"))]
fn read_attribute_color(
_: impl Ctx,
@@ -394,7 +397,7 @@ fn read_attribute_color(
result
}
/// Reads a named `BlendMode` attribute from the input list, outputting each value as an element of a new `List<BlendMode>`.
/// Reads a named `BlendMode` attribute from the input list, outputting each value as an element of a new `BlendMode[]`.
#[node_macro::node(category("Attributes: Read"))]
fn read_attribute_blend_mode(
_: impl Ctx,
@@ -410,7 +413,7 @@ fn read_attribute_blend_mode(
result
}
/// Reads a named `GradientType` attribute from the input list, outputting each value as an element of a new `List<GradientType>`.
/// Reads a named `GradientType` attribute from the input list, outputting each value as an element of a new `GradientType[]`.
#[node_macro::node(category("Attributes: Read"))]
fn read_attribute_gradient_type(
_: impl Ctx,
@@ -426,7 +429,7 @@ fn read_attribute_gradient_type(
result
}
/// Reads a named `GradientSpreadMethod` attribute from the input list, outputting each value as an element of a new `List<GradientSpreadMethod>`.
/// Reads a named `GradientSpreadMethod` attribute from the input list, outputting each value as an element of a new `GradientSpreadMethod[]`.
#[node_macro::node(category("Attributes: Read"))]
fn read_attribute_spread_method(
_: impl Ctx,
@@ -442,7 +445,7 @@ fn read_attribute_spread_method(
result
}
/// Reads a named `GradientStops` attribute from the input list, outputting each value as an element of a new `List<GradientStops>`.
/// Reads a named `GradientStops` attribute from the input list, outputting each value as an element of a new `GradientStops[]`.
#[node_macro::node(category("Attributes: Read"))]
fn read_attribute_gradient_stops(
_: impl Ctx,
@@ -458,7 +461,7 @@ fn read_attribute_gradient_stops(
result
}
/// Reads a named `Artboard` attribute from the input list, outputting each value as an element of a new `List<Artboard>`.
/// Reads a named `Artboard` attribute from the input list, outputting each value as an element of a new `Artboard[]`.
#[node_macro::node(category("Attributes: Read"))]
fn read_attribute_artboard(
_: impl Ctx,
@@ -474,7 +477,7 @@ fn read_attribute_artboard(
result
}
/// Reads a named `Raster<CPU>` attribute from the input list, outputting each value as an element of a new `List<Raster<CPU>>`.
/// Reads a named `Raster` attribute from the input list, outputting each value as an element of a new `Raster[]`.
#[node_macro::node(category("Attributes: Read"))]
fn read_attribute_raster(
_: impl Ctx,
@@ -495,11 +498,11 @@ fn read_attribute_raster(
pub async fn extend<T: 'n + Send + Clone>(
_: impl Ctx,
/// The `List` whose items will appear at the start of the extended `List`.
#[implementations(List<Artboard>, List<Graphic>, List<Vector>, List<Raster<CPU>>, List<Raster<GPU>>, List<Color>, List<GradientStops>)]
#[implementations(List<Artboard>, List<Graphic>, List<Vector>, List<String>, List<Raster<CPU>>, List<Raster<GPU>>, List<Color>, List<GradientStops>)]
base: List<T>,
/// The `List` whose items will appear at the end of the extended `List`.
#[expose]
#[implementations(List<Artboard>, List<Graphic>, List<Vector>, List<Raster<CPU>>, List<Raster<GPU>>, List<Color>, List<GradientStops>)]
#[implementations(List<Artboard>, List<Graphic>, List<Vector>, List<String>, List<Raster<CPU>>, List<Raster<GPU>>, List<Color>, List<GradientStops>)]
new: List<T>,
) -> List<T> {
let mut base = base;
@@ -514,9 +517,9 @@ pub async fn extend<T: 'n + Send + Clone>(
#[node_macro::node(category(""))]
pub async fn legacy_layer_extend<T: 'n + Send + Clone>(
_: impl Ctx,
#[implementations(List<Artboard>, List<Graphic>, List<Vector>, List<Raster<CPU>>, List<Raster<GPU>>, List<Color>, List<GradientStops>)] base: List<T>,
#[implementations(List<Artboard>, List<Graphic>, List<Vector>, List<String>, List<Raster<CPU>>, List<Raster<GPU>>, List<Color>, List<GradientStops>)] base: List<T>,
#[expose]
#[implementations(List<Artboard>, List<Graphic>, List<Vector>, List<Raster<CPU>>, List<Raster<GPU>>, List<Color>, List<GradientStops>)]
#[implementations(List<Artboard>, List<Graphic>, List<Vector>, List<String>, List<Raster<CPU>>, List<Raster<GPU>>, List<Color>, List<GradientStops>)]
new: List<T>,
nested_node_path: List<NodeId>,
) -> List<T> {
@@ -548,6 +551,7 @@ pub async fn wrap_graphic<T: Into<Graphic> + 'n>(
List<Raster<GPU>>,
List<Color>,
List<GradientStops>,
List<String>,
DAffine2,
DVec2,
)]
@@ -556,8 +560,8 @@ pub async fn wrap_graphic<T: Into<Graphic> + 'n>(
List::new_from_element(content.into())
}
/// Converts a `List` of graphical content into a `List<Graphic>` by placing it into an element of a new wrapper `List<Graphic>`.
/// If it is already a `List<Graphic>`, it is not wrapped again. Use the 'Wrap Graphic' node if wrapping is always desired.
/// Converts a list of graphical content into a `Graphic[]` by placing it into an element of a new wrapper `Graphic[]`.
/// If it is already a `Graphic[]`, it is not wrapped again. Use the 'Wrap Graphic' node if wrapping is always desired.
#[node_macro::node(category("General"))]
pub async fn to_graphic<T: IntoGraphicList + 'n>(
_: impl Ctx,
@@ -568,13 +572,14 @@ pub async fn to_graphic<T: IntoGraphicList + 'n>(
List<Raster<GPU>>,
List<Color>,
List<GradientStops>,
List<String>,
)]
content: T,
) -> List<Graphic> {
content.into_graphic_list()
}
/// Removes a level of nesting from a `List<Graphic>`, or all nesting if "Fully Flatten" is enabled.
/// Removes a level of nesting from a `Graphic[]`, or all nesting if "Fully Flatten" is enabled.
#[node_macro::node(category("General"))]
pub async fn flatten_graphic(_: impl Ctx, content: List<Graphic>, fully_flatten: bool) -> List<Graphic> {
// TODO: Avoid mutable reference, instead return a new List<Graphic>?
@@ -596,7 +601,7 @@ pub async fn flatten_graphic(_: impl Ctx, content: List<Graphic>, fully_flatten:
flatten_list(output_graphic_list, current_element, fully_flatten, recursion_depth + 1);
}
// Push any leaf elements we encounter: either `Graphic::Graphic(...)` values beyond the recursion depth, or non-`Graphic::Graphic` variants (e.g. `Graphic::Vector`, `Graphic::Raster*`, `Graphic::Color`, `Graphic::Gradient`)
// Push any leaf elements we encounter: either `Graphic::Graphic(...)` values beyond the recursion depth, or non-`Graphic::Graphic` variants (e.g. `Graphic::Vector`, `Graphic::Raster*`, `Graphic::Color`, `Graphic::Gradient`, `Graphic::Text`)
_ => {
let attributes = current_graphic_list.clone_item_attributes(index);
output_graphic_list.push(Item::from_parts(current_element, attributes));
@@ -611,7 +616,7 @@ pub async fn flatten_graphic(_: impl Ctx, content: List<Graphic>, fully_flatten:
output
}
/// Converts a `List<Graphic>` into a `List<Vector>` by deeply flattening any vector content it contains, and discarding any non-vector content.
/// Converts a `Graphic[]` into a `Vector[]` by deeply flattening any vector content it contains, and discarding any non-vector content.
#[node_macro::node(category("Vector"))]
pub async fn flatten_vector<T: IntoGraphicList + 'n + Send + Clone>(_: impl Ctx, #[implementations(List<Graphic>, List<Vector>)] content: T) -> List<Vector> {
let graphic_list = content.into_graphic_list();
@@ -644,25 +649,25 @@ pub async fn flatten_vector<T: IntoGraphicList + 'n + Send + Clone>(_: impl Ctx,
output
}
/// Converts a `List<Graphic>` into a `List<Raster>` by deeply flattening any raster content it contains, and discarding any non-raster content.
/// Converts a `Graphic[]` into a `Raster[]` by deeply flattening any raster content it contains, and discarding any non-raster content.
#[node_macro::node(category("Raster"))]
pub async fn flatten_raster<T: IntoGraphicList + 'n + Send + Clone>(_: impl Ctx, #[implementations(List<Graphic>, List<Raster<CPU>>)] content: T) -> List<Raster<CPU>> {
content.into_flattened_list()
}
/// Converts a `List<Graphic>` into a `List<Color>` by deeply flattening any color content it contains, and discarding any non-color content.
/// Converts a `Graphic[]` into a `Color[]` by deeply flattening any color content it contains, and discarding any non-color content.
#[node_macro::node(category("General"))]
pub async fn flatten_color<T: IntoGraphicList + 'n + Send + Clone>(_: impl Ctx, #[implementations(List<Graphic>, List<Color>)] content: T) -> List<Color> {
content.into_flattened_list()
}
/// Converts a `List<Graphic>` into a `List<GradientStops>` by deeply flattening any gradient content it contains, and discarding any non-gradient content.
/// Converts a `Graphic[]` into a `GradientStops[]` by deeply flattening any gradient content it contains, and discarding any non-gradient content.
#[node_macro::node(category("General"))]
pub async fn flatten_gradient<T: IntoGraphicList + 'n + Send + Clone>(_: impl Ctx, #[implementations(List<Graphic>, List<GradientStops>)] content: T) -> List<GradientStops> {
content.into_flattened_list()
}
/// Constructs a gradient from a `List<Color>`, where the colors are evenly distributed as gradient stops across the range from 0 to 1.
/// 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<T: IntoGraphicList + 'n + Send + Clone>(_: impl Ctx, #[implementations(List<Graphic>, List<Color>)] colors: T) -> List<GradientStops> {
let colors = colors.into_flattened_list::<Color>();

View File

@@ -39,6 +39,7 @@ async fn render_intermediate<'a: 'n, T: 'static + Render + WasmNotSend + Send +
Context -> List<Raster<CPU>>,
Context -> List<Color>,
Context -> List<GradientStops>,
Context -> List<String>,
)]
data: impl Node<Context<'static>, Output = T>,
) -> RenderIntermediate {

View File

@@ -1,10 +1,13 @@
use core_types::Ctx;
use core_types::consts::{DEFAULT_FONT_SIZE, DEFAULT_LINE_HEIGHT};
use core_types::list::List;
use core_types::{ATTR_FONT, ATTR_FONT_SIZE, ATTR_LETTER_SPACING, ATTR_LETTER_TILT, ATTR_LINE_HEIGHT, ATTR_MAX_HEIGHT, ATTR_MAX_WIDTH, ATTR_TEXT_ALIGN, Ctx};
use graph_craft::application_io::resource::Resource;
use graphic_types::Vector;
pub use text_nodes::*;
/// Draws a text string as vector geometry with a choice of font and styling.
/// Produces a styled `String[]` carrying all typographic attributes.
///
/// Use the **Text to Vector** node to convert this into vector geometry if desired.
#[node_macro::node(category("Text"))]
fn text(
_: impl Ctx,
@@ -32,8 +35,13 @@ fn text(
/// Additional spacing, in pixels, added between each character.
#[unit(" px")]
#[step(0.1)]
character_spacing: f64,
/// Whether the *Max Width* property is enabled so that lines can wrap to fit its specified block width.
letter_spacing: f64,
/// The angle of faux italic slant applied to each glyph.
#[unit("°")]
#[hard_min(-85.)]
#[hard_max(85.)]
letter_tilt: f64,
/// Enables the maximum width constraint so lines can wrap.
#[widget(ParsedWidgetOverride::Hidden)]
has_max_width: bool,
/// The maximum width that the text block can occupy before wrapping to a new line. Otherwise, lines do not wrap.
@@ -49,27 +57,49 @@ fn text(
#[hard_min(1.)]
#[widget(ParsedWidgetOverride::Custom = "optional_f64")]
max_height: f64,
/// The angle of faux italic slant applied to each glyph.
#[unit("°")]
#[hard_min(-85.)]
#[hard_max(85.)]
tilt: f64,
/// The horizontal alignment of each line of text within its surrounding box.
/// To have an effect on a single line of text, *Max Width* must be set.
/// The horizontal alignment of each line of text within its surrounding box. To have an effect on a single line of text, *Max Width* must be set.
#[widget(ParsedWidgetOverride::Custom = "text_align")]
align: TextAlign,
) -> List<String> {
let mut list = List::new_from_element(text);
if font != Resource::default() {
list.set_attribute(ATTR_FONT, 0, font);
}
if (size - DEFAULT_FONT_SIZE).abs() > f64::EPSILON {
list.set_attribute(ATTR_FONT_SIZE, 0, size);
}
if (line_height - DEFAULT_LINE_HEIGHT).abs() > f64::EPSILON {
list.set_attribute(ATTR_LINE_HEIGHT, 0, line_height);
}
if letter_spacing != 0. {
list.set_attribute(ATTR_LETTER_SPACING, 0, letter_spacing);
}
if letter_tilt != 0. {
list.set_attribute(ATTR_LETTER_TILT, 0, letter_tilt);
}
if has_max_width {
list.set_attribute(ATTR_MAX_WIDTH, 0, Some(max_width));
}
if has_max_height {
list.set_attribute(ATTR_MAX_HEIGHT, 0, Some(max_height));
}
if align != TextAlign::default() {
list.set_attribute(ATTR_TEXT_ALIGN, 0, align);
}
list
}
/// Converts a styled `String[]` into vector geometry.
#[node_macro::node(category("Text"), name("Text to Vector"))]
fn text_to_vector(
_: impl Ctx,
/// A styled list of text strings produced by the **Text** node (or any other `String[]` source).
#[implementations(List<String>)]
strings: List<String>,
/// Whether to split every letterform into its own vector item. Otherwise, a single vector compound path is produced.
separate_glyphs: bool,
) -> List<Vector> {
let typesetting = TypesettingConfig {
font_size: size,
line_height_ratio: line_height,
character_spacing,
max_width: has_max_width.then_some(max_width),
max_height: has_max_height.then_some(max_height),
tilt,
align,
};
to_path(&text, &font, typesetting, separate_glyphs)
shape_text_list(&strings, separate_glyphs)
}

View File

@@ -10,6 +10,7 @@ license = "MIT OR Apache-2.0"
# Local dependencies
core-types = { workspace = true }
graphic-types = { workspace = true }
text-nodes = { workspace = true }
node-macro = { workspace = true }
glam = { workspace = true }
linesweeper = { workspace = true }

View File

@@ -278,6 +278,18 @@ fn flatten_vector(graphic_list: &List<Graphic>) -> List<Vector> {
Item::from_parts(element, attributes)
})
.collect::<Vec<_>>(),
Graphic::Text(text) => {
// Shape the glyphs into vectors (each item's own transform is applied), then compose the parent's transform like the other arms
let parent_transform: DAffine2 = graphic_list.attribute_cloned_or_default(ATTR_TRANSFORM, index);
text_nodes::shape_text_list(&text, false)
.into_iter()
.map(|mut sub_vector| {
let current_transform: DAffine2 = sub_vector.attribute_cloned_or_default(ATTR_TRANSFORM);
*sub_vector.attribute_mut_or_insert_default(ATTR_TRANSFORM) = parent_transform * current_transform;
sub_vector
})
.collect::<Vec<_>>()
}
}
})
.collect()

View File

@@ -0,0 +1,5 @@
use graphene_resource::Resource;
use std::sync::LazyLock;
const FALLBACK_FONT_BYTES: &[u8] = include_bytes!("source-sans-pro-regular.ttf");
pub static FALLBACK_FONT_RESOURCE: LazyLock<Resource> = LazyLock::new(|| Resource::new(FALLBACK_FONT_BYTES));

View File

@@ -1,3 +1,4 @@
pub mod fallback;
mod font;
pub mod json;
mod path_builder;
@@ -16,8 +17,9 @@ use unicode_segmentation::UnicodeSegmentation;
// Re-export for convenience
pub use core_types as gcore;
pub use fallback::FALLBACK_FONT_RESOURCE;
pub use font::*;
pub use text_context::TextContext;
pub use text_context::{TextContext, for_each_styled_glyph_run};
pub use to_path::*;
pub use vector_types;
@@ -92,10 +94,10 @@ impl TextAlign {
pub struct TypesettingConfig {
pub font_size: f64,
pub line_height_ratio: f64,
pub character_spacing: f64,
pub letter_spacing: f64,
pub letter_tilt: f64,
pub max_width: Option<f64>,
pub max_height: Option<f64>,
pub tilt: f64,
pub align: TextAlign,
}
@@ -104,10 +106,10 @@ impl Default for TypesettingConfig {
Self {
font_size: 24.,
line_height_ratio: 1.2,
character_spacing: 0.,
letter_spacing: 0.,
letter_tilt: 0.,
max_width: None,
max_height: None,
tilt: 0.,
align: TextAlign::default(),
}
}

View File

@@ -105,19 +105,19 @@ impl PathBuilder {
has_geometry
}
pub fn render_glyph_run(&mut self, glyph_run: &GlyphRun<'_, ()>, tilt: f64, per_glyph_items: bool, x_offset: f32, space_extra: f32) {
pub fn render_glyph_run(&mut self, glyph_run: &GlyphRun<'_, ()>, letter_tilt: f64, per_glyph_items: bool, x_offset: f32, space_extra: f32) {
let mut run_x = glyph_run.offset() + x_offset;
let run_y = glyph_run.baseline();
let run = glyph_run.run();
// User-requested tilt applied around baseline to avoid vertical displacement
// User-requested letter tilt applied around baseline to avoid vertical displacement
// Translation ensures rotation point is at the baseline, not origin
let skew = if per_glyph_items {
DAffine2::from_cols_array(&[1., 0., -tilt.to_radians().tan(), 1., 0., 0.])
DAffine2::from_cols_array(&[1., 0., -letter_tilt.to_radians().tan(), 1., 0., 0.])
} else {
DAffine2::from_translation(DVec2::new(0., run_y as f64))
* DAffine2::from_cols_array(&[1., 0., -tilt.to_radians().tan(), 1., 0., 0.])
* DAffine2::from_cols_array(&[1., 0., -letter_tilt.to_radians().tan(), 1., 0., 0.])
* DAffine2::from_translation(DVec2::new(0., -run_y as f64))
};

Binary file not shown.

View File

@@ -1,19 +1,71 @@
use super::TypesettingConfig;
use super::path_builder::PathBuilder;
use core::cell::RefCell;
use core_types::list::List;
use glam::DVec2;
use graphene_resource::{Resource, ResourceHash};
use parley::fontique::{Blob, FamilyId, FontInfo};
use parley::{AlignmentOptions, FontContext, Layout, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty};
use parley::{AlignmentOptions, FontContext, GlyphRun, Layout, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty};
use std::collections::HashMap;
use vector_types::Vector;
use super::path_builder::PathBuilder;
thread_local! {
static THREAD_TEXT: RefCell<TextContext> = RefCell::new(TextContext::default());
}
/// Iterates the glyph runs of a laid-out text in reading order, computing each line's last-line alignment correction
/// (`x_offset` and per-space `space_extra`) and skipping runs clipped by `max_height`. Shared by the vector shaper and the
/// SVG/Vello text renderers so the alignment logic lives in one place.
pub fn for_each_styled_glyph_run(layout: &Layout<()>, text: &str, typesetting: TypesettingConfig, mut visit: impl FnMut(&GlyphRun<'_, ()>, f32, f32)) {
let alignment_width = typesetting.max_width.map(|w| w as f32).unwrap_or_else(|| layout.full_width());
let last_line_correction = typesetting.align.last_line_correction();
for line in layout.lines() {
let range = line.text_range();
// Parley always includes a hard-break `\n` as the last byte of the preceding line's range, so the line is at the end of
// a paragraph if it's the very last line of the buffer or its text ends with `\n`.
let is_last_para_line = range.end == text.len() || text.get(range.clone()).is_some_and(|s| s.ends_with('\n'));
let mut x_offset = 0.;
let mut space_extra = 0.;
if is_last_para_line && let Some(correction) = last_line_correction {
let metrics = line.metrics();
let content_advance = metrics.advance - metrics.trailing_whitespace;
let free_space = alignment_width - content_advance;
match correction {
parley::Alignment::Center => x_offset = free_space * 0.5,
parley::Alignment::Right => x_offset = free_space,
parley::Alignment::Justify => {
// Exclude trailing-whitespace clusters from the divisor so the redistribution stretches only the internal spaces.
// Parley's `trailing_whitespace` is in advance units, not bytes, so we re-derive the byte boundary here to filter cluster ranges.
let line_text = text.get(range.clone()).unwrap_or("");
let trailing_len = line_text.len() - line_text.trim_end().len();
let visible_end_index = range.end - trailing_len;
let space_count: usize = line
.runs()
.map(|run| run.clusters().filter(|c| c.is_space_or_nbsp() && c.text_range().start < visible_end_index).count())
.sum();
if space_count > 0 {
space_extra = free_space / space_count as f32;
}
}
_ => {}
}
}
for item in line.items() {
if let PositionedLayoutItem::GlyphRun(glyph_run) = item
&& typesetting.max_height.filter(|&max_height| glyph_run.baseline() > max_height as f32).is_none()
{
visit(&glyph_run, x_offset, space_extra);
}
}
}
}
/// Unified thread-local text processing context that combines font and layout management
/// for efficient text rendering operations.
#[derive(Default)]
@@ -54,14 +106,14 @@ impl TextContext {
}
/// Create a text layout from the given font resource and typesetting configuration.
fn layout_text(&mut self, text: &str, font: &Resource, typesetting: TypesettingConfig) -> Option<Layout<()>> {
pub fn layout_text(&mut self, text: &str, font: &Resource, typesetting: TypesettingConfig) -> Option<Layout<()>> {
let (font_family, font_info) = self.get_font_info(font)?;
const DISPLAY_SCALE: f32 = 1.;
let mut builder = self.layout_context.ranged_builder(&mut self.font_context, text, DISPLAY_SCALE, false);
builder.push_default(StyleProperty::FontSize(typesetting.font_size as f32));
builder.push_default(StyleProperty::LetterSpacing(typesetting.character_spacing as f32));
builder.push_default(StyleProperty::LetterSpacing(typesetting.letter_spacing as f32));
builder.push_default(StyleProperty::FontFamily(parley::FontFamily::Single(parley::FontFamilyName::Named(std::borrow::Cow::Owned(
font_family,
)))));
@@ -100,53 +152,11 @@ impl TextContext {
})
.unwrap_or_default();
let alignment_width = typesetting.max_width.map(|w| w as f32).unwrap_or_else(|| layout.full_width());
let last_line_correction = typesetting.align.last_line_correction();
let mut path_builder = PathBuilder::new(per_glyph_items, layout.scale() as f64, text_frame_size, first_glyph_offset);
for line in layout.lines() {
let range = line.text_range();
// Parley always includes a hard-break `\n` as the last byte of the preceding line's range, so the line
// is at the end of a paragraph if it's the very last line of the buffer or its text ends with `\n`.
let is_last_para_line = range.end == text.len() || text.get(range.clone()).is_some_and(|s| s.ends_with('\n'));
let (x_offset, space_extra) = if let (true, Some(correction)) = (is_last_para_line, last_line_correction) {
let metrics = line.metrics();
let content_advance = metrics.advance - metrics.trailing_whitespace;
let free_space = alignment_width - content_advance;
match correction {
parley::Alignment::Center => (free_space * 0.5, 0.),
parley::Alignment::Right => (free_space, 0.),
parley::Alignment::Justify => {
// Exclude trailing-whitespace clusters from the divisor so the redistribution stretches only the internal spaces.
// Parley's `trailing_whitespace` is in advance units, not bytes, so we re-derive the byte boundary here to filter cluster ranges.
let line_text = text.get(range.clone()).unwrap_or("");
let trailing_len = line_text.len() - line_text.trim_end().len();
let visible_end_index = range.end - trailing_len;
let space_count: usize = line
.runs()
.map(|run| run.clusters().filter(|c| c.is_space_or_nbsp() && c.text_range().start < visible_end_index).count())
.sum();
let extra = if space_count > 0 { free_space / space_count as f32 } else { 0. };
(0., extra)
}
_ => (0., 0.),
}
} else {
(0., 0.)
};
for item in line.items() {
if let PositionedLayoutItem::GlyphRun(glyph_run) = item
&& typesetting.max_height.filter(|&max_height| glyph_run.baseline() > max_height as f32).is_none()
{
path_builder.render_glyph_run(&glyph_run, typesetting.tilt, per_glyph_items, x_offset, space_extra);
}
}
}
for_each_styled_glyph_run(&layout, text, typesetting, |glyph_run, x_offset, space_extra| {
path_builder.render_glyph_run(glyph_run, typesetting.letter_tilt, per_glyph_items, x_offset, space_extra);
});
path_builder.finalize()
}

View File

@@ -1,7 +1,13 @@
use super::TypesettingConfig;
use super::text_context::TextContext;
use core_types::blending::BlendMode;
use core_types::list::List;
use glam::DVec2;
use core_types::uuid::NodeId;
use core_types::{
ATTR_BLEND_MODE, ATTR_EDITOR_LAYER_PATH, ATTR_FONT, ATTR_FONT_SIZE, ATTR_LETTER_SPACING, ATTR_LETTER_TILT, ATTR_LINE_HEIGHT, ATTR_MAX_HEIGHT, ATTR_MAX_WIDTH, ATTR_OPACITY, ATTR_OPACITY_FILL,
ATTR_TEXT_ALIGN, ATTR_TRANSFORM,
};
use glam::{DAffine2, DVec2};
use graphene_resource::Resource;
use vector_types::Vector;
@@ -16,3 +22,63 @@ pub fn bounding_box(text: &str, font: &Resource, typesetting: TypesettingConfig,
pub fn lines_clipping(text: &str, font: &Resource, typesetting: TypesettingConfig) -> bool {
TextContext::with_thread_local(|ctx| ctx.lines_clipping(text, font, typesetting))
}
/// Shapes each string item of a styled `List<String>` into vector geometry, reading its font and typesetting
/// from the item's attributes (as set by the 'Text' node) and re-applying its transform and blending
/// attributes onto the produced paths. With `separate_glyphs`, each glyph becomes its own item.
pub fn shape_text_list(strings: &List<String>, separate_glyphs: bool) -> List<Vector> {
let mut result = List::new();
for index in 0..strings.len() {
let Some(text) = strings.element(index) else { continue };
if text.is_empty() {
continue;
}
// Use fallback font when none is explicitly attached.
let font: Resource = {
let f: Resource = strings.attribute_cloned_or_default(ATTR_FONT, index);
if f.is_empty() { super::FALLBACK_FONT_RESOURCE.clone() } else { f }
};
let defaults = TypesettingConfig::default();
let typesetting = TypesettingConfig {
font_size: strings.attribute_cloned_or(ATTR_FONT_SIZE, index, defaults.font_size),
line_height_ratio: strings.attribute_cloned_or(ATTR_LINE_HEIGHT, index, defaults.line_height_ratio),
letter_spacing: strings.attribute_cloned_or(ATTR_LETTER_SPACING, index, defaults.letter_spacing),
letter_tilt: strings.attribute_cloned_or(ATTR_LETTER_TILT, index, defaults.letter_tilt),
max_width: strings.attribute_cloned_or::<Option<f64>>(ATTR_MAX_WIDTH, index, defaults.max_width),
max_height: strings.attribute_cloned_or::<Option<f64>>(ATTR_MAX_HEIGHT, index, defaults.max_height),
align: strings.attribute_cloned_or(ATTR_TEXT_ALIGN, index, defaults.align),
};
let vectors = to_path(text, &font, typesetting, separate_glyphs);
let transform = strings.attribute_cloned_or_default::<DAffine2>(ATTR_TRANSFORM, index);
let layer_path = strings.attribute_cloned_or_default::<List<NodeId>>(ATTR_EDITOR_LAYER_PATH, index);
let blend_mode = strings.attribute::<BlendMode>(ATTR_BLEND_MODE, index).copied();
let opacity = strings.attribute::<f64>(ATTR_OPACITY, index).copied();
let opacity_fill = strings.attribute::<f64>(ATTR_OPACITY_FILL, index).copied();
for mut item in vectors.into_iter() {
if transform != DAffine2::IDENTITY {
let local = item.attribute_cloned_or_default::<DAffine2>(ATTR_TRANSFORM);
item.set_attribute(ATTR_TRANSFORM, transform * local);
}
if !layer_path.is_empty() {
item.set_attribute(ATTR_EDITOR_LAYER_PATH, layer_path.clone());
}
if let Some(blend_mode) = blend_mode {
item.set_attribute(ATTR_BLEND_MODE, blend_mode);
}
if let Some(opacity) = opacity {
item.set_attribute(ATTR_OPACITY, opacity);
}
if let Some(opacity_fill) = opacity_fill {
item.set_attribute(ATTR_OPACITY_FILL, opacity_fill);
}
result.push(item);
}
}
result
}

View File

@@ -17,6 +17,7 @@ async fn transform<T: ApplyTransform + 'n + 'static>(
Context -> DAffine2,
Context -> DVec2,
Context -> List<Graphic>,
Context -> List<String>,
Context -> List<Vector>,
Context -> List<Raster<CPU>>,
Context -> List<Raster<GPU>>,

View File

@@ -259,7 +259,7 @@ async fn copy_to_points<I: 'n + Send + Clone>(
points: List<Vector>,
/// Artwork to be copied and placed at each point.
#[expose]
#[implementations(List<Graphic>, List<Vector>, List<Raster<CPU>>, List<Color>, List<GradientStops>)]
#[implementations(List<Graphic>, List<Vector>, List<String>, List<Raster<CPU>>, List<Color>, List<GradientStops>)]
content: List<I>,
/// Minimum range of randomized sizes given to each placed copy.
#[default(1)]