Skip to content
Open
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.

1 change: 1 addition & 0 deletions editor/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ color = { workspace = true }
zip = { workspace = true }
reqwest = { workspace = true }
url = { workspace = true }
parley = { workspace = true }

# Optional local dependencies
wgpu-executor = { workspace = true, optional = true }
Expand Down
2 changes: 2 additions & 0 deletions editor/src/messages/frontend/frontend_message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ pub enum FrontendMessage {
align: String,
#[serde(rename = "alignLast")]
align_last: String,
#[serde(rename = "textDecoration")]
text_decoration: String,
},
DisplayEditableTextboxUpdateFontData {
#[serde(rename = "fontData")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ use graphene_std::renderer::convert_usvg_path::convert_usvg_path;
use graphene_std::text::{Font, TypesettingConfig};
use graphene_std::vector::style::{Gradient, GradientForm, GradientSettings, GradientSpace, GradientSpread, GradientStop, Stroke, StrokeAlign, StrokeCap, StrokeJoin};
use graphene_std::{Artboard, Color};
use std::sync::{Arc, OnceLock};

#[derive(ExtractField)]
pub struct GraphOperationMessageContext<'a> {
Expand Down Expand Up @@ -502,7 +503,8 @@ impl MessageHandler<GraphOperationMessage, GraphOperationMessageContext<'_>> for
insert_index,
center,
} => {
let tree = match usvg::Tree::from_str(&svg, &usvg::Options::default()) {
let options = usvg_options();
let tree = match usvg::Tree::from_str(&svg, &options) {
Ok(t) => t,
Err(e) => {
responses.add(DialogMessage::DisplayDialogError {
Expand Down Expand Up @@ -567,6 +569,42 @@ fn usvg_transform(c: usvg::Transform) -> DAffine2 {
DAffine2::from_cols_array(&[c.sx as f64, c.ky as f64, c.kx as f64, c.sy as f64, c.tx as f64, c.ty as f64])
}

/// The usvg options used when parsing an SVG.
///
/// usvg drops a `<text>` element outright when it can find no font to shape it with, so the database needs at least one
/// face. Graphite reshapes imported text with its own shaper, so the fallback font alone is enough, and holding the database
/// in a `OnceLock` keeps every paste from rescanning the system's fonts and the editor's cached ones.
fn usvg_options() -> usvg::Options<'static> {
static DATABASE: OnceLock<(String, Arc<usvg::fontdb::Database>)> = OnceLock::new();

let (fallback_family, database) = DATABASE.get_or_init(|| {
let mut fontdb = usvg::fontdb::Database::new();
fontdb.load_font_data(graphene_std::text::FALLBACK_FONT_RESOURCE.to_vec());

let fallback_family = fontdb
.faces()
.next()
.and_then(|face| face.families.first().map(|(name, _)| name.clone()))
.unwrap_or_else(|| graphene_std::consts::DEFAULT_FONT_FAMILY.to_string());

// A document's own `font-family` is usually a font usvg doesn't have, so aim the generic families at the fallback
// too. That resolves to text being imported rather than dropped.
fontdb.set_sans_serif_family(&fallback_family);
fontdb.set_serif_family(&fallback_family);
fontdb.set_monospace_family(&fallback_family);
fontdb.set_cursive_family(&fallback_family);
fontdb.set_fantasy_family(&fallback_family);

(fallback_family, Arc::new(fontdb))
});

usvg::Options {
font_family: fallback_family.clone(),
fontdb: database.clone(),
..Default::default()
}
}

const GRAPHITE_NAMESPACE: &str = "https://graphite.art";

/// Gradient information pre-parsed from the raw SVG XML, carrying what usvg's simplified tree drops.
Expand Down Expand Up @@ -814,8 +852,9 @@ fn import_usvg_node(modify_inputs: &mut ModifyInputsContext, node: &usvg::Node,
}
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.insert_text(text.chunks().iter().map(|chunk| chunk.text()).collect(), font, usvg_text_typesetting(text), layer);
modify_inputs.fill_color_set(Some(Color::BLACK));
apply_usvg_text_transform(modify_inputs, text);
}
}
}
Expand Down Expand Up @@ -866,8 +905,9 @@ fn import_usvg_node_inner(
}
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.insert_text(text.chunks().iter().map(|chunk| chunk.text()).collect(), font, usvg_text_typesetting(text), layer);
modify_inputs.fill_color_set(Some(Color::BLACK));
apply_usvg_text_transform(modify_inputs, text);
0
}
}
Expand All @@ -889,6 +929,66 @@ fn insert_brush_strokes_chain(network_interface: &mut NodeNetworkInterface, laye
network_interface.set_input(&InputConnector::node_at_index(layer.to_node(), 1), NodeInput::node(strokes_node_id, 0), &[]);
}

