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

@@ -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()