Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 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};
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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,18 @@ 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.font_family = "Source Sans Pro".to_string();
let fontdb = options.fontdb_mut();
fontdb.load_font_data(include_bytes!("../overlays/source-sans-pro-regular.ttf").to_vec());
fontdb.set_serif_family("Source Sans Pro");
fontdb.set_sans_serif_family("Source Sans Pro");

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'");
Comment thread
jsjgdh marked this conversation as resolved.
let svg = prepare_svg_textpath_direct_paths(&svg);

let tree = match usvg::Tree::from_str(&svg, &options) {
Ok(t) => t,
Err(e) => {
responses.add(DialogMessage::DisplayDialogError {
Expand Down Expand Up @@ -424,6 +434,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 mut 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 +446,7 @@ impl MessageHandler<GraphOperationMessage, GraphOperationMessageContext<'_>> for
parent,
insert_index,
&graphite_gradient_stops,
&mut textpath_attrs,
);

// After import, `layer_node` is set to the root group. Apply the placement transform to it
Expand Down Expand Up @@ -463,6 +477,7 @@ fn usvg_transform(c: usvg::Transform) -> DAffine2 {
}

const GRAPHITE_NAMESPACE: &str = "https://graphite.art";
const XLINK_NAMESPACE: &str = "http://www.w3.org/1999/xlink";

/// Pre-parses the raw SVG XML to extract gradient stops that have `graphite:midpoint` attributes.
/// Graphite exports gradients with midpoint curve data by writing interpolated approximation stops
Expand Down Expand Up @@ -532,6 +547,115 @@ fn parse_hex_stop_color(hex: &str, opacity: f32) -> Option<Color> {
Some(Color::from_rgbaf32_unchecked(r, g, b, opacity))
}

fn prepare_svg_textpath_direct_paths(svg: &str) -> String {
let doc = match usvg::roxmltree::Document::parse(svg) {
Ok(doc) => doc,
Err(_) => return svg.to_string(),
};

let mut edits = Vec::new();
let mut defs = String::new();
for (index, node) in doc.descendants().filter(|node| node.tag_name().name() == "textPath").enumerate() {
let Some(path_data) = node.attribute("path").filter(|path| !path.trim().is_empty()) else {
continue;
};

let path_id = format!("graphite-textpath-direct-{index}");
Comment thread
jsjgdh marked this conversation as resolved.
defs.push_str(&format!(r#"<path id="{path_id}" d="{}"/>"#, escape_xml_attr(path_data)));

if let Some(href_attr) = node
.attributes()
.find(|attr| attr.name() == "href" && (attr.namespace().is_none() || attr.namespace() == Some(XLINK_NAMESPACE)))
{
edits.push((href_attr.range_value(), format!("#{path_id}")));
} else if let Some(insert_at) = textpath_start_tag_name_end(svg, node) {
edits.push((insert_at..insert_at, format!(r##" href="#{path_id}""##)));
}
}

if defs.is_empty() {
return svg.to_string();
}

if let Some(insert_at) = svg_root_start_tag_end(svg, doc.root_element()) {
edits.push((insert_at..insert_at, format!("<defs>{defs}</defs>")));
}

apply_string_edits(svg, edits)
}

fn textpath_start_tag_name_end(svg: &str, node: usvg::roxmltree::Node) -> Option<usize> {
let start = node.range().start + 1;
svg.get(start..)?
.char_indices()
.find_map(|(offset, c)| matches!(c, ' ' | '\t' | '\n' | '\r' | '/' | '>').then_some(start + offset))
}

fn svg_root_start_tag_end(svg: &str, root: usvg::roxmltree::Node) -> Option<usize> {
let mut quote = None;
for (offset, c) in svg.get(root.range().start..)?.char_indices() {
match (quote, c) {
(Some(q), c) if c == q => quote = None,
(None, '"' | '\'') => quote = Some(c),
(None, '>') => return Some(root.range().start + offset + 1),
_ => {}
}
}
None
}

fn apply_string_edits(source: &str, mut edits: Vec<(std::ops::Range<usize>, String)>) -> String {
edits.sort_by_key(|(range, _)| range.start);
let mut result = source.to_string();
for (range, replacement) in edits.into_iter().rev() {
result.replace_range(range, &replacement);
}
result
}

fn escape_xml_attr(value: &str) -> String {
value.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;").replace('>', "&gt;")
}

#[derive(Debug, Default, Clone)]
struct TextPathAttrs {
pub method: Option<String>,
pub spacing: Option<String>,
pub side: Option<String>,
pub text_length: Option<f64>,
pub length_adjust: Option<String>,
}

fn pre_parse_textpath_attrs(svg: &str) -> std::collections::HashMap<String, Vec<TextPathAttrs>> {
let mut map = std::collections::HashMap::<String, Vec<TextPathAttrs>>::new();
let doc = match usvg::roxmltree::Document::parse(svg) {
Ok(doc) => doc,
Err(_) => return map,
};
for node in doc.descendants() {
if node.tag_name().name() == "textPath" {
let Some(path_id) = textpath_href_id(node) else {
continue;
};
map.entry(path_id).or_default().push(TextPathAttrs {
method: node.attribute("method").map(str::to_string),
spacing: node.attribute("spacing").map(str::to_string),
side: node.attribute("side").map(str::to_string),
text_length: node.attribute("textLength").and_then(|v| v.parse().ok()),
length_adjust: node.attribute("lengthAdjust").map(str::to_string),
});
}
}
map
}

fn textpath_href_id(node: usvg::roxmltree::Node) -> Option<String> {
node.attribute((XLINK_NAMESPACE, "href"))
.or_else(|| node.attribute("href"))
.and_then(|href| href.strip_prefix('#'))
.map(str::to_string)
}

/// 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 +669,7 @@ fn import_usvg_node(
parent: LayerNodeIdentifier,
insert_index: usize,
graphite_gradient_stops: &HashMap<String, GradientStops>,
textpath_attrs: &mut HashMap<String, Vec<TextPathAttrs>>,
) {
let layer = modify_inputs.create_layer(id);

Expand All @@ -565,7 +690,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 @@ -584,15 +709,15 @@ fn import_usvg_node(
modify_inputs.network_interface.unload_all_nodes_bounding_box(&[]);
}
usvg::Node::Path(path) => {
log::info!("Importing node as Path: id={}", node.id());
import_usvg_path(modify_inputs, node, path, layer, graphite_gradient_stops);
}
usvg::Node::Image(_image) => {
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));
log::info!("Importing node as Text: id={}", node.id());
import_usvg_text(modify_inputs, text, node.abs_transform(), layer, parent, insert_index, textpath_attrs);
}
}
}
Expand All @@ -610,6 +735,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: &mut HashMap<String, Vec<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 +745,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 +759,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 +797,114 @@ 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: &mut HashMap<String, Vec<TextPathAttrs>>,
) {
log::info!("Importing usvg text node with {} chunks", text.chunks().len());

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, &[]);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
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 tp_id = text_path.id();
let tp_attrs = take_textpath_attrs(textpath_attrs, tp_id);
let path_subpaths = convert_tiny_skia_path(text_path.path());
let start_offset = text_path.start_offset() as f64;

modify_inputs.insert_text_on_path(
chunk.text().to_string(),
font,
font_size,
letter_spacing,
path_subpaths,
start_offset,
text_anchor(chunk.anchor()),
text_path_side(&tp_attrs),
text_path_method(&tp_attrs),
text_path_spacing(&tp_attrs),
tp_attrs.text_length,
text_length_adjust(&tp_attrs),
usvg_transform(transform),
current_layer,
);
if let Some(fill) = chunk.spans().first().and_then(|span| span.fill()) {
apply_usvg_fill(fill, modify_inputs, DAffine2::IDENTITY, &HashMap::new());
}
} else {
// Regular text fallback
modify_inputs.insert_text(chunk.text().to_string(), font, TypesettingConfig { font_size, ..Default::default() }, current_layer);
if let Some(fill) = chunk.spans().first().and_then(|span| span.fill()) {
apply_usvg_fill(fill, modify_inputs, DAffine2::IDENTITY, &HashMap::new());
}
}
}
}