fn usvg_text_typesetting(text: &usvg::Text) -> TypesettingConfig {
let mut typesetting = TypesettingConfig::default();

for span in text.chunks().iter().flat_map(|chunk| chunk.spans()) {
let decoration = span.decoration();
typesetting.underline |= decoration.underline().is_some();
typesetting.overline |= decoration.overline().is_some();
typesetting.strikethrough |= decoration.line_through().is_some();
}

if let Some(first_span) = text.chunks().first().and_then(|chunk| chunk.spans().first()) {
typesetting.font_size = first_span.font_size().get() as f64;
}

typesetting
}

/// Lays imported SVG text out once so its SVG position, which marks the baseline, converts to the top-left origin a
/// Graphite text layer uses. Returns the first line's baseline and the laid-out width that `middle` and `end` anchors
/// measure against, or `None` when the text cannot be laid out, in which case the SVG position is used as-is.
fn usvg_text_layout_metrics(text: &str, typesetting: &TypesettingConfig) -> Option<(f64, f64)> {
use crate::messages::portfolio::fonts::FALLBACK_FONT_RESOURCE;
use graphene_std::text::TextContext;

TextContext::with_thread_local(|context| {
let layout = context.layout_text(text, &FALLBACK_FONT_RESOURCE, typesetting.clone())?;
let baseline = layout.lines().next()?.items().find_map(|item| match item {
parley::layout::PositionedLayoutItem::GlyphRun(run) => Some(f64::from(run.baseline())),
_ => None,
})?;
Some((baseline, f64::from(layout.full_width())))
})
}

fn apply_usvg_text_transform(modify_inputs: &mut ModifyInputsContext, text: &usvg::Text) {
let element_transform = usvg_transform(text.abs_transform());
let first_chunk = text.chunks().first();
let chunk_offset = first_chunk.map(|c| DVec2::new(f64::from(c.x().unwrap_or(0.)), f64::from(c.y().unwrap_or(0.)))).unwrap_or_default();

// SVG positions text by its baseline while a Graphite layer starts at its layout's top-left, so the first baseline
// comes off, and `middle` and `end` anchors shift back by the laid-out width.
let text_string: String = text.chunks().iter().map(|chunk| chunk.text()).collect();
let typesetting = usvg_text_typesetting(text);
let (baseline, width) = usvg_text_layout_metrics(&text_string, &typesetting).unwrap_or_default();
let anchor_shift = match first_chunk.map(|chunk| chunk.anchor()).unwrap_or_default() {
usvg::TextAnchor::Start => 0.,
usvg::TextAnchor::Middle => width / 2.,
usvg::TextAnchor::End => width,
};
let text_transform = element_transform * DAffine2::from_translation(chunk_offset - DVec2::new(anchor_shift, baseline));

if text_transform.abs_diff_eq(DAffine2::IDENTITY, 1e-6) {
return;
}
// `insert_text` always creates a Transform node; update it in-place.
if let Some(transform_node_id) = modify_inputs.existing_proto_node_id(graphene_std::transform_nodes::transform::IDENTIFIER, false) {
transform_utils::update_transform(modify_inputs.network_interface, &transform_node_id, text_transform);
}
}

