mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-15 14:18:04 +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:
5
node-graph/nodes/text/src/fallback.rs
Normal file
5
node-graph/nodes/text/src/fallback.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
use graphene_resource::Resource;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
const FALLBACK_FONT_BYTES: &[u8] = include_bytes!("source-sans-pro-regular.ttf");
|
||||
pub static FALLBACK_FONT_RESOURCE: LazyLock<Resource> = LazyLock::new(|| Resource::new(FALLBACK_FONT_BYTES));
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod fallback;
|
||||
mod font;
|
||||
pub mod json;
|
||||
mod path_builder;
|
||||
@@ -16,8 +17,9 @@ use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
// Re-export for convenience
|
||||
pub use core_types as gcore;
|
||||
pub use fallback::FALLBACK_FONT_RESOURCE;
|
||||
pub use font::*;
|
||||
pub use text_context::TextContext;
|
||||
pub use text_context::{TextContext, for_each_styled_glyph_run};
|
||||
pub use to_path::*;
|
||||
pub use vector_types;
|
||||
|
||||
@@ -92,10 +94,10 @@ impl TextAlign {
|
||||
pub struct TypesettingConfig {
|
||||
pub font_size: f64,
|
||||
pub line_height_ratio: f64,
|
||||
pub character_spacing: f64,
|
||||
pub letter_spacing: f64,
|
||||
pub letter_tilt: f64,
|
||||
pub max_width: Option<f64>,
|
||||
pub max_height: Option<f64>,
|
||||
pub tilt: f64,
|
||||
pub align: TextAlign,
|
||||
}
|
||||
|
||||
@@ -104,10 +106,10 @@ impl Default for TypesettingConfig {
|
||||
Self {
|
||||
font_size: 24.,
|
||||
line_height_ratio: 1.2,
|
||||
character_spacing: 0.,
|
||||
letter_spacing: 0.,
|
||||
letter_tilt: 0.,
|
||||
max_width: None,
|
||||
max_height: None,
|
||||
tilt: 0.,
|
||||
align: TextAlign::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,19 +105,19 @@ impl PathBuilder {
|
||||
has_geometry
|
||||
}
|
||||
|
||||
pub fn render_glyph_run(&mut self, glyph_run: &GlyphRun<'_, ()>, tilt: f64, per_glyph_items: bool, x_offset: f32, space_extra: f32) {
|
||||
pub fn render_glyph_run(&mut self, glyph_run: &GlyphRun<'_, ()>, letter_tilt: f64, per_glyph_items: bool, x_offset: f32, space_extra: f32) {
|
||||
let mut run_x = glyph_run.offset() + x_offset;
|
||||
let run_y = glyph_run.baseline();
|
||||
|
||||
let run = glyph_run.run();
|
||||
|
||||
// User-requested tilt applied around baseline to avoid vertical displacement
|
||||
// User-requested letter tilt applied around baseline to avoid vertical displacement
|
||||
// Translation ensures rotation point is at the baseline, not origin
|
||||
let skew = if per_glyph_items {
|
||||
DAffine2::from_cols_array(&[1., 0., -tilt.to_radians().tan(), 1., 0., 0.])
|
||||
DAffine2::from_cols_array(&[1., 0., -letter_tilt.to_radians().tan(), 1., 0., 0.])
|
||||
} else {
|
||||
DAffine2::from_translation(DVec2::new(0., run_y as f64))
|
||||
* DAffine2::from_cols_array(&[1., 0., -tilt.to_radians().tan(), 1., 0., 0.])
|
||||
* DAffine2::from_cols_array(&[1., 0., -letter_tilt.to_radians().tan(), 1., 0., 0.])
|
||||
* DAffine2::from_translation(DVec2::new(0., -run_y as f64))
|
||||
};
|
||||
|
||||
|
||||
BIN
node-graph/nodes/text/src/source-sans-pro-regular.ttf
Normal file
BIN
node-graph/nodes/text/src/source-sans-pro-regular.ttf
Normal file
Binary file not shown.
@@ -1,19 +1,71 @@
|
||||
use super::TypesettingConfig;
|
||||
use super::path_builder::PathBuilder;
|
||||
use core::cell::RefCell;
|
||||
use core_types::list::List;
|
||||
use glam::DVec2;
|
||||
use graphene_resource::{Resource, ResourceHash};
|
||||
use parley::fontique::{Blob, FamilyId, FontInfo};
|
||||
use parley::{AlignmentOptions, FontContext, Layout, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty};
|
||||
use parley::{AlignmentOptions, FontContext, GlyphRun, Layout, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty};
|
||||
use std::collections::HashMap;
|
||||
use vector_types::Vector;
|
||||
|
||||
use super::path_builder::PathBuilder;
|
||||
|
||||
thread_local! {
|
||||
static THREAD_TEXT: RefCell<TextContext> = RefCell::new(TextContext::default());
|
||||
}
|
||||
|
||||
/// Iterates the glyph runs of a laid-out text in reading order, computing each line's last-line alignment correction
|
||||
/// (`x_offset` and per-space `space_extra`) and skipping runs clipped by `max_height`. Shared by the vector shaper and the
|
||||
/// SVG/Vello text renderers so the alignment logic lives in one place.
|
||||
pub fn for_each_styled_glyph_run(layout: &Layout<()>, text: &str, typesetting: TypesettingConfig, mut visit: impl FnMut(&GlyphRun<'_, ()>, f32, f32)) {
|
||||
let alignment_width = typesetting.max_width.map(|w| w as f32).unwrap_or_else(|| layout.full_width());
|
||||
let last_line_correction = typesetting.align.last_line_correction();
|
||||
|
||||
for line in layout.lines() {
|
||||
let range = line.text_range();
|
||||
// Parley always includes a hard-break `\n` as the last byte of the preceding line's range, so the line is at the end of
|
||||
// a paragraph if it's the very last line of the buffer or its text ends with `\n`.
|
||||
let is_last_para_line = range.end == text.len() || text.get(range.clone()).is_some_and(|s| s.ends_with('\n'));
|
||||
|
||||
let mut x_offset = 0.;
|
||||
let mut space_extra = 0.;
|
||||
|
||||
if is_last_para_line && let Some(correction) = last_line_correction {
|
||||
let metrics = line.metrics();
|
||||
let content_advance = metrics.advance - metrics.trailing_whitespace;
|
||||
let free_space = alignment_width - content_advance;
|
||||
|
||||
match correction {
|
||||
parley::Alignment::Center => x_offset = free_space * 0.5,
|
||||
parley::Alignment::Right => x_offset = free_space,
|
||||
parley::Alignment::Justify => {
|
||||
// Exclude trailing-whitespace clusters from the divisor so the redistribution stretches only the internal spaces.
|
||||
// Parley's `trailing_whitespace` is in advance units, not bytes, so we re-derive the byte boundary here to filter cluster ranges.
|
||||
let line_text = text.get(range.clone()).unwrap_or("");
|
||||
let trailing_len = line_text.len() - line_text.trim_end().len();
|
||||
let visible_end_index = range.end - trailing_len;
|
||||
|
||||
let space_count: usize = line
|
||||
.runs()
|
||||
.map(|run| run.clusters().filter(|c| c.is_space_or_nbsp() && c.text_range().start < visible_end_index).count())
|
||||
.sum();
|
||||
if space_count > 0 {
|
||||
space_extra = free_space / space_count as f32;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
for item in line.items() {
|
||||
if let PositionedLayoutItem::GlyphRun(glyph_run) = item
|
||||
&& typesetting.max_height.filter(|&max_height| glyph_run.baseline() > max_height as f32).is_none()
|
||||
{
|
||||
visit(&glyph_run, x_offset, space_extra);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Unified thread-local text processing context that combines font and layout management
|
||||
/// for efficient text rendering operations.
|
||||
#[derive(Default)]
|
||||
@@ -54,14 +106,14 @@ impl TextContext {
|
||||
}
|
||||
|
||||
/// Create a text layout from the given font resource and typesetting configuration.
|
||||
fn layout_text(&mut self, text: &str, font: &Resource, typesetting: TypesettingConfig) -> Option<Layout<()>> {
|
||||
pub fn layout_text(&mut self, text: &str, font: &Resource, typesetting: TypesettingConfig) -> Option<Layout<()>> {
|
||||
let (font_family, font_info) = self.get_font_info(font)?;
|
||||
|
||||
const DISPLAY_SCALE: f32 = 1.;
|
||||
let mut builder = self.layout_context.ranged_builder(&mut self.font_context, text, DISPLAY_SCALE, false);
|
||||
|
||||
builder.push_default(StyleProperty::FontSize(typesetting.font_size as f32));
|
||||
builder.push_default(StyleProperty::LetterSpacing(typesetting.character_spacing as f32));
|
||||
builder.push_default(StyleProperty::LetterSpacing(typesetting.letter_spacing as f32));
|
||||
builder.push_default(StyleProperty::FontFamily(parley::FontFamily::Single(parley::FontFamilyName::Named(std::borrow::Cow::Owned(
|
||||
font_family,
|
||||
)))));
|
||||
@@ -100,53 +152,11 @@ impl TextContext {
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let alignment_width = typesetting.max_width.map(|w| w as f32).unwrap_or_else(|| layout.full_width());
|
||||
let last_line_correction = typesetting.align.last_line_correction();
|
||||
|
||||
let mut path_builder = PathBuilder::new(per_glyph_items, layout.scale() as f64, text_frame_size, first_glyph_offset);
|
||||
|
||||
for line in layout.lines() {
|
||||
let range = line.text_range();
|
||||
// Parley always includes a hard-break `\n` as the last byte of the preceding line's range, so the line
|
||||
// is at the end of a paragraph if it's the very last line of the buffer or its text ends with `\n`.
|
||||
let is_last_para_line = range.end == text.len() || text.get(range.clone()).is_some_and(|s| s.ends_with('\n'));
|
||||
|
||||
let (x_offset, space_extra) = if let (true, Some(correction)) = (is_last_para_line, last_line_correction) {
|
||||
let metrics = line.metrics();
|
||||
let content_advance = metrics.advance - metrics.trailing_whitespace;
|
||||
let free_space = alignment_width - content_advance;
|
||||
|
||||
match correction {
|
||||
parley::Alignment::Center => (free_space * 0.5, 0.),
|
||||
parley::Alignment::Right => (free_space, 0.),
|
||||
parley::Alignment::Justify => {
|
||||
// Exclude trailing-whitespace clusters from the divisor so the redistribution stretches only the internal spaces.
|
||||
// Parley's `trailing_whitespace` is in advance units, not bytes, so we re-derive the byte boundary here to filter cluster ranges.
|
||||
let line_text = text.get(range.clone()).unwrap_or("");
|
||||
let trailing_len = line_text.len() - line_text.trim_end().len();
|
||||
let visible_end_index = range.end - trailing_len;
|
||||
|
||||
let space_count: usize = line
|
||||
.runs()
|
||||
.map(|run| run.clusters().filter(|c| c.is_space_or_nbsp() && c.text_range().start < visible_end_index).count())
|
||||
.sum();
|
||||
let extra = if space_count > 0 { free_space / space_count as f32 } else { 0. };
|
||||
(0., extra)
|
||||
}
|
||||
_ => (0., 0.),
|
||||
}
|
||||
} else {
|
||||
(0., 0.)
|
||||
};
|
||||
|
||||
for item in line.items() {
|
||||
if let PositionedLayoutItem::GlyphRun(glyph_run) = item
|
||||
&& typesetting.max_height.filter(|&max_height| glyph_run.baseline() > max_height as f32).is_none()
|
||||
{
|
||||
path_builder.render_glyph_run(&glyph_run, typesetting.tilt, per_glyph_items, x_offset, space_extra);
|
||||
}
|
||||
}
|
||||
}
|
||||
for_each_styled_glyph_run(&layout, text, typesetting, |glyph_run, x_offset, space_extra| {
|
||||
path_builder.render_glyph_run(glyph_run, typesetting.letter_tilt, per_glyph_items, x_offset, space_extra);
|
||||
});
|
||||
|
||||
path_builder.finalize()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
use super::TypesettingConfig;
|
||||
use super::text_context::TextContext;
|
||||
use core_types::blending::BlendMode;
|
||||
use core_types::list::List;
|
||||
use glam::DVec2;
|
||||
use core_types::uuid::NodeId;
|
||||
use core_types::{
|
||||
ATTR_BLEND_MODE, ATTR_EDITOR_LAYER_PATH, ATTR_FONT, ATTR_FONT_SIZE, ATTR_LETTER_SPACING, ATTR_LETTER_TILT, ATTR_LINE_HEIGHT, ATTR_MAX_HEIGHT, ATTR_MAX_WIDTH, ATTR_OPACITY, ATTR_OPACITY_FILL,
|
||||
ATTR_TEXT_ALIGN, ATTR_TRANSFORM,
|
||||
};
|
||||
use glam::{DAffine2, DVec2};
|
||||
use graphene_resource::Resource;
|
||||
use vector_types::Vector;
|
||||
|
||||
@@ -16,3 +22,63 @@ pub fn bounding_box(text: &str, font: &Resource, typesetting: TypesettingConfig,
|
||||
pub fn lines_clipping(text: &str, font: &Resource, typesetting: TypesettingConfig) -> bool {
|
||||
TextContext::with_thread_local(|ctx| ctx.lines_clipping(text, font, typesetting))
|
||||
}
|
||||
|
||||
/// Shapes each string item of a styled `List<String>` into vector geometry, reading its font and typesetting
|
||||
/// from the item's attributes (as set by the 'Text' node) and re-applying its transform and blending
|
||||
/// attributes onto the produced paths. With `separate_glyphs`, each glyph becomes its own item.
|
||||
pub fn shape_text_list(strings: &List<String>, separate_glyphs: bool) -> List<Vector> {
|
||||
let mut result = List::new();
|
||||
|
||||
for index in 0..strings.len() {
|
||||
let Some(text) = strings.element(index) else { continue };
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use fallback font when none is explicitly attached.
|
||||
let font: Resource = {
|
||||
let f: Resource = strings.attribute_cloned_or_default(ATTR_FONT, index);
|
||||
if f.is_empty() { super::FALLBACK_FONT_RESOURCE.clone() } else { f }
|
||||
};
|
||||
|
||||
let defaults = TypesettingConfig::default();
|
||||
let typesetting = TypesettingConfig {
|
||||
font_size: strings.attribute_cloned_or(ATTR_FONT_SIZE, index, defaults.font_size),
|
||||
line_height_ratio: strings.attribute_cloned_or(ATTR_LINE_HEIGHT, index, defaults.line_height_ratio),
|
||||
letter_spacing: strings.attribute_cloned_or(ATTR_LETTER_SPACING, index, defaults.letter_spacing),
|
||||
letter_tilt: strings.attribute_cloned_or(ATTR_LETTER_TILT, index, defaults.letter_tilt),
|
||||
max_width: strings.attribute_cloned_or::<Option<f64>>(ATTR_MAX_WIDTH, index, defaults.max_width),
|
||||
max_height: strings.attribute_cloned_or::<Option<f64>>(ATTR_MAX_HEIGHT, index, defaults.max_height),
|
||||
align: strings.attribute_cloned_or(ATTR_TEXT_ALIGN, index, defaults.align),
|
||||
};
|
||||
|
||||
let vectors = to_path(text, &font, typesetting, separate_glyphs);
|
||||
let transform = strings.attribute_cloned_or_default::<DAffine2>(ATTR_TRANSFORM, index);
|
||||
let layer_path = strings.attribute_cloned_or_default::<List<NodeId>>(ATTR_EDITOR_LAYER_PATH, index);
|
||||
let blend_mode = strings.attribute::<BlendMode>(ATTR_BLEND_MODE, index).copied();
|
||||
let opacity = strings.attribute::<f64>(ATTR_OPACITY, index).copied();
|
||||
let opacity_fill = strings.attribute::<f64>(ATTR_OPACITY_FILL, index).copied();
|
||||
|
||||
for mut item in vectors.into_iter() {
|
||||
if transform != DAffine2::IDENTITY {
|
||||
let local = item.attribute_cloned_or_default::<DAffine2>(ATTR_TRANSFORM);
|
||||
item.set_attribute(ATTR_TRANSFORM, transform * local);
|
||||
}
|
||||
if !layer_path.is_empty() {
|
||||
item.set_attribute(ATTR_EDITOR_LAYER_PATH, layer_path.clone());
|
||||
}
|
||||
if let Some(blend_mode) = blend_mode {
|
||||
item.set_attribute(ATTR_BLEND_MODE, blend_mode);
|
||||
}
|
||||
if let Some(opacity) = opacity {
|
||||
item.set_attribute(ATTR_OPACITY, opacity);
|
||||
}
|
||||
if let Some(opacity_fill) = opacity_fill {
|
||||
item.set_attribute(ATTR_OPACITY_FILL, opacity_fill);
|
||||
}
|
||||
result.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user