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

@@ -1,5 +1,6 @@
use super::DocumentNode;
use crate::application_io::PlatformEditorApi;
use crate::application_io::resource::Resource;
use crate::proto::{Any as DAny, FutureAny};
use brush_nodes::brush_stroke::BrushStroke;
use core_types::color::SRGBA8;
@@ -37,6 +38,7 @@ macro_rules! for_each_type_default {
$action!(List<Vector>);
$action!(List<String>);
$action!(DocumentNode);
$action!(Resource);
};
}

View File

@@ -7,3 +7,5 @@ pub const LAYER_OUTLINE_STROKE_WEIGHT: f64 = 0.5;
// Fonts
pub const DEFAULT_FONT_FAMILY: &str = "Lato";
pub const DEFAULT_FONT_STYLE: &str = "Regular (400)";
pub const DEFAULT_FONT_SIZE: f64 = 24.;
pub const DEFAULT_LINE_HEIGHT: f64 = 1.2;

View File

@@ -25,7 +25,8 @@ pub use graphene_hash;
pub use graphene_hash::CacheHash;
pub use list::{
ATTR_BACKGROUND, ATTR_BLEND_MODE, ATTR_CLIP, ATTR_CLIPPING_MASK, ATTR_DIMENSIONS, ATTR_EDITOR_CLICK_TARGET, ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_EDITOR_TEXT_FRAME, ATTR_END,
ATTR_GRADIENT_TYPE, ATTR_LOCATION, ATTR_NAME, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_SPREAD_METHOD, ATTR_START, ATTR_TRANSFORM, ATTR_TYPE,
ATTR_FONT, ATTR_FONT_SIZE, ATTR_GRADIENT_TYPE, ATTR_LETTER_SPACING, ATTR_LETTER_TILT, ATTR_LINE_HEIGHT, ATTR_LOCATION, ATTR_MAX_HEIGHT, ATTR_MAX_WIDTH, ATTR_NAME, ATTR_OPACITY, ATTR_OPACITY_FILL,
ATTR_SPREAD_METHOD, ATTR_START, ATTR_TEXT_ALIGN, ATTR_TRANSFORM, ATTR_TYPE,
};
pub use memo::MemoHash;
pub use no_std_types::AsU32;

View File

@@ -12,76 +12,72 @@ use std::fmt::Debug;
/// Item's `DAffine2` transformation, composed multiplicatively through nested groups.
pub const ATTR_TRANSFORM: &str = "transform";
/// Item's `BlendMode`, controlling how it composites with content beneath it.
pub const ATTR_BLEND_MODE: &str = "blend_mode";
/// Item's opacity multiplier (`f64`, implicit default `1.`).
/// Composed multiplicatively through nested groups. Affects content clipped to the item.
pub const ATTR_OPACITY: &str = "opacity";
/// Item's fill opacity multiplier (`f64`, implicit default `1.`).
/// Like opacity but does not affect content clipped to the item.
pub const ATTR_OPACITY_FILL: &str = "opacity_fill";
/// `bool` for whether an item inherits the alpha of the content beneath it (clipping mask).
pub const ATTR_CLIPPING_MASK: &str = "clipping_mask";
/// `List<NodeId>` path from the root network to the layer node owning this item.
/// Used by editor tools to route clicks/selection back to the originating layer.
pub const ATTR_EDITOR_LAYER_PATH: &str = "editor:layer_path";
/// `List<Graphic>` snapshot of the upstream content that fed into a destructive merge
/// (Boolean Operation, Rasterize, etc.), so the editor can still surface click targets for
/// the original child layers after their content has been collapsed.
pub const ATTR_EDITOR_MERGED_LAYERS: &str = "editor:merged_layers";
/// Optional `Vector` that overrides the item's own geometry for click-target generation.
/// Used by the 'Text' node for per-glyph bounding-box rectangles so glyphs are selectable
/// by clicking anywhere within their bounds, not just the filled letterform.
pub const ATTR_EDITOR_CLICK_TARGET: &str = "editor:click_target";
/// `DAffine2` mapping the unit square `[(0, 0), (1, 1)]` (top-left convention) onto the 'Text'
/// node's text frame in this item's local space. Each item carries the frame relative to its own
/// glyph origin so it survives `Index Elements` filtering. The Text tool reads this to position
/// its drag cage. Stored as an affine to allow non-axis-aligned frames in the future.
pub const ATTR_EDITOR_TEXT_FRAME: &str = "editor:text_frame";
/// `u64` byte offset where a regex match begins ('Regex Find All', 'Regex Capture' text nodes).
pub const ATTR_START: &str = "start";
/// `u64` byte offset where a regex match ends ('Regex Find All', 'Regex Capture' text nodes).
pub const ATTR_END: &str = "end";
/// `String` for a regex named-capture-group's name, or empty for unnamed groups ('Regex Capture' text node).
pub const ATTR_NAME: &str = "name";
/// `String` for a JSON value's type (`"string"`, `"number"`, `"object"`, etc.) from 'JSON Query All'.
pub const ATTR_TYPE: &str = "type";
/// Artboard's `DVec2` top-left corner in document coordinates.
pub const ATTR_LOCATION: &str = "location";
/// Artboard's `DVec2` width and height.
pub const ATTR_DIMENSIONS: &str = "dimensions";
/// Artboard's `Color` background fill.
pub const ATTR_BACKGROUND: &str = "background";
/// `bool` for whether an artboard clips content to its bounds.
pub const ATTR_CLIP: &str = "clip";
/// Gradient's `GradientSpreadMethod` (`Pad`, `Reflect`, or `Repeat`).
pub const ATTR_SPREAD_METHOD: &str = "spread_method";
/// Gradient's `GradientType` (`Linear` or `Radial`).
pub const ATTR_GRADIENT_TYPE: &str = "gradient_type";
/// Vector graphics object's filled area paint, of type List<T> where T is any graphic type.
pub const ATTR_FILL: &str = "fill";
/// Vector graphics object's stroke paint, of type List<T> where T is any graphic type.
pub const ATTR_STROKE: &str = "stroke";
/// Text item's font size in document-space units (`f64`, implicit default `24.`).
pub const ATTR_FONT_SIZE: &str = "font_size";
/// Text item's font, as a `Resource` of the loaded font file.
pub const ATTR_FONT: &str = "font";
/// Text item's line height as a ratio of the font size (`f64`, implicit default `1.2`).
pub const ATTR_LINE_HEIGHT: &str = "line_height";
/// Text item's extra spacing between letters in document-space units (`f64`, implicit default `0.`).
pub const ATTR_LETTER_SPACING: &str = "letter_spacing";
/// Text item's maximum line-wrap width in document-space units (`Option<f64>`, implicit default `None`).
pub const ATTR_MAX_WIDTH: &str = "max_width";
/// Text item's maximum block height in document-space units, past which lines are not drawn (`Option<f64>`, implicit default `None`).
pub const ATTR_MAX_HEIGHT: &str = "max_height";
/// Text item's faux-italic letter tilt angle in degrees (`f64`, implicit default `0.`).
pub const ATTR_LETTER_TILT: &str = "letter_tilt";
/// Text item's `TextAlign` horizontal alignment of lines within the block.
pub const ATTR_TEXT_ALIGN: &str = "text_align";
// ===========================
// Implicit attribute defaults

View File

@@ -19,3 +19,9 @@ impl RenderComplexity for Color {
1
}
}
impl RenderComplexity for String {
fn render_complexity(&self) -> usize {
self.chars().count()
}
}

View File

@@ -22,6 +22,7 @@ pub enum Graphic {
RasterGPU(List<Raster<GPU>>),
Color(List<Color>),
Gradient(List<GradientStops>),
Text(List<String>),
}
impl Default for Graphic {
@@ -103,6 +104,18 @@ impl From<List<GradientStops>> for Graphic {
}
}
// String
impl From<String> for Graphic {
fn from(text: String) -> Self {
Graphic::Text(List::new_from_element(text))
}
}
impl From<List<String>> for Graphic {
fn from(text: List<String>) -> Self {
Graphic::Text(text)
}
}
/// Deeply flattens a `List<Graphic>`, collecting only elements matching a specific variant (extracted by `extract_variant`)
/// and discarding all other non-matching content. Recursion through `Graphic::Graphic` sub-`List`s composes transforms and opacity.
fn flatten_graphic_list<T>(content: List<Graphic>, extract_variant: fn(Graphic) -> Option<List<T>>) -> List<T> {
@@ -325,6 +338,12 @@ impl TryFromGraphic for GradientStops {
}
}
impl TryFromGraphic for String {
fn try_from_graphic(graphic: Graphic) -> Option<List<Self>> {
if let Graphic::Text(t) = graphic { Some(t) } else { None }
}
}
// Local trait to convert types to List<Graphic> (avoids orphan rule issues)
pub trait IntoGraphicList {
fn into_graphic_list(self) -> List<Graphic>;
@@ -381,6 +400,17 @@ impl IntoGraphicList for List<GradientStops> {
}
}
impl IntoGraphicList for List<String> {
fn into_graphic_list(self) -> List<Graphic> {
let layer_path: List<NodeId> = self.attribute_cloned_or_default(ATTR_EDITOR_LAYER_PATH, 0);
let mut graphic_list = List::new_from_element(Graphic::Text(self));
if !layer_path.is_empty() {
graphic_list.set_attribute(ATTR_EDITOR_LAYER_PATH, 0, layer_path);
}
graphic_list
}
}
impl IntoGraphicList for DAffine2 {
fn into_graphic_list(self) -> List<Graphic> {
List::new_from_element(Graphic::default())
@@ -457,6 +487,7 @@ impl Graphic {
Graphic::RasterGPU(list) => all_clipped(list),
Graphic::Color(list) => all_clipped(list),
Graphic::Gradient(list) => all_clipped(list),
Graphic::Text(list) => all_clipped(list),
}
}
@@ -500,7 +531,7 @@ impl Graphic {
}
Graphic::Color(list) => list.element(0).is_some_and(|color| color.is_opaque()),
Graphic::Gradient(list) => list.element(0).is_some_and(|stops| stops.iter().all(|stop| stop.color.is_opaque())),
Graphic::RasterCPU(_) | Graphic::RasterGPU(_) => false,
Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Text(_) => false,
}
}
@@ -520,7 +551,7 @@ impl Graphic {
}),
Graphic::Color(list) => list.iter_element_values().all(|color| color.a() == 0.),
Graphic::Gradient(list) => list.iter_element_values().all(|stops| stops.iter().all(|stop| stop.color.a() == 0.)),
Graphic::RasterCPU(_) | Graphic::RasterGPU(_) => false,
Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Text(_) => false,
}
}
@@ -539,6 +570,7 @@ impl Graphic {
Graphic::Gradient(list) => list.is_empty(),
Graphic::RasterCPU(list) => list.is_empty(),
Graphic::RasterGPU(list) => list.is_empty(),
Graphic::Text(list) => list.is_empty(),
}
}
}
@@ -552,6 +584,7 @@ impl BoundingBox for Graphic {
Graphic::Graphic(list) => list.bounding_box(transform, include_stroke),
Graphic::Color(list) => list.bounding_box(transform, include_stroke),
Graphic::Gradient(list) => list.bounding_box(transform, include_stroke),
Graphic::Text(list) => list.bounding_box(transform, include_stroke),
}
}
@@ -563,6 +596,7 @@ impl BoundingBox for Graphic {
Graphic::Graphic(graphic) => graphic.thumbnail_bounding_box(transform, include_stroke),
Graphic::Color(color) => color.thumbnail_bounding_box(transform, include_stroke),
Graphic::Gradient(gradient) => gradient.thumbnail_bounding_box(transform, include_stroke),
Graphic::Text(list) => list.thumbnail_bounding_box(transform, include_stroke),
}
}
}
@@ -592,6 +626,7 @@ impl RenderComplexity for Graphic {
Self::RasterGPU(list) => list.render_complexity(),
Self::Color(list) => list.render_complexity(),
Self::Gradient(list) => list.render_complexity(),
Self::Text(list) => list.render_complexity(),
}
}
}

View File

@@ -15,6 +15,8 @@ serde = ["dep:serde", "core-types/serde", "vector-types/serde", "graphic-types/s
dyn-any = { workspace = true }
core-types = { workspace = true }
graphene-hash = { workspace = true }
graphene-resource = { workspace = true }
text-nodes = { workspace = true }
# Workspace dependencies
glam = { workspace = true }
@@ -27,6 +29,8 @@ vector-types = { workspace = true }
graphic-types = { workspace = true }
vello = { workspace = true }
vello_encoding = { workspace = true }
parley = { workspace = true }
skrifa = { workspace = true }
# Optional workspace dependencies
serde = { workspace = true, optional = true }

View File

@@ -244,7 +244,7 @@ impl RenderExt for List<Graphic> {
let gradient_id = gradient_list.render(svg_defs, item_transform, element_transform, stroke_transform, bounds, transformed_bounds, render_params, target);
format!(r##" {paint_attr}="url(#{gradient_id})""##)
}
Some(Graphic::Vector(_)) | Some(Graphic::RasterCPU(_)) | Some(Graphic::RasterGPU(_)) | Some(Graphic::Graphic(_)) => {
Some(Graphic::Vector(_)) | Some(Graphic::RasterCPU(_)) | Some(Graphic::RasterGPU(_)) | Some(Graphic::Graphic(_)) | Some(Graphic::Text(_)) => {
let bounds = if target == PaintTarget::Stroke {
// To prevent a wraparound artefact occurring when the tile boundary and the stroke region are perfectly aligned, the local coordinate is expanded slightly.
let inverse = |len: f64| if len > 0. { 1. / len } else { 0. };

View File

@@ -6,18 +6,21 @@ use core_types::bounds::BoundingBox;
use core_types::bounds::RenderBoundingBox;
use core_types::color::Color;
use core_types::color::SRGBA8;
use core_types::consts::DEFAULT_FONT_SIZE;
use core_types::list::{ATTR_FILL, ATTR_STROKE, Item, List};
use core_types::math::quad::Quad;
use core_types::render_complexity::RenderComplexity;
use core_types::transform::Footprint;
use core_types::uuid::{NodeId, generate_uuid};
use core_types::{
ATTR_BACKGROUND, ATTR_BLEND_MODE, ATTR_CLIP, ATTR_CLIPPING_MASK, ATTR_DIMENSIONS, ATTR_EDITOR_CLICK_TARGET, ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_EDITOR_TEXT_FRAME,
ATTR_GRADIENT_TYPE, ATTR_LOCATION, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_SPREAD_METHOD, ATTR_TRANSFORM,
ATTR_BACKGROUND, ATTR_BLEND_MODE, ATTR_CLIP, ATTR_CLIPPING_MASK, ATTR_DIMENSIONS, ATTR_EDITOR_CLICK_TARGET, ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_EDITOR_TEXT_FRAME, ATTR_FONT,
ATTR_FONT_SIZE, ATTR_GRADIENT_TYPE, ATTR_LETTER_SPACING, ATTR_LETTER_TILT, ATTR_LINE_HEIGHT, ATTR_LOCATION, ATTR_MAX_HEIGHT, ATTR_MAX_WIDTH, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_SPREAD_METHOD,
ATTR_TEXT_ALIGN, ATTR_TRANSFORM,
};
use dyn_any::DynAny;
use glam::{DAffine2, DVec2};
use graphene_hash::CacheHashWrapper;
use graphene_resource::Resource;
use graphic_types::graphic::{fill_graphic_list_at, graphic_list_at, is_stroke_fully_transparent_at, stroke_graphic_list_at};
use graphic_types::raster_types::{BitmapMut, CPU, GPU, Image, Raster};
use graphic_types::vector_types::gradient::{GradientStops, GradientType};
@@ -25,10 +28,15 @@ use graphic_types::vector_types::subpath::Subpath;
use graphic_types::vector_types::vector::click_target::{ClickTarget, FreePoint};
use graphic_types::vector_types::vector::style::{Fill, PaintOrder, RenderMode, StrokeAlign, StrokeCap, StrokeJoin};
use graphic_types::{Artboard, Graphic, Vector};
use kurbo::{Affine, Cap, Join, Shape, StrokeOpts};
use kurbo::{Affine, BezPath, Cap, Join, Shape, StrokeOpts};
use num_traits::Zero;
use skrifa::instance::{LocationRef, NormalizedCoord, Size};
use skrifa::outline::{DrawSettings, OutlinePen};
use skrifa::raw::FontRef as SkrifaFontRef;
use skrifa::{GlyphId, MetadataProvider};
use std::collections::{HashMap, HashSet};
use std::fmt::Write;
use std::hash::Hash;
use std::ops::Deref;
use std::sync::{Arc, LazyLock};
use vector_types::gradient::GradientSpreadMethod;
@@ -232,8 +240,10 @@ impl RenderParams {
}
pub fn for_alignment(&self, transform: DAffine2) -> Self {
let alignment_parent_transform = Some(transform);
Self { alignment_parent_transform, ..*self }
Self {
alignment_parent_transform: Some(transform),
..*self
}
}
pub fn for_pattern(&self) -> Self {
@@ -547,6 +557,7 @@ impl Render for Graphic {
Graphic::RasterGPU(_) => (),
Graphic::Color(list) => list.render_svg(render, render_params),
Graphic::Gradient(list) => list.render_svg(render, render_params),
Graphic::Text(list) => list.render_svg(render, render_params),
}
}
@@ -558,6 +569,7 @@ impl Render for Graphic {
Graphic::RasterGPU(list) => list.render_to_vello(scene, transform, context, render_params),
Graphic::Color(list) => list.render_to_vello(scene, transform, context, render_params),
Graphic::Gradient(list) => list.render_to_vello(scene, transform, context, render_params),
Graphic::Text(list) => list.render_to_vello(scene, transform, context, render_params),
}
}
@@ -606,6 +618,14 @@ impl Render for Graphic {
Graphic::Gradient(list) => {
metadata.upstream_footprints.insert(element_id, footprint);
// TODO: Find a way to handle more than the first item
if !list.is_empty() {
metadata.local_transforms.insert(element_id, list.attribute_cloned_or_default(ATTR_TRANSFORM, 0));
}
}
Graphic::Text(list) => {
metadata.upstream_footprints.insert(element_id, footprint);
// TODO: Find a way to handle more than the first item
if !list.is_empty() {
metadata.local_transforms.insert(element_id, list.attribute_cloned_or_default(ATTR_TRANSFORM, 0));
@@ -621,6 +641,7 @@ impl Render for Graphic {
Graphic::RasterGPU(list) => list.collect_metadata(metadata, footprint, element_id),
Graphic::Color(list) => list.collect_metadata(metadata, footprint, element_id),
Graphic::Gradient(list) => list.collect_metadata(metadata, footprint, element_id),
Graphic::Text(list) => list.collect_metadata(metadata, footprint, element_id),
}
}
@@ -632,6 +653,7 @@ impl Render for Graphic {
Graphic::RasterGPU(list) => list.add_upstream_click_targets(click_targets),
Graphic::Color(list) => list.add_upstream_click_targets(click_targets),
Graphic::Gradient(list) => list.add_upstream_click_targets(click_targets),
Graphic::Text(list) => list.add_upstream_click_targets(click_targets),
}
}
@@ -643,6 +665,7 @@ impl Render for Graphic {
Graphic::RasterGPU(list) => list.add_upstream_outline_targets(outlines),
Graphic::Color(list) => list.add_upstream_outline_targets(outlines),
Graphic::Gradient(list) => list.add_upstream_outline_targets(outlines),
Graphic::Text(list) => list.add_upstream_outline_targets(outlines),
}
}
@@ -654,6 +677,7 @@ impl Render for Graphic {
Graphic::RasterGPU(list) => list.contains_artboard(),
Graphic::Color(list) => list.contains_artboard(),
Graphic::Gradient(list) => list.contains_artboard(),
Graphic::Text(list) => list.contains_artboard(),
}
}
@@ -665,6 +689,7 @@ impl Render for Graphic {
Graphic::RasterGPU(_) => (),
Graphic::Color(_) => (),
Graphic::Gradient(_) => (),
Graphic::Text(_) => (),
}
}
}
@@ -1379,7 +1404,7 @@ impl Render for List<Vector> {
let brush_transform = kurbo::Affine::new((inverse_element_transform * parent_transform).to_cols_array());
scene.fill(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), &brush, Some(brush_transform), path);
}
Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_) => {
Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_) | Graphic::Text(_) => {
scene.push_clip_layer(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), path);
paint.render_to_vello(scene, multiplied_transform, context, render_params);
scene.pop_layer();
@@ -1461,7 +1486,7 @@ impl Render for List<Vector> {
scene.stroke(&stroke, kurbo::Affine::new(element_transform.to_cols_array()), &brush, Some(brush_transform), &path);
}
Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_) => {
Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_) | Graphic::Text(_) => {
let stroked = peniko::kurbo::stroke(path.iter(), &stroke, &StrokeOpts::default(), 0.01);
scene.push_clip_layer(peniko::Fill::NonZero, kurbo::Affine::new(element_transform.to_cols_array()), &stroked);
@@ -2244,6 +2269,376 @@ impl Render for List<GradientStops> {
}
}
/// Builds a `kurbo::BezPath` from a glyph outline, baking in the glyph origin (`ox`, `oy`) and faux-italic shear (`tilt_tan`).
struct GlyphOutlinePen<'a> {
path: &'a mut BezPath,
ox: f64,
oy: f64,
tilt_tan: f64,
}
impl GlyphOutlinePen<'_> {
#[inline]
fn px(&self, x: f32, y: f32) -> f64 {
self.ox + x as f64 + (y as f64 * self.tilt_tan)
}
#[inline]
fn py(&self, y: f32) -> f64 {
self.oy - y as f64
}
}
impl OutlinePen for GlyphOutlinePen<'_> {
fn move_to(&mut self, x: f32, y: f32) {
self.path.move_to((self.px(x, y), self.py(y)));
}
fn line_to(&mut self, x: f32, y: f32) {
self.path.line_to((self.px(x, y), self.py(y)));
}
fn quad_to(&mut self, cx: f32, cy: f32, x: f32, y: f32) {
self.path.quad_to((self.px(cx, cy), self.py(cy)), (self.px(x, y), self.py(y)));
}
fn curve_to(&mut self, cx1: f32, cy1: f32, cx2: f32, cy2: f32, x: f32, y: f32) {
self.path.curve_to((self.px(cx1, cy1), self.py(cy1)), (self.px(cx2, cy2), self.py(cy2)), (self.px(x, y), self.py(y)));
}
fn close(&mut self) {
self.path.close_path();
}
}
/// Draws each glyph of `glyph_run` into a `BezPath` (with the run's position and faux-italic `tilt_tan` baked in)
/// and calls `emit` for each non-empty glyph. Zero-geometry glyphs advance by `space_extra` for justified spacing.
fn draw_glyph_run_to_bezpaths(glyph_run: &parley::GlyphRun<'_, ()>, x_offset: f32, space_extra: f32, tilt_tan: f64, mut emit: impl FnMut(&BezPath)) {
let mut run_x = glyph_run.offset() + x_offset;
let run_y = glyph_run.baseline();
let run = glyph_run.run();
let font = run.font();
let font_size_pts = run.font_size();
let normalized_coords: Vec<NormalizedCoord> = run.normalized_coords().iter().map(|c| NormalizedCoord::from_bits(*c)).collect();
let Ok(font_ref) = SkrifaFontRef::from_index(font.data.as_ref(), font.index) else { return };
let outlines = font_ref.outline_glyphs();
let mut bez_path = BezPath::new();
for glyph in glyph_run.glyphs() {
let ox = (run_x + glyph.x) as f64;
let oy = (run_y - glyph.y) as f64;
run_x += glyph.advance;
let Some(outline) = outlines.get(GlyphId::from(glyph.id)) else { continue };
let settings = DrawSettings::unhinted(Size::new(font_size_pts), LocationRef::new(&normalized_coords));
bez_path.truncate(0);
let path = &mut bez_path;
let mut pen = GlyphOutlinePen { path, ox, oy, tilt_tan };
if outline.draw(settings, &mut pen).is_ok() && !bez_path.elements().is_empty() {
emit(&bez_path);
} else if space_extra != 0. && glyph.advance > 0. {
run_x += space_extra;
}
}
}
/// Lays out text item `index` of a styled `List<String>` and returns its local size and transform. The `BoundingBox` trait can't do
/// this since a bare `String` carries no typography, so click-target and bounding-box computation share this. Falls back to an em
/// square if the font isn't registered yet.
fn text_item_size_and_transform(list: &List<String>, index: usize) -> Option<(DVec2, DAffine2)> {
let text = list.element(index)?;
let font: Resource = {
let f: Resource = list.attribute_cloned_or_default(ATTR_FONT, index);
if f.is_empty() { text_nodes::FALLBACK_FONT_RESOURCE.clone() } else { f }
};
let font_size: f64 = list.attribute_cloned_or(ATTR_FONT_SIZE, index, DEFAULT_FONT_SIZE);
let line_height: f64 = list.attribute_cloned_or(ATTR_LINE_HEIGHT, index, 1.2);
let letter_spacing: f64 = list.attribute_cloned_or(ATTR_LETTER_SPACING, index, 0.);
let max_width: Option<f64> = list.attribute_cloned_or(ATTR_MAX_WIDTH, index, None);
let max_height: Option<f64> = list.attribute_cloned_or(ATTR_MAX_HEIGHT, index, None);
let align: text_nodes::TextAlign = list.attribute_cloned_or_default(ATTR_TEXT_ALIGN, index);
let transform: DAffine2 = list.attribute_cloned_or_default(ATTR_TRANSFORM, index);
let typesetting = text_nodes::TypesettingConfig {
font_size,
line_height_ratio: line_height,
letter_spacing,
letter_tilt: 0.,
max_width,
max_height,
align,
};
let (width, height) = text_nodes::TextContext::with_thread_local(|ctx| {
ctx.layout_text(text, &font, typesetting).map(|layout| {
let w = max_width.unwrap_or_else(|| layout.width() as f64);
let h = max_height.unwrap_or_else(|| layout.height() as f64);
(w, h)
})
})
.unwrap_or((font_size, font_size));
Some((DVec2::new(width, height), transform))
}
/// Union bounding box of a styled `List<String>`, laid out per item. The `BoundingBox` trait returns `None` for `List<String>`
/// (a bare `String` has no extent), so text-layer thumbnails and bounds use this instead. Each item is laid out under `outer_transform`.
pub fn text_list_bounding_box(list: &List<String>, outer_transform: DAffine2) -> RenderBoundingBox {
let mut bounds: Option<[DVec2; 2]> = None;
for index in 0..list.len() {
let Some((size, transform)) = text_item_size_and_transform(list, index) else { continue };
let full_transform = outer_transform * transform;
for corner in [DVec2::ZERO, DVec2::new(size.x, 0.), DVec2::new(0., size.y), size] {
let point = full_transform.transform_point2(corner);
bounds = Some(match bounds {
Some([min, max]) => [min.min(point), max.max(point)],
None => [point, point],
});
}
}
match bounds {
Some(bounds) => RenderBoundingBox::Rectangle(bounds),
None => RenderBoundingBox::None,
}
}
/// Like `List<Graphic>::thumbnail_bounding_box`, but lays out `Graphic::Text` items, which the `BoundingBox` trait reports as `None`.
/// Used for layer thumbnails so text layers (whose content is a `List<Graphic>` wrapping the text) frame their content.
pub fn graphic_list_bounding_box(list: &List<Graphic>, transform: DAffine2) -> RenderBoundingBox {
let mut combined: Option<[DVec2; 2]> = None;
let mut any_infinite = false;
for index in 0..list.len() {
let item_transform = transform * list.attribute_cloned_or_default::<DAffine2>(ATTR_TRANSFORM, index);
let Some(graphic) = list.element(index) else { continue };
let bounds = match graphic {
Graphic::Text(text_list) => text_list_bounding_box(text_list, item_transform),
Graphic::Graphic(sub_list) => graphic_list_bounding_box(sub_list, item_transform),
other => other.thumbnail_bounding_box(item_transform, true),
};
match bounds {
RenderBoundingBox::None => {}
RenderBoundingBox::Infinite => any_infinite = true,
RenderBoundingBox::Rectangle([min, max]) => {
combined = Some(match combined {
Some([existing_min, existing_max]) => [existing_min.min(min), existing_max.max(max)],
None => [min, max],
})
}
}
}
match (combined, any_infinite) {
(Some(bounds), _) => RenderBoundingBox::Rectangle(bounds),
(None, true) => RenderBoundingBox::Infinite,
(None, false) => RenderBoundingBox::None,
}
}
impl Render for List<String> {
fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) {
for index in 0..self.len() {
let Some(text) = self.element(index) else { continue };
if text.is_empty() {
continue;
}
let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index);
let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.);
let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.);
let blend_mode_attr: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index);
let font: Resource = {
let f: Resource = self.attribute_cloned_or_default(ATTR_FONT, index);
if f.is_empty() { text_nodes::FALLBACK_FONT_RESOURCE.clone() } else { f }
};
let font_size: f64 = self.attribute_cloned_or(ATTR_FONT_SIZE, index, DEFAULT_FONT_SIZE);
let line_height: f64 = self.attribute_cloned_or(ATTR_LINE_HEIGHT, index, 1.2);
let letter_spacing: f64 = self.attribute_cloned_or(ATTR_LETTER_SPACING, index, 0.);
let max_width: Option<f64> = self.attribute_cloned_or(ATTR_MAX_WIDTH, index, None);
let max_height: Option<f64> = self.attribute_cloned_or(ATTR_MAX_HEIGHT, index, None);
let letter_tilt: f64 = self.attribute_cloned_or(ATTR_LETTER_TILT, index, 0.);
let align: text_nodes::TextAlign = self.attribute_cloned_or_default(ATTR_TEXT_ALIGN, index);
let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32;
let typesetting = text_nodes::TypesettingConfig {
font_size,
line_height_ratio: line_height,
letter_spacing,
letter_tilt,
max_width,
max_height,
align,
};
let mut glyph_paths: Vec<String> = Vec::new();
text_nodes::TextContext::with_thread_local(|ctx| {
let Some(layout) = ctx.layout_text(text, &font, typesetting) else { return };
let tilt_tan = letter_tilt.to_radians().tan();
text_nodes::for_each_styled_glyph_run(&layout, text, typesetting, |glyph_run, x_offset, space_extra| {
draw_glyph_run_to_bezpaths(glyph_run, x_offset, space_extra, tilt_tan, |bez_path| {
glyph_paths.push(bez_path.to_svg());
});
});
});
if glyph_paths.is_empty() {
continue;
}
// Wrap all glyph <path> elements in a <g> with the item's transform/opacity/blend-mode.
render.parent_tag(
"g",
|attributes| {
let matrix = format_transform_matrix(transform);
if !matrix.is_empty() {
attributes.push("transform", matrix);
}
if opacity < 1. {
attributes.push("opacity", opacity.to_string());
}
if blend_mode_attr != BlendMode::default() {
attributes.push("style", blend_mode_attr.render());
}
},
|render| {
for path_d in glyph_paths {
render.leaf_tag("path", |attributes| {
attributes.push("d", path_d);
if let RenderMode::Outline = render_params.render_mode {
attributes.push("fill", "none");
attributes.push("stroke", "black");
attributes.push("stroke-width", "1");
} else {
attributes.push("fill", "black");
attributes.push("fill-rule", "nonzero");
}
});
}
},
);
}
}
fn render_to_vello(&self, scene: &mut Scene, transform: DAffine2, _context: &mut RenderContext, render_params: &RenderParams) {
for index in 0..self.len() {
let Some(text) = self.element(index) else { continue };
if text.is_empty() {
continue;
}
let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index);
let font: Resource = {
let f: Resource = self.attribute_cloned_or_default(ATTR_FONT, index);
if f.is_empty() { text_nodes::FALLBACK_FONT_RESOURCE.clone() } else { f }
};
let font_size: f64 = self.attribute_cloned_or(ATTR_FONT_SIZE, index, DEFAULT_FONT_SIZE);
let line_height: f64 = self.attribute_cloned_or(ATTR_LINE_HEIGHT, index, 1.2);
let letter_spacing: f64 = self.attribute_cloned_or(ATTR_LETTER_SPACING, index, 0.);
let max_width: Option<f64> = self.attribute_cloned_or(ATTR_MAX_WIDTH, index, None);
let max_height: Option<f64> = self.attribute_cloned_or(ATTR_MAX_HEIGHT, index, None);
let letter_tilt: f64 = self.attribute_cloned_or(ATTR_LETTER_TILT, index, 0.);
let align: text_nodes::TextAlign = self.attribute_cloned_or_default(ATTR_TEXT_ALIGN, index);
let blend_mode_attr: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index);
let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.);
let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.);
let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32;
let typesetting = text_nodes::TypesettingConfig {
font_size,
line_height_ratio: line_height,
letter_spacing,
letter_tilt,
max_width,
max_height,
align,
};
let affine = Affine::new((transform * item_transform).to_cols_array());
text_nodes::TextContext::with_thread_local(|ctx| {
let Some(layout) = ctx.layout_text(text, &font, typesetting) else { return };
let needs_layer = opacity < 1. || blend_mode_attr != BlendMode::default();
if needs_layer {
let alignment_width = max_width.map(|w| w as f32).unwrap_or_else(|| layout.full_width());
let blending = peniko::BlendMode::new(blend_mode_attr.to_peniko(), peniko::Compose::SrcOver);
let padding = font_size;
let bounds = kurbo::Rect::new(-padding, -padding, alignment_width as f64 + padding, layout.height() as f64 + padding);
let transformed_bounds = affine.transform_rect_bbox(bounds);
scene.push_layer(peniko::Fill::NonZero, blending, opacity, kurbo::Affine::IDENTITY, &transformed_bounds);
}
let tilt_tan = letter_tilt.to_radians().tan();
text_nodes::for_each_styled_glyph_run(&layout, text, typesetting, |glyph_run, x_offset, space_extra| {
draw_glyph_run_to_bezpaths(glyph_run, x_offset, space_extra, tilt_tan, |bez_path| {
if let RenderMode::Outline = render_params.render_mode {
let (outline_stroke, outline_color) = get_outline_styles(render_params);
scene.stroke(&outline_stroke, affine, outline_color, None, bez_path);
} else {
scene.fill(peniko::Fill::NonZero, affine, peniko::Color::BLACK, None, bez_path);
}
});
});
if needs_layer {
scene.pop_layer();
}
});
}
}
fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, caller_element_id: Option<NodeId>) {
// Click targets are baked relative to item 0's transform, which `Graphic::collect_metadata` records as `local_transforms[element_id]`.
let item_zero_transform: DAffine2 = if !self.is_empty() {
self.attribute_cloned_or_default(ATTR_TRANSFORM, 0)
} else {
DAffine2::IDENTITY
};
let item_zero_inverse = if item_zero_transform.matrix2.determinant() != 0. {
item_zero_transform.inverse()
} else {
DAffine2::IDENTITY
};
let mut accumulated_click_targets: HashMap<NodeId, Vec<Arc<ClickTarget>>> = HashMap::new();
for index in 0..self.len() {
let layer_path: List<NodeId> = self.attribute_cloned_or_default(ATTR_EDITOR_LAYER_PATH, index);
let layer = layer_path.iter_element_values().next_back().copied();
let Some(element_id) = caller_element_id.or(layer) else { continue };
// When recovering element_id from the item's tag (caller passed None), also store the transform metadata.
if caller_element_id.is_none() {
metadata.upstream_footprints.entry(element_id).or_insert(footprint);
metadata.local_transforms.entry(element_id).or_insert(item_zero_transform);
}
let Some((size, item_transform)) = text_item_size_and_transform(self, index) else { continue };
let subpath = Subpath::new_rectangle(DVec2::ZERO, size);
let mut target = ClickTarget::new_with_subpath(subpath, 0.);
target.apply_transform(item_zero_inverse * item_transform);
accumulated_click_targets.entry(element_id).or_default().push(Arc::new(target));
}
// One rectangle per text item, reused for the selection outline (there's no letterform geometry to outline at this stage).
for (element_id, targets) in accumulated_click_targets {
metadata.outlines.insert(element_id, targets.clone());
metadata.click_targets.insert(element_id, targets);
}
}
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>) {
for index in 0..self.len() {
let Some((size, transform)) = text_item_size_and_transform(self, index) else { continue };
let subpath = Subpath::new_rectangle(DVec2::ZERO, size);
let mut target = ClickTarget::new_with_subpath(subpath, 0.);
target.apply_transform(transform);
click_targets.push(target);
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SvgSegment {
Slice(&'static str),

View File

@@ -32,6 +32,12 @@ impl Resource {
}
}
impl Default for Resource {
fn default() -> Self {
Self::empty()
}
}
impl From<&Resource> for Arc<dyn AsRef<[u8]> + Send + Sync> {
fn from(val: &Resource) -> Self {
val.inner.clone()

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)]