/// 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, gradient_info: &SvgGradientInfo) {
let bezpath = convert_usvg_path(path);
Expand Down Expand Up @@ -1071,6 +1171,18 @@ fn apply_usvg_fill(fill: &usvg::Fill, modify_inputs: &mut ModifyInputsContext, g
mod tests {
use super::*;

#[test]
fn imported_text_baseline_comes_off_before_placement() {
use graphene_std::text::TypesettingConfig;

let typesetting = TypesettingConfig { font_size: 24., ..Default::default() };
let Some((baseline, width)) = usvg_text_layout_metrics("Hello", &typesetting) else {
panic!("the fallback font should lay text out");
};
assert!(baseline > 0. && baseline < 24., "a 24px line's baseline should sit inside it, got {baseline}");
assert!(width > 0., "the laid-out line should have a width to anchor against, got {width}");
}

#[tokio::test]
async fn stroke_order_set_reorders_the_fill_and_stroke_nodes() {
use crate::messages::tool::common_functionality::graph_modification_utils::get_stroke_paint_order;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,9 @@ impl<'a> ModifyInputsContext<'a> {
Some(NodeInput::value(TaggedValue::Bool(typesetting.max_height.is_some()), false)),
Some(NodeInput::value(TaggedValue::Number(typesetting.max_height.unwrap_or(100.)), false)),
Some(NodeInput::value(TaggedValue::TextAlign(typesetting.align), false)),
Some(NodeInput::value(TaggedValue::Bool(typesetting.underline), false)),
Some(NodeInput::value(TaggedValue::Bool(typesetting.overline), false)),
Some(NodeInput::value(TaggedValue::Bool(typesetting.strikethrough), false)),
]);
let text_to_vector = resolve_proto_node_type(graphene_std::text::text_to_vector::IDENTIFIER)
.expect("Text to Vector node does not exist")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1089,6 +1089,9 @@ impl OverlayContextInternal {
max_width: None,
max_height: None,
align: TextAlign::AlignLeft,
underline: false,
overline: false,
strikethrough: false,
};

// Lay out the text once, taking its dimensions and vector paths from the same thread-local context pass
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ pub fn text_width(text: &str, font_size: f64) -> f64 {
max_width: None,
max_height: None,
align: TextAlign::AlignLeft,
underline: false,
overline: false,
strikethrough: false,
};

TextContext::with_thread_local(|text_context| text_context.bounding_box(text, &FALLBACK_FONT_RESOURCE, typesetting, false).x)
Expand Down
122 changes: 117 additions & 5 deletions editor/src/messages/portfolio/document_migration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -998,9 +998,14 @@ pub fn document_migration_string_preprocessing(document_serialized_content: Stri

/// Rebuilds the old 13-input "Text" node template from the current `text` template plus the trailing `separate_glyphs` input it dropped,
/// so the staged input-count migrations can still upgrade old text nodes before the split.
///
/// The current template is truncated to its first 12 inputs first, so later additions to the node (such as the decoration
/// booleans) don't change what the legacy shape means.
fn legacy_text_node_template() -> Option<NodeTemplate> {
let mut template = resolve_document_node_type(&DefinitionIdentifier::ProtoNode(graphene_std::text::text::IDENTIFIER))?.default_node_template();
template.implementation = NodeTemplateImplementation::ProtoNode(ProtoNodeIdentifier::new("graphene_std::text::TextNode"));
template.inputs.truncate(12);
template.input_metadata.truncate(12);
template.inputs.push(NodeInput::value(TaggedValue::Bool(false), false));
template.input_metadata.push(Default::default());
Some(template)
Expand Down Expand Up @@ -1410,8 +1415,9 @@ pub fn document_migration_upgrades(document: &mut DocumentMessageHandler, reset_
}

// The old geometry-producing "Text" node was split into the current "Text" (`String[]`) -> converter pair, which reuses the same proto
// identifier. Runs after `migrate_node` normalizes old text nodes to the legacy 13-input layout, distinguished from the current 12-input
// node by the trailing `separate_glyphs` input (index 12): forward inputs 0..=11 onto the new node and splice the matching converter after it.
// identifier. Runs after `migrate_node` normalizes old text nodes to the legacy 13-input layout, distinguished from the current node by the
// trailing `separate_glyphs` input (index 12): forward inputs 0..=11 onto the new node and splice the matching converter after it.
// Only 13 inputs is legacy. The current node now also has a `Bool` at index 12 (underline), so a wider node is a current node, not a legacy one.
let old_text_nodes: Vec<(NodeId, Vec<NodeId>)> = document
.network_interface
.document_network()
Expand All @@ -1424,7 +1430,7 @@ pub fn document_migration_upgrades(document: &mut DocumentMessageHandler, reset_
})
.collect();
for (node_id, network_path) in &old_text_nodes {
// Convert the old node in place to the current `text` node (12 inputs), capturing its old inputs.
// Convert the old node in place to the current `text` node, capturing its old inputs.
let Some(text_definition) = resolve_document_node_type(&DefinitionIdentifier::ProtoNode(graphene_std::text::text::IDENTIFIER)) else {
continue;
};
Expand Down Expand Up @@ -2226,6 +2232,20 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId],
inputs_count = 13;
}

// Insert text decoration parameters: underline, overline, and strikethrough.
// A 12-input node gains the three decoration booleans at 12, 13 and 14 to reach the current 15-input shape. Nodes that already
// have the decorations, or a wider legacy node, are left alone.
if reference == DefinitionIdentifier::ProtoNode(graphene_std::text::text::IDENTIFIER) && inputs_count == 12 {
let mut template: NodeTemplate = resolve_document_node_type(&reference)?.default_node_template();
document.network_interface.replace_implementation(node_id, network_path, &mut template);
let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut template)?;

// Copy the original inputs into the new node by position. The appended decorations keep their template defaults.
for (index, input) in old_inputs.iter().enumerate() {
document.network_interface.set_input(&InputConnector::node_at_index(*node_id, index), input.clone(), network_path);
}
}

// Upgrade Sine, Cosine, and Tangent nodes to include a boolean input for whether the output should be in radians, which was previously the only option but is now not the default
if inputs_count == 1
&& (reference == DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::sine::IDENTIFIER)
Expand Down Expand Up @@ -3342,13 +3362,13 @@ mod tests {

let network = document.network_interface.document_network();
let text_node = network.nodes.get(&text_id).expect("the upgraded text node should keep its ID");
assert_eq!(text_node.inputs.len(), 12, "a {shape}-input text node should reach the current shape");
assert_eq!(text_node.inputs.len(), 15, "a {shape}-input text node should reach the current shape");

// The converter is a new node, so it is found by identity rather than by ID
let converter = network
.nodes
.iter()
.find(|(_, node)| matches!(&node.implementation, DocumentNodeImplementation::ProtoNode(identifier) if *identifier == graphene_std::text::text_to_vector::IDENTIFIER))
.find(|(_, node)| matches!(&node.implementation, DocumentNodeImplementation::ProtoNode(identifier) if *identifier == graphene_std::text::text_to_vector::IDENTIFIER || *identifier == graphene_std::text::text_to_vector_glyphs::IDENTIFIER))
.map(|(converter_id, _)| *converter_id)
.unwrap_or_else(|| panic!("a {shape}-input text node should gain a string converter"));
assert_eq!(
Expand Down Expand Up @@ -3381,6 +3401,98 @@ mod tests {
}
}

// A 12-input text node predates the decoration booleans, so it gains them at 12, 13 and 14. It must not then be mistaken for a
// legacy geometry node by the split pass, which would reorder its inputs and splice a second converter onto its output.
#[test]
fn a_twelve_input_text_node_gains_its_decorations_without_being_split() {
use crate::messages::portfolio::document::utility_types::network_interface::NodeTemplate;
use graphene_std::text::Font;

let (text_id, consumer_id) = (NodeId(1), NodeId(2));
let mut document = DocumentMessageHandler::default();
document.network_interface.insert_node(
text_id,
NodeTemplate {
implementation: NodeTemplateImplementation::ProtoNode(graphene_std::text::text::IDENTIFIER),
inputs: vec![
NodeInput::scope("editor-api"),
NodeInput::value(TaggedValue::String("Lorem".into()), false),
NodeInput::value(TaggedValue::Font(Font::new("Lato".to_string(), "Regular (400)".to_string())), false),
NodeInput::value(TaggedValue::Number(48.), false),
NodeInput::value(TaggedValue::Number(1.5), false),
NodeInput::value(TaggedValue::Number(2.), false),
NodeInput::value(TaggedValue::Number(0.), false),
NodeInput::value(TaggedValue::Number(0.), false),
NodeInput::value(TaggedValue::Number(10.), false),
NodeInput::value(TaggedValue::Bool(false), false),
NodeInput::value(TaggedValue::None, false),
NodeInput::value(TaggedValue::None, false),
],
..Default::default()
},
&[],
);
document.network_interface.insert_node(
consumer_id,
NodeTemplate {
inputs: vec![NodeInput::value(TaggedValue::None, false)],
..Default::default()
},
&[],
);
document.network_interface.set_input(&InputConnector::node_at_index(consumer_id, 0), NodeInput::node(text_id, 0), &[]);

migrate(&mut document);

let network = document.network_interface.document_network();
let text_node = network.nodes.get(&text_id).expect("the text node should keep its ID");
assert_eq!(text_node.inputs.len(), 15, "the node should gain its three decoration inputs");
assert_eq!(text_node.inputs.get(3), Some(&NodeInput::value(TaggedValue::Number(48.), false)), "its size should survive by position");

assert!(
!network.nodes.values().any(|node| matches!(&node.implementation, DocumentNodeImplementation::ProtoNode(identifier) if *identifier == graphene_std::text::text_to_vector::IDENTIFIER || *identifier == graphene_std::text::text_to_vector_glyphs::IDENTIFIER)),
"a current text node must not be split by the geometry migration"
);
assert_eq!(
network.nodes[&consumer_id].inputs.first(),
Some(&NodeInput::node(text_id, 0)),
"the consumer should stay wired to the text node"
);
}

// A current 15-input text node is already correct, so migrating again must leave it exactly as it was.
#[test]
fn a_current_text_node_is_left_alone() {
use crate::messages::portfolio::document::utility_types::network_interface::NodeTemplate;

let text_id = NodeId(1);
let mut document = DocumentMessageHandler::default();
let template = resolve_document_node_type(&DefinitionIdentifier::ProtoNode(graphene_std::text::text::IDENTIFIER))
.expect("the Text node should be registered")
.default_node_template();
assert_eq!(template.inputs.len(), 15, "the current Text node should have 15 inputs");
document.network_interface.insert_node(
text_id,
NodeTemplate {
inputs: template.inputs.clone(),
..template
},
&[],
);

// Mark it so any rewrite of its inputs would be visible.
document
.network_interface
.set_input(&InputConnector::node_at_index(text_id, 3), NodeInput::value(TaggedValue::Number(48.), false), &[]);

migrate(&mut document);

let network = document.network_interface.document_network();
let text_node = network.nodes.get(&text_id).expect("the text node should keep its ID");
assert_eq!(text_node.inputs.len(), 15, "a current text node should keep its shape");
assert_eq!(text_node.inputs.get(3), Some(&NodeInput::value(TaggedValue::Number(48.), false)), "its size should be untouched");
}

// An expression node saved before the output-type witness input had only its value and expression inputs
#[test]
fn expression_nodes_gain_the_output_type_witness() {
Expand Down
Loading
Loading