mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-15 22:28:10 +08:00
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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,3 +19,9 @@ impl RenderComplexity for Color {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderComplexity for String {
|
||||
fn render_complexity(&self) -> usize {
|
||||
self.chars().count()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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. };
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user