Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ kurbo = { version = "0.13", features = ["serde"] }
vello = "0.7"
vello_encoding = "0.7"
resvg = "0.47"
usvg = "0.47"
usvg = { version = "0.47", features = ["text", "system-fonts", "memmap-fonts"] }
parley = "0.6"
skrifa = "0.40"
polycool = "0.4"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,10 @@ use graph_craft::document::value::TaggedValue;
use graph_craft::document::{NodeId, NodeInput};
use graphene_std::Color;
use graphene_std::renderer::Quad;
use graphene_std::renderer::convert_usvg_path::convert_usvg_path;
use graphene_std::renderer::convert_usvg_path::{convert_tiny_skia_path, convert_usvg_path};
use graphene_std::table::Table;
use graphene_std::text::{Font, TypesettingConfig};
use graphene_std::vector::style::{Fill, Gradient, GradientStop, GradientStops, GradientType, PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin};

#[derive(ExtractField)]
pub struct GraphOperationMessageContext<'a> {
pub network_interface: &'a mut NodeNetworkInterface,
Expand Down Expand Up @@ -394,7 +393,14 @@ impl MessageHandler<GraphOperationMessage, GraphOperationMessageContext<'_>> for
insert_index,
center,
} => {
let tree = match usvg::Tree::from_str(&svg, &usvg::Options::default()) {
let mut options = usvg::Options::default();
options.fontdb_mut().load_font_data(include_bytes!("../overlays/source-sans-pro-regular.ttf").to_vec());
options.font_family = "Source Sans Pro".to_string();

let svg = svg.replace("font-family=\"sans-serif\"", "font-family=\"Source Sans Pro\"");
let svg = svg.replace("font-family='sans-serif'", "font-family='Source Sans Pro'");

let tree = match usvg::Tree::from_str(&svg, &options) {
Ok(t) => t,
Err(e) => {
responses.add(DialogMessage::DisplayDialogError {
Expand Down Expand Up @@ -424,6 +430,9 @@ impl MessageHandler<GraphOperationMessage, GraphOperationMessageContext<'_>> for

let graphite_gradient_stops = extract_graphite_gradient_stops(&svg);

// Pre-parse the raw SVG XML for <textPath> attributes that usvg doesn't expose
let textpath_attrs = pre_parse_textpath_attrs(&svg);

// Pass identity so each leaf layer receives only its SVG-native transform from `abs_transform`.
// The placement offset is then applied once to the root group layer below.
import_usvg_node(
Expand All @@ -433,6 +442,7 @@ impl MessageHandler<GraphOperationMessage, GraphOperationMessageContext<'_>> for
parent,
insert_index,
&graphite_gradient_stops,
&textpath_attrs,
);

// After import, `layer_node` is set to the root group. Apply the placement transform to it
Expand Down Expand Up @@ -532,6 +542,76 @@ fn parse_hex_stop_color(hex: &str, opacity: f32) -> Option<Color> {
Some(Color::from_rgbaf32_unchecked(r, g, b, opacity))
}

/// Attributes from `<textPath>` elements that `usvg` does not expose.
/// Keyed by the parent `<text>` element's `id` attribute (may be empty string if no id).
/// For simple SVGs with one `<text>` element, the key will be "".
#[derive(Debug, Default, Clone)]
struct TextPathAttrs {
/// SVG `method` attribute: "align" (default) | "stretch"
pub method: Option<String>,
/// SVG `spacing` attribute: "exact" (default) | "auto"
pub spacing: Option<String>,
/// SVG `side` attribute: "left" (default) | "right"
pub side: Option<String>,
/// SVG `textLength` attribute in user units
pub text_length: Option<f64>,
/// SVG `lengthAdjust` attribute: "spacing" | "spacingAndGlyphs"
pub length_adjust: Option<String>,
}

/// Scan the raw SVG string for `<textPath>` elements and extract the attributes that
/// `usvg` does not expose. Returns a map from parent `<text>` id → attrs.
/// Uses simple byte-level scanning instead of a full XML parser to avoid new dependencies.
fn pre_parse_textpath_attrs(svg: &str) -> std::collections::HashMap<String, TextPathAttrs> {
/// Extract the value of `attr="..."` or `attr='...'` from a raw attribute string.
fn get_attr<'a>(attrs_str: &'a str, name: &str) -> Option<&'a str> {
// Try double-quote then single-quote form
for q in ['"', '\'' ] {
let pattern = format!("{name}={q}");
if let Some(start) = attrs_str.find(&pattern) {
let rest = &attrs_str[start + pattern.len()..];
if let Some(end) = rest.find(q) {
return Some(&rest[..end]);
}
}
}
None
}

let mut map = std::collections::HashMap::new();
let mut search = svg;

while let Some(text_start) = search.find("<text") {
search = &search[text_start..];

// Find the end of the opening <text ...> tag
let tag_end = search.find('>').unwrap_or(search.len());
let text_tag = &search[..=tag_end];

// Extract the id of this <text> element
let text_id = get_attr(text_tag, "id").unwrap_or("").to_string();

// Advance past this tag and look for a nested <textPath ...>
search = &search[tag_end.saturating_add(1).min(search.len())..];

if let Some(tp_start) = search.find("<textPath") {
let tp_end = search[tp_start..].find('>').map(|e| tp_start + e).unwrap_or(search.len());
let tp_tag = &search[tp_start..=tp_end];

let attrs = TextPathAttrs {
method: get_attr(tp_tag, "method").map(str::to_string),
spacing: get_attr(tp_tag, "spacing").map(str::to_string),
side: get_attr(tp_tag, "side").map(str::to_string),
text_length: get_attr(tp_tag, "textLength").and_then(|v| v.parse().ok()),
length_adjust: get_attr(tp_tag, "lengthAdjust").map(str::to_string),
};
map.insert(text_id, attrs);
}
}

map
}

/// Import a usvg node as the root of an SVG import operation.
///
/// The root layer uses the full `move_layer_to_stack` (with push/collision logic) to correctly
Expand All @@ -545,6 +625,7 @@ fn import_usvg_node(
parent: LayerNodeIdentifier,
insert_index: usize,
graphite_gradient_stops: &HashMap<String, GradientStops>,
textpath_attrs: &HashMap<String, TextPathAttrs>,
) {
let layer = modify_inputs.create_layer(id);

Expand All @@ -565,7 +646,7 @@ fn import_usvg_node(
modify_inputs.import = true;

for child in group.children() {
let extent = import_usvg_node_inner(modify_inputs, child, NodeId::new(), layer, 0, graphite_gradient_stops, &mut group_extents_map);
let extent = import_usvg_node_inner(modify_inputs, child, NodeId::new(), layer, 0, graphite_gradient_stops, &mut group_extents_map, textpath_attrs);
child_extents_svg_order.push(extent);
}

Expand All @@ -590,9 +671,7 @@ fn import_usvg_node(
warn!("Skip image");
}
usvg::Node::Text(text) => {
let font = Font::new(graphene_std::consts::DEFAULT_FONT_FAMILY.to_string(), graphene_std::consts::DEFAULT_FONT_STYLE.to_string());
modify_inputs.insert_text(text.chunks().iter().map(|chunk| chunk.text()).collect(), font, TypesettingConfig::default(), layer);
modify_inputs.fill_set(Fill::Solid(Color::BLACK));
import_usvg_text(modify_inputs, text, node.abs_transform(), layer, parent, insert_index, textpath_attrs);
}
}
}
Expand All @@ -610,6 +689,7 @@ fn import_usvg_node_inner(
insert_index: usize,
graphite_gradient_stops: &HashMap<String, GradientStops>,
group_extents_map: &mut HashMap<LayerNodeIdentifier, Vec<u32>>,
textpath_attrs: &HashMap<String, TextPathAttrs>,
) -> u32 {
let layer = modify_inputs.create_layer(id);
modify_inputs.network_interface.move_layer_to_stack_for_import(layer, parent, insert_index, &[]);
Expand All @@ -619,7 +699,7 @@ fn import_usvg_node_inner(
usvg::Node::Group(group) => {
let mut child_extents: Vec<u32> = Vec::new();
for child in group.children() {
let extent = import_usvg_node_inner(modify_inputs, child, NodeId::new(), layer, 0, graphite_gradient_stops, group_extents_map);
let extent = import_usvg_node_inner(modify_inputs, child, NodeId::new(), layer, 0, graphite_gradient_stops, group_extents_map, textpath_attrs);
child_extents.push(extent);
}
modify_inputs.layer_node = Some(layer);
Expand All @@ -633,24 +713,21 @@ fn import_usvg_node_inner(
group_extents_map.insert(layer, child_extents);
total_extent
}
usvg::Node::Path(path) => {
import_usvg_path(modify_inputs, node, path, layer, graphite_gradient_stops);
0
}
usvg::Node::Image(_image) => {
warn!("Skip image");
0
}
usvg::Node::Text(text) => {
let font = Font::new(graphene_std::consts::DEFAULT_FONT_FAMILY.to_string(), graphene_std::consts::DEFAULT_FONT_STYLE.to_string());
modify_inputs.insert_text(text.chunks().iter().map(|chunk| chunk.text()).collect(), font, TypesettingConfig::default(), layer);
modify_inputs.fill_set(Fill::Solid(Color::BLACK));
import_usvg_text(modify_inputs, text, node.abs_transform(), layer, parent, insert_index, textpath_attrs);
0
}
usvg::Node::Path(path) => {
import_usvg_path(modify_inputs, node, path, layer, graphite_gradient_stops);
0
}
}
}

/// Helper to apply path data (vector geometry, fill, stroke, transform) to a layer.
fn import_usvg_path(modify_inputs: &mut ModifyInputsContext, node: &usvg::Node, path: &usvg::Path, layer: LayerNodeIdentifier, graphite_gradient_stops: &HashMap<String, GradientStops>) {
let subpaths = convert_usvg_path(path);
let bounds = subpaths.iter().filter_map(|subpath| subpath.bounding_box()).reduce(Quad::combine_bounds).unwrap_or_default();
Expand All @@ -674,6 +751,80 @@ fn import_usvg_path(modify_inputs: &mut ModifyInputsContext, node: &usvg::Node,
}
}

fn import_usvg_text(modify_inputs: &mut ModifyInputsContext, text: &usvg::Text, transform: usvg::Transform, layer: LayerNodeIdentifier, parent: LayerNodeIdentifier, insert_index: usize, textpath_attrs: &HashMap<String, TextPathAttrs>) {
use graphene_std::text::{LengthAdjust, TextAnchor, TextPathMethod, TextPathSide, TextPathSpacing};

// Look up pre-parsed textPath attrs using the <text> element id (or "" for unidentified elements)
let text_id = text.id();
let tp_attrs = textpath_attrs.get(text_id).cloned().unwrap_or_default();

for (i, chunk) in text.chunks().iter().enumerate() {
let current_layer = if i == 0 {
layer
} else {
let new_id = NodeId::new();
let new_layer = modify_inputs.create_layer(new_id);
modify_inputs.network_interface.move_layer_to_stack_for_import(new_layer, parent, insert_index, &[]);
new_layer
};
modify_inputs.layer_node = Some(current_layer);

let font_family = chunk
.spans()
.first()
.and_then(|span| span.font().families().first().map(|f| f.to_string()))
.unwrap_or_else(|| graphene_std::consts::DEFAULT_FONT_FAMILY.to_string());
let font_style = graphene_std::consts::DEFAULT_FONT_STYLE.to_string();
let font = Font::new(font_family, font_style);

let font_size = chunk.spans().first().map(|s| s.font_size().get()).unwrap_or(24.0) as f64;
let letter_spacing = chunk.spans().first().map(|s| s.letter_spacing()).unwrap_or(0.0) as f64;

if let usvg::TextFlow::Path(text_path) = chunk.text_flow() {
let path_subpaths = convert_tiny_skia_path(text_path.path());
let start_offset = text_path.start_offset() as f64;
let anchor = match chunk.anchor() {
usvg::TextAnchor::Start => TextAnchor::Start,
usvg::TextAnchor::Middle => TextAnchor::Middle,
usvg::TextAnchor::End => TextAnchor::End,
};

let affine = DAffine2::from_cols_array(&[
transform.sx as f64,
transform.ky as f64,
transform.kx as f64,
transform.sy as f64,
transform.tx as f64,
transform.ty as f64,
]);
// Translate pre-parsed attrs to typed enums
let method = match tp_attrs.method.as_deref() {
Some("stretch") => TextPathMethod::Stretch,
_ => TextPathMethod::Align,
};
let spacing = match tp_attrs.spacing.as_deref() {
Some("auto") => TextPathSpacing::Auto,
_ => TextPathSpacing::Exact,
};
let side = match tp_attrs.side.as_deref() {
Some("right") => TextPathSide::Right,
_ => TextPathSide::Left,
};
let length_adjust = match tp_attrs.length_adjust.as_deref() {
Some("spacingAndGlyphs") => LengthAdjust::SpacingAndGlyphs,
_ => LengthAdjust::Spacing,
};

modify_inputs.insert_text_on_path(chunk.text().to_string(), font, font_size, letter_spacing, path_subpaths, start_offset, anchor, side, method, spacing, tp_attrs.text_length, length_adjust, affine, current_layer);
modify_inputs.fill_set(Fill::Solid(Color::BLACK));
} else {
// Regular text fallback
modify_inputs.insert_text(chunk.text().to_string(), font, TypesettingConfig { font_size, ..Default::default() }, current_layer);
modify_inputs.fill_set(Fill::Solid(Color::BLACK));
}
}
}

/// Set correct positions for all imported layers in a single top-down O(n) pass.
///
/// For each group's child stack:
Expand Down
Loading