Files
Graphite/node-graph/nodes/text/src/text_context.rs
Jatin Bharti 5f100946f2 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>
2026-06-19 19:39:52 +00:00

190 lines
7.8 KiB
Rust

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, GlyphRun, Layout, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty};
use std::collections::HashMap;
use vector_types::Vector;
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)]
pub struct TextContext {
font_context: FontContext,
layout_context: LayoutContext<()>,
font_info_cache: HashMap<ResourceHash, (FamilyId, FontInfo)>,
}
impl TextContext {
/// Access the thread-local TextContext instance for text processing operations
pub fn with_thread_local<F, R>(f: F) -> R
where
F: FnOnce(&mut TextContext) -> R,
{
THREAD_TEXT.with_borrow_mut(f)
}
/// Get or cache font information for the given font resource.
fn get_font_info(&mut self, font: &Resource) -> Option<(String, FontInfo)> {
let hash = font.hash();
if let Some((family_id, font_info)) = self.font_info_cache.get(&hash)
&& let Some(family_name) = self.font_context.collection.family_name(*family_id)
{
return Some((family_name.to_string(), font_info.clone()));
}
let families = self.font_context.collection.register_fonts(Blob::new(font.into()), None);
families.first().and_then(|(family_id, fonts_info)| {
fonts_info.first().and_then(|font_info| {
self.font_context.collection.family_name(*family_id).map(|family_name| {
self.font_info_cache.insert(hash, (*family_id, font_info.clone()));
(family_name.to_string(), font_info.clone())
})
})
})
}
/// Create a text layout from the given font resource and typesetting configuration.
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.letter_spacing as f32));
builder.push_default(StyleProperty::FontFamily(parley::FontFamily::Single(parley::FontFamilyName::Named(std::borrow::Cow::Owned(
font_family,
)))));
builder.push_default(StyleProperty::FontWeight(font_info.weight()));
builder.push_default(StyleProperty::FontStyle(font_info.style()));
builder.push_default(StyleProperty::FontWidth(font_info.width()));
builder.push_default(LineHeight::FontSizeRelative(typesetting.line_height_ratio as f32));
let mut layout: Layout<()> = builder.build(text);
layout.break_all_lines(typesetting.max_width.map(|mw| mw as f32));
layout.align(typesetting.align.into(), AlignmentOptions::default());
Some(layout)
}
/// Convert text to vector paths using the specified font and typesetting configuration
pub fn to_path(&mut self, text: &str, font: &Resource, typesetting: TypesettingConfig, per_glyph_items: bool) -> List<Vector> {
let Some(layout) = self.layout_text(text, font, typesetting) else {
return List::new_from_element(Vector::default());
};
let text_frame_size = DVec2::new(
typesetting.max_width.unwrap_or_else(|| layout.full_width() as f64),
typesetting.max_height.unwrap_or_else(|| layout.height() as f64),
);
// First glyph offset (pre-height-filter) so the empty placeholder item in `per_glyph_items`
// mode keeps the same item 0's transform, preventing `local_transforms` from jumping mid-drag
let first_glyph_offset = layout
.lines()
.flat_map(|line| line.items())
.find_map(|item| match item {
PositionedLayoutItem::GlyphRun(run) => run.glyphs().next().map(|glyph| DVec2::new((run.offset() + glyph.x) as f64, (run.baseline() - glyph.y) as f64)),
_ => None,
})
.unwrap_or_default();
let mut path_builder = PathBuilder::new(per_glyph_items, layout.scale() as f64, text_frame_size, first_glyph_offset);
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()
}
/// Calculate the bounding box of text using the specified font and typesetting configuration
pub fn bounding_box(&mut self, text: &str, font: &Resource, typesetting: TypesettingConfig, for_clipping_test: bool) -> DVec2 {
let Some(layout) = self.layout_text(text, font, typesetting) else {
return DVec2::ZERO;
};
let layout_width = layout.full_width() as f64;
let layout_height = layout.height() as f64;
if for_clipping_test {
return DVec2::new(layout_width, layout_height);
}
let width = typesetting.max_width.unwrap_or(layout_width);
let height = typesetting.max_height.unwrap_or(layout_height);
DVec2::new(width, height)
}
/// Check if text lines are being clipped due to height constraints
pub fn lines_clipping(&mut self, text: &str, font: &Resource, typesetting: TypesettingConfig) -> bool {
let Some(max_height) = typesetting.max_height else { return false };
let bounds = self.bounding_box(text, font, typesetting, true);
max_height < bounds.y
}
}