fn take_textpath_attrs(textpath_attrs: &mut HashMap<String, Vec<TextPathAttrs>>, path_id: &str) -> TextPathAttrs {
textpath_attrs.get_mut(path_id).and_then(|attrs| (!attrs.is_empty()).then(|| attrs.remove(0))).unwrap_or_default()
}

fn text_anchor(anchor: usvg::TextAnchor) -> graphene_std::text::TextAnchor {
match anchor {
usvg::TextAnchor::Start => graphene_std::text::TextAnchor::Start,
usvg::TextAnchor::Middle => graphene_std::text::TextAnchor::Middle,
usvg::TextAnchor::End => graphene_std::text::TextAnchor::End,
}
}

fn text_path_side(attrs: &TextPathAttrs) -> graphene_std::text::TextPathSide {
match attrs.side.as_deref() {
Some("right") => graphene_std::text::TextPathSide::Right,
_ => graphene_std::text::TextPathSide::Left,
}
}

fn text_path_method(attrs: &TextPathAttrs) -> graphene_std::text::TextPathMethod {
match attrs.method.as_deref() {
Some("stretch") => graphene_std::text::TextPathMethod::Stretch,
_ => graphene_std::text::TextPathMethod::Align,
}
}

fn text_path_spacing(attrs: &TextPathAttrs) -> graphene_std::text::TextPathSpacing {
match attrs.spacing.as_deref() {
Some("auto") => graphene_std::text::TextPathSpacing::Auto,
_ => graphene_std::text::TextPathSpacing::Exact,
}
}

fn text_length_adjust(attrs: &TextPathAttrs) -> graphene_std::text::LengthAdjust {
match attrs.length_adjust.as_deref() {
Some("spacingAndGlyphs") => graphene_std::text::LengthAdjust::SpacingAndGlyphs,
_ => graphene_std::text::LengthAdjust::Spacing,
}
}

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