Restructure gcore/text module and fix memory leak (#3221)

* Restructure gcore/text module and fix memory leak

* Remove unused import

* Fix default font fallback causing wrong caching and rename to TextContext

* Upgrade demo art
This commit is contained in:
Dennis Kobert 2025-09-25 15:29:07 +02:00 committed by GitHub
parent d595089bb0
commit 4e47b5db93
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 381 additions and 342 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -15,6 +15,8 @@ pub struct OverlaysMessageHandler {
canvas: Option<web_sys::HtmlCanvasElement>, canvas: Option<web_sys::HtmlCanvasElement>,
#[cfg(target_family = "wasm")] #[cfg(target_family = "wasm")]
context: Option<web_sys::CanvasRenderingContext2d>, context: Option<web_sys::CanvasRenderingContext2d>,
#[cfg(all(not(target_family = "wasm"), not(test)))]
context: Option<super::utility_types::OverlayContext>,
} }
#[message_handler_data] #[message_handler_data]
@ -80,7 +82,11 @@ impl MessageHandler<OverlaysMessage, OverlaysMessageContext<'_>> for OverlaysMes
let size = ipp.viewport_bounds.size(); let size = ipp.viewport_bounds.size();
let overlay_context = OverlayContext::new(size, device_pixel_ratio, visibility_settings); if self.context.is_none() {
self.context = Some(OverlayContext::new(size, device_pixel_ratio, visibility_settings));
}
let overlay_context = self.context.as_mut().unwrap();
if visibility_settings.all() { if visibility_settings.all() {
responses.add(DocumentMessage::GridOverlays { context: overlay_context.clone() }); responses.add(DocumentMessage::GridOverlays { context: overlay_context.clone() });
@ -89,7 +95,7 @@ impl MessageHandler<OverlaysMessage, OverlaysMessageContext<'_>> for OverlaysMes
responses.add(provider(overlay_context.clone())); responses.add(provider(overlay_context.clone()));
} }
} }
responses.add(FrontendMessage::RenderOverlays { context: overlay_context }); responses.add(FrontendMessage::RenderOverlays { context: overlay_context.clone() });
} }
#[cfg(all(not(target_family = "wasm"), test))] #[cfg(all(not(target_family = "wasm"), test))]
OverlaysMessage::Draw => { OverlaysMessage::Draw => {

View File

@ -12,7 +12,8 @@ use graphene_std::Color;
use graphene_std::math::quad::Quad; use graphene_std::math::quad::Quad;
use graphene_std::subpath::{self, Subpath}; use graphene_std::subpath::{self, Subpath};
use graphene_std::table::Table; use graphene_std::table::Table;
use graphene_std::text::{TextAlign, TypesettingConfig, load_font, to_path}; use graphene_std::text::TextContext;
use graphene_std::text::{Font, FontCache, TextAlign, TypesettingConfig};
use graphene_std::vector::click_target::ClickTargetType; use graphene_std::vector::click_target::ClickTargetType;
use graphene_std::vector::misc::point_to_dvec2; use graphene_std::vector::misc::point_to_dvec2;
use graphene_std::vector::{PointId, SegmentId, Vector}; use graphene_std::vector::{PointId, SegmentId, Vector};
@ -215,7 +216,7 @@ impl OverlayContext {
pub fn take_scene(self) -> Scene { pub fn take_scene(self) -> Scene {
let mut internal = self.internal.lock().expect("Failed to lock internal overlay context"); let mut internal = self.internal.lock().expect("Failed to lock internal overlay context");
std::mem::take(&mut *internal).scene std::mem::take(&mut internal.scene)
} }
fn internal(&'_ self) -> MutexGuard<'_, OverlayContextInternal> { fn internal(&'_ self) -> MutexGuard<'_, OverlayContextInternal> {
@ -411,26 +412,31 @@ pub(super) struct OverlayContextInternal {
size: DVec2, size: DVec2,
device_pixel_ratio: f64, device_pixel_ratio: f64,
visibility_settings: OverlaysVisibilitySettings, visibility_settings: OverlaysVisibilitySettings,
font_cache: FontCache,
thread_text: TextContext,
} }
impl Default for OverlayContextInternal { impl Default for OverlayContextInternal {
fn default() -> Self { fn default() -> Self {
Self { Self::new(DVec2::new(100., 100.), 1., OverlaysVisibilitySettings::default())
scene: Scene::new(),
size: DVec2::ZERO,
device_pixel_ratio: 1.0,
visibility_settings: OverlaysVisibilitySettings::default(),
}
} }
} }
impl OverlayContextInternal { impl OverlayContextInternal {
pub(super) fn new(size: DVec2, device_pixel_ratio: f64, visibility_settings: OverlaysVisibilitySettings) -> Self { pub(super) fn new(size: DVec2, device_pixel_ratio: f64, visibility_settings: OverlaysVisibilitySettings) -> Self {
let mut font_cache = FontCache::default();
// Initialize with the hardcoded font used by overlay text
const FONT_DATA: &[u8] = include_bytes!("source-sans-pro-regular.ttf");
let font = Font::new("Source Sans Pro".to_string(), "Regular".to_string());
font_cache.insert(font, String::new(), FONT_DATA.to_vec());
Self { Self {
scene: Scene::new(), scene: Scene::new(),
size, size,
device_pixel_ratio, device_pixel_ratio,
visibility_settings, visibility_settings,
font_cache,
thread_text: TextContext::default(),
} }
} }
@ -1007,7 +1013,7 @@ impl OverlayContextInternal {
self.scene.fill(peniko::Fill::NonZero, self.get_transform(), &brush, None, &path); self.scene.fill(peniko::Fill::NonZero, self.get_transform(), &brush, None, &path);
} }
fn get_width(&self, text: &str) -> f64 { fn get_width(&mut self, text: &str) -> f64 {
// Use the actual text-to-path system to get precise text width // Use the actual text-to-path system to get precise text width
const FONT_SIZE: f64 = 12.0; const FONT_SIZE: f64 = 12.0;
@ -1024,13 +1030,9 @@ impl OverlayContextInternal {
// Load Source Sans Pro font data // Load Source Sans Pro font data
// TODO: Grab this from the node_modules folder (either with `include_bytes!` or ideally at runtime) instead of checking the font file into the repo. // TODO: Grab this from the node_modules folder (either with `include_bytes!` or ideally at runtime) instead of checking the font file into the repo.
// TODO: And maybe use the WOFF2 version (if it's supported) for its smaller, compressed file size. // TODO: And maybe use the WOFF2 version (if it's supported) for its smaller, compressed file size.
const FONT_DATA: &[u8] = include_bytes!("source-sans-pro-regular.ttf"); let font = Font::new("Source Sans Pro".to_string(), "Regular".to_string());
let font_blob = Some(load_font(FONT_DATA)); let bounds = self.thread_text.bounding_box(text, &font, &self.font_cache, typesetting, false);
bounds.x
// Convert text to paths and calculate actual bounds
let text_table = to_path(text, font_blob, typesetting, false);
let text_bounds = self.calculate_text_bounds(&text_table);
text_bounds.width()
} }
fn text(&mut self, text: &str, font_color: &str, background_color: Option<&str>, transform: DAffine2, padding: f64, pivot: [Pivot; 2]) { fn text(&mut self, text: &str, font_color: &str, background_color: Option<&str>, transform: DAffine2, padding: f64, pivot: [Pivot; 2]) {
@ -1051,15 +1053,17 @@ impl OverlayContextInternal {
// Load Source Sans Pro font data // Load Source Sans Pro font data
// TODO: Grab this from the node_modules folder (either with `include_bytes!` or ideally at runtime) instead of checking the font file into the repo. // TODO: Grab this from the node_modules folder (either with `include_bytes!` or ideally at runtime) instead of checking the font file into the repo.
// TODO: And maybe use the WOFF2 version (if it's supported) for its smaller, compressed file size. // TODO: And maybe use the WOFF2 version (if it's supported) for its smaller, compressed file size.
const FONT_DATA: &[u8] = include_bytes!("source-sans-pro-regular.ttf"); let font = Font::new("Source Sans Pro".to_string(), "Regular".to_string());
let font_blob = Some(load_font(FONT_DATA));
// Convert text to vector paths using the existing text system // Get text dimensions directly from layout
let text_table = to_path(text, font_blob, typesetting, false); let text_size = self.thread_text.bounding_box(text, &font, &self.font_cache, typesetting, false);
// Calculate text bounds from the generated paths let text_width = text_size.x;
let text_bounds = self.calculate_text_bounds(&text_table); let text_height = text_size.y;
let text_width = text_bounds.width(); // Create a rect from the size (assuming text starts at origin)
let text_height = text_bounds.height(); let text_bounds = kurbo::Rect::new(0.0, 0.0, text_width, text_height);
// Convert text to vector paths for rendering
let text_table = self.thread_text.to_path(text, &font, &self.font_cache, typesetting, false);
// Calculate position based on pivot // Calculate position based on pivot
let mut position = DVec2::ZERO; let mut position = DVec2::ZERO;
@ -1094,56 +1098,6 @@ impl OverlayContextInternal {
self.render_text_paths(&text_table, font_color, vello_transform); self.render_text_paths(&text_table, font_color, vello_transform);
} }
// Calculate bounds of text from vector table
fn calculate_text_bounds(&self, text_table: &Table<Vector>) -> kurbo::Rect {
let mut min_x = f64::INFINITY;
let mut min_y = f64::INFINITY;
let mut max_x = f64::NEG_INFINITY;
let mut max_y = f64::NEG_INFINITY;
for row in text_table.iter() {
// Use the existing segment_bezier_iter to get all bezier curves
for (_, bezier, _, _) in row.element.segment_bezier_iter() {
let transformed_bezier = bezier.apply_transformation(|point| row.transform.transform_point2(point));
// Add start and end points to bounds
let points = [transformed_bezier.start, transformed_bezier.end];
for point in points {
min_x = min_x.min(point.x);
min_y = min_y.min(point.y);
max_x = max_x.max(point.x);
max_y = max_y.max(point.y);
}
// Add handle points if they exist
match transformed_bezier.handles {
subpath::BezierHandles::Quadratic { handle } => {
min_x = min_x.min(handle.x);
min_y = min_y.min(handle.y);
max_x = max_x.max(handle.x);
max_y = max_y.max(handle.y);
}
subpath::BezierHandles::Cubic { handle_start, handle_end } => {
for handle in [handle_start, handle_end] {
min_x = min_x.min(handle.x);
min_y = min_y.min(handle.y);
max_x = max_x.max(handle.x);
max_y = max_y.max(handle.y);
}
}
_ => {}
}
}
}
if min_x.is_finite() && min_y.is_finite() && max_x.is_finite() && max_y.is_finite() {
kurbo::Rect::new(min_x, min_y, max_x, max_y)
} else {
// Fallback for empty text
kurbo::Rect::new(0.0, 0.0, 0.0, 12.0)
}
}
// Render text paths to the vello scene using existing infrastructure // Render text paths to the vello scene using existing infrastructure
fn render_text_paths(&mut self, text_table: &Table<Vector>, font_color: &str, base_transform: kurbo::Affine) { fn render_text_paths(&mut self, text_table: &Table<Vector>, font_color: &str, base_transform: kurbo::Affine) {
let color = Self::parse_color(font_color); let color = Self::parse_color(font_color);

View File

@ -15,7 +15,7 @@ use graph_craft::document::value::TaggedValue;
use graphene_std::renderer::Quad; use graphene_std::renderer::Quad;
use graphene_std::subpath::{Bezier, BezierHandles}; use graphene_std::subpath::{Bezier, BezierHandles};
use graphene_std::table::Table; use graphene_std::table::Table;
use graphene_std::text::{FontCache, load_font}; use graphene_std::text::FontCache;
use graphene_std::vector::algorithms::bezpath_algorithms::pathseg_compute_lookup_table; use graphene_std::vector::algorithms::bezpath_algorithms::pathseg_compute_lookup_table;
use graphene_std::vector::misc::{HandleId, ManipulatorPointId, dvec2_to_point}; use graphene_std::vector::misc::{HandleId, ManipulatorPointId, dvec2_to_point};
use graphene_std::vector::{HandleExt, PointId, SegmentId, Vector, VectorModification, VectorModificationType}; use graphene_std::vector::{HandleExt, PointId, SegmentId, Vector, VectorModification, VectorModificationType};
@ -74,8 +74,7 @@ pub fn text_bounding_box(layer: LayerNodeIdentifier, document: &DocumentMessageH
return Quad::from_box([DVec2::ZERO, DVec2::ZERO]); return Quad::from_box([DVec2::ZERO, DVec2::ZERO]);
}; };
let font_data = font_cache.get(font).map(|data| load_font(data)); let far = graphene_std::text::bounding_box(text, font, font_cache, typesetting, false);
let far = graphene_std::text::bounding_box(text, font_data, typesetting, false);
// TODO: Once the instance tables refactor is complete and per_glyph_instances can be removed (since it'll be the default), // TODO: Once the instance tables refactor is complete and per_glyph_instances can be removed (since it'll be the default),
// TODO: remove this because the top of the dashed bounding overlay should no longer be based on the first line's baseline. // TODO: remove this because the top of the dashed bounding overlay should no longer be based on the first line's baseline.

View File

@ -17,7 +17,7 @@ use graph_craft::document::value::TaggedValue;
use graph_craft::document::{NodeId, NodeInput}; use graph_craft::document::{NodeId, NodeInput};
use graphene_std::Color; use graphene_std::Color;
use graphene_std::renderer::Quad; use graphene_std::renderer::Quad;
use graphene_std::text::{Font, FontCache, TextAlign, TypesettingConfig, lines_clipping, load_font}; use graphene_std::text::{Font, FontCache, TextAlign, TypesettingConfig, lines_clipping};
use graphene_std::vector::style::Fill; use graphene_std::vector::style::Fill;
#[derive(Default, ExtractField)] #[derive(Default, ExtractField)]
@ -513,8 +513,7 @@ impl Fsm for TextToolFsmState {
transform: document.metadata().transform_to_viewport(tool_data.layer).to_cols_array(), transform: document.metadata().transform_to_viewport(tool_data.layer).to_cols_array(),
}); });
if let Some(editing_text) = tool_data.editing_text.as_mut() { if let Some(editing_text) = tool_data.editing_text.as_mut() {
let font_data = font_cache.get(&editing_text.font).map(|data| load_font(data)); let far = graphene_std::text::bounding_box(&tool_data.new_text, &editing_text.font, font_cache, editing_text.typesetting, false);
let far = graphene_std::text::bounding_box(&tool_data.new_text, font_data, editing_text.typesetting, false);
if far.x != 0. && far.y != 0. { if far.x != 0. && far.y != 0. {
let quad = Quad::from_box([DVec2::ZERO, far]); let quad = Quad::from_box([DVec2::ZERO, far]);
let transformed_quad = document.metadata().transform_to_viewport(tool_data.layer) * quad; let transformed_quad = document.metadata().transform_to_viewport(tool_data.layer) * quad;
@ -562,8 +561,7 @@ impl Fsm for TextToolFsmState {
// Draw red overlay if text is clipped // Draw red overlay if text is clipped
let transformed_quad = layer_transform * bounds; let transformed_quad = layer_transform * bounds;
if let Some((text, font, typesetting, _)) = graph_modification_utils::get_text(layer.unwrap(), &document.network_interface) { if let Some((text, font, typesetting, _)) = graph_modification_utils::get_text(layer.unwrap(), &document.network_interface) {
let font_data = font_cache.get(font).map(|data| load_font(data)); if lines_clipping(text.as_str(), font, font_cache, typesetting) {
if lines_clipping(text.as_str(), font_data, typesetting) {
overlay_context.line(transformed_quad.0[2], transformed_quad.0[3], Some(COLOR_OVERLAY_RED), Some(3.)); overlay_context.line(transformed_quad.0[2], transformed_quad.0[3], Some(COLOR_OVERLAY_RED), Some(3.));
} }
} }

View File

@ -1,8 +1,11 @@
mod font_cache; mod font_cache;
mod path_builder;
mod text_context;
mod to_path; mod to_path;
use dyn_any::DynAny; use dyn_any::DynAny;
pub use font_cache::*; pub use font_cache::*;
pub use text_context::TextContext;
pub use to_path::*; pub use to_path::*;
/// Alignment of lines of type within a text block. /// Alignment of lines of type within a text block.
@ -29,3 +32,28 @@ impl From<TextAlign> for parley::Alignment {
} }
} }
} }
#[derive(PartialEq, Clone, Copy, Debug, serde::Serialize, serde::Deserialize)]
pub struct TypesettingConfig {
pub font_size: f64,
pub line_height_ratio: f64,
pub character_spacing: f64,
pub max_width: Option<f64>,
pub max_height: Option<f64>,
pub tilt: f64,
pub align: TextAlign,
}
impl Default for TypesettingConfig {
fn default() -> Self {
Self {
font_size: 24.,
line_height_ratio: 1.2,
character_spacing: 0.,
max_width: None,
max_height: None,
tilt: 0.,
align: TextAlign::default(),
}
}
}

View File

@ -1,5 +1,7 @@
use dyn_any::DynAny; use dyn_any::DynAny;
use parley::fontique::Blob;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc;
/// A font type (storing font family and font style and an optional preview URL) /// A font type (storing font family and font style and an optional preview URL)
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Hash, PartialEq, Eq, DynAny, specta::Type)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Hash, PartialEq, Eq, DynAny, specta::Type)]
@ -50,8 +52,13 @@ impl FontCache {
} }
/// Try to get the bytes for a font /// Try to get the bytes for a font
pub fn get(&self, font: &Font) -> Option<&Vec<u8>> { pub fn get<'a>(&'a self, font: &'a Font) -> Option<(&'a Vec<u8>, &'a Font)> {
self.resolve_font(font).and_then(|font| self.font_file_data.get(font)) self.resolve_font(font).and_then(|font| self.font_file_data.get(font).map(|data| (data, font)))
}
/// Get font data as a Blob for use with parley/skrifa
pub fn get_blob<'a>(&'a self, font: &'a Font) -> Option<(Blob<u8>, &'a Font)> {
self.get(font).map(|(data, font)| (Blob::new(Arc::new(data.clone())), font))
} }
/// Check if the font is already loaded /// Check if the font is already loaded

View File

@ -0,0 +1,158 @@
use crate::subpath::{ManipulatorGroup, Subpath};
use crate::table::{Table, TableRow};
use crate::vector::{PointId, Vector};
use glam::{DAffine2, DVec2};
use parley::GlyphRun;
use skrifa::GlyphId;
use skrifa::instance::{LocationRef, NormalizedCoord, Size};
use skrifa::outline::{DrawSettings, OutlinePen};
use skrifa::raw::FontRef as ReadFontsRef;
use skrifa::{MetadataProvider, OutlineGlyph};
pub struct PathBuilder {
current_subpath: Subpath<PointId>,
origin: DVec2,
glyph_subpaths: Vec<Subpath<PointId>>,
pub vector_table: Table<Vector>,
scale: f64,
id: PointId,
}
impl PathBuilder {
pub fn new(per_glyph_instances: bool, scale: f64) -> Self {
Self {
current_subpath: Subpath::new(Vec::new(), false),
glyph_subpaths: Vec::new(),
vector_table: if per_glyph_instances { Table::new() } else { Table::new_from_element(Vector::default()) },
scale,
id: PointId::ZERO,
origin: DVec2::default(),
}
}
fn point(&self, x: f32, y: f32) -> DVec2 {
DVec2::new(self.origin.x + x as f64, self.origin.y - y as f64) * self.scale
}
#[allow(clippy::too_many_arguments)]
fn draw_glyph(&mut self, glyph: &OutlineGlyph<'_>, size: f32, normalized_coords: &[NormalizedCoord], glyph_offset: DVec2, style_skew: Option<DAffine2>, skew: DAffine2, per_glyph_instances: bool) {
let location_ref = LocationRef::new(normalized_coords);
let settings = DrawSettings::unhinted(Size::new(size), location_ref);
glyph.draw(settings, self).unwrap();
// Apply transforms in correct order: style-based skew first, then user-requested skew
// This ensures font synthesis (italic) is applied before user transformations
for glyph_subpath in &mut self.glyph_subpaths {
if let Some(style_skew) = style_skew {
glyph_subpath.apply_transform(style_skew);
}
glyph_subpath.apply_transform(skew);
}
if per_glyph_instances {
self.vector_table.push(TableRow {
element: Vector::from_subpaths(core::mem::take(&mut self.glyph_subpaths), false),
transform: DAffine2::from_translation(glyph_offset),
..Default::default()
});
} else {
for subpath in self.glyph_subpaths.drain(..) {
// Unwrapping here is ok because `self.vector_table` is initialized with a single `Vector` table element
self.vector_table.get_mut(0).unwrap().element.append_subpath(subpath, false);
}
}
}
pub fn render_glyph_run(&mut self, glyph_run: &GlyphRun<'_, ()>, tilt: f64, per_glyph_instances: bool) {
let mut run_x = glyph_run.offset();
let run_y = glyph_run.baseline();
let run = glyph_run.run();
// User-requested tilt applied around baseline to avoid vertical displacement
// Translation ensures rotation point is at the baseline, not origin
let skew = if per_glyph_instances {
DAffine2::from_cols_array(&[1., 0., -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_translation(DVec2::new(0., -run_y as f64))
};
let synthesis = run.synthesis();
// Font synthesis (e.g., synthetic italic) applied separately from user transforms
// This preserves the distinction between font styling and user transformations
let style_skew = synthesis.skew().map(|angle| {
if per_glyph_instances {
DAffine2::from_cols_array(&[1., 0., -angle.to_radians().tan() as f64, 1., 0., 0.])
} else {
DAffine2::from_translation(DVec2::new(0., run_y as f64))
* DAffine2::from_cols_array(&[1., 0., -angle.to_radians().tan() as f64, 1., 0., 0.])
* DAffine2::from_translation(DVec2::new(0., -run_y as f64))
}
});
let font = run.font();
let font_size = run.font_size();
let normalized_coords = run.normalized_coords().iter().map(|coord| NormalizedCoord::from_bits(*coord)).collect::<Vec<_>>();
// TODO: This can be cached for better performance
let font_collection_ref = font.data.as_ref();
let font_ref = ReadFontsRef::from_index(font_collection_ref, font.index).unwrap();
let outlines = font_ref.outline_glyphs();
for glyph in glyph_run.glyphs() {
let glyph_offset = DVec2::new((run_x + glyph.x) as f64, (run_y - glyph.y) as f64);
run_x += glyph.advance;
let glyph_id = GlyphId::from(glyph.id);
if let Some(glyph_outline) = outlines.get(glyph_id) {
if !per_glyph_instances {
self.origin = glyph_offset;
}
self.draw_glyph(&glyph_outline, font_size, &normalized_coords, glyph_offset, style_skew, skew, per_glyph_instances);
}
}
}
pub fn finalize(mut self) -> Table<Vector> {
if self.vector_table.is_empty() {
self.vector_table = Table::new_from_element(Vector::default());
}
self.vector_table
}
}
impl OutlinePen for PathBuilder {
fn move_to(&mut self, x: f32, y: f32) {
if !self.current_subpath.is_empty() {
self.glyph_subpaths.push(std::mem::replace(&mut self.current_subpath, Subpath::new(Vec::new(), false)));
}
self.current_subpath.push_manipulator_group(ManipulatorGroup::new_anchor_with_id(self.point(x, y), self.id.next_id()));
}
fn line_to(&mut self, x: f32, y: f32) {
self.current_subpath.push_manipulator_group(ManipulatorGroup::new_anchor_with_id(self.point(x, y), self.id.next_id()));
}
fn quad_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32) {
let [handle, anchor] = [self.point(x1, y1), self.point(x2, y2)];
self.current_subpath.last_manipulator_group_mut().unwrap().out_handle = Some(handle);
self.current_subpath.push_manipulator_group(ManipulatorGroup::new_with_id(anchor, None, None, self.id.next_id()));
}
fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x3: f32, y3: f32) {
let [handle1, handle2, anchor] = [self.point(x1, y1), self.point(x2, y2), self.point(x3, y3)];
self.current_subpath.last_manipulator_group_mut().unwrap().out_handle = Some(handle1);
self.current_subpath
.push_manipulator_group(ManipulatorGroup::new_with_id(anchor, Some(handle2), None, self.id.next_id()));
}
fn close(&mut self) {
self.current_subpath.set_closed(true);
self.glyph_subpaths.push(std::mem::replace(&mut self.current_subpath, Subpath::new(Vec::new(), false)));
}
}

View File

@ -0,0 +1,129 @@
use super::{Font, FontCache, TypesettingConfig};
use crate::table::Table;
use crate::vector::Vector;
use core::cell::RefCell;
use glam::DVec2;
use parley::fontique::{Blob, FamilyId, FontInfo};
use parley::{AlignmentOptions, FontContext, Layout, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty};
use std::collections::HashMap;
use super::path_builder::PathBuilder;
thread_local! {
static THREAD_TEXT: RefCell<TextContext> = RefCell::new(TextContext::default());
}
/// 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<()>,
/// Cached font metadata for performance optimization
font_info_cache: HashMap<Font, (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)
}
/// Resolve a font and return its data as a Blob if available
fn resolve_font_data<'a>(&self, font: &'a Font, font_cache: &'a FontCache) -> Option<(Blob<u8>, &'a Font)> {
font_cache.get_blob(font)
}
/// Get or cache font information for a given font
fn get_font_info(&mut self, font: &Font, font_data: &Blob<u8>) -> Option<(String, FontInfo)> {
// Check if we already have the font info cached
if let Some((family_id, font_info)) = self.font_info_cache.get(font) {
if let Some(family_name) = self.font_context.collection.family_name(*family_id) {
return Some((family_name.to_string(), font_info.clone()));
}
}
// Register the font and cache the info
let families = self.font_context.collection.register_fonts(font_data.clone(), 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| {
// Cache the font info for future use
self.font_info_cache.insert(font.clone(), (*family_id, font_info.clone()));
(family_name.to_string(), font_info.clone())
})
})
})
}
/// Create a text layout using the specified font and typesetting configuration
fn layout_text(&mut self, text: &str, font: &Font, font_cache: &FontCache, typesetting: TypesettingConfig) -> Option<Layout<()>> {
// Note that the actual_font may not be the desired font if that font is not yet loaded.
// It is important not to cache the default font under the name of another font.
let (font_data, actual_font) = self.resolve_font_data(font, font_cache)?;
let (font_family, font_info) = self.get_font_info(actual_font, &font_data)?;
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::FontStack(parley::FontStack::Single(parley::FontFamily::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.max_width.map(|max_w| max_w as f32), 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: &Font, font_cache: &FontCache, typesetting: TypesettingConfig, per_glyph_instances: bool) -> Table<Vector> {
let Some(layout) = self.layout_text(text, font, font_cache, typesetting) else {
return Table::new_from_element(Vector::default());
};
let mut path_builder = PathBuilder::new(per_glyph_instances, layout.scale() as f64);
for line in layout.lines() {
for item in line.items() {
if let PositionedLayoutItem::GlyphRun(glyph_run) = item {
path_builder.render_glyph_run(&glyph_run, typesetting.tilt, per_glyph_instances);
}
}
}
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: &Font, font_cache: &FontCache, typesetting: TypesettingConfig, for_clipping_test: bool) -> DVec2 {
if !for_clipping_test {
if let (Some(max_height), Some(max_width)) = (typesetting.max_height, typesetting.max_width) {
return DVec2::new(max_width, max_height);
}
}
let Some(layout) = self.layout_text(text, font, font_cache, typesetting) else {
return DVec2::ZERO;
};
DVec2::new(layout.full_width() as f64, layout.height() as f64)
}
/// Check if text lines are being clipped due to height constraints
pub fn lines_clipping(&mut self, text: &str, font: &Font, font_cache: &FontCache, typesetting: TypesettingConfig) -> bool {
let Some(max_height) = typesetting.max_height else { return false };
let bounds = self.bounding_box(text, font, font_cache, typesetting, true);
max_height < bounds.y
}
}

View File

@ -1,261 +1,23 @@
use super::TextAlign; use super::text_context::TextContext;
use crate::subpath::{ManipulatorGroup, Subpath}; use super::{Font, FontCache, TypesettingConfig};
use crate::table::{Table, TableRow}; use crate::table::Table;
use crate::vector::{PointId, Vector}; use crate::vector::Vector;
use core::cell::RefCell; use glam::DVec2;
use glam::{DAffine2, DVec2};
use parley::fontique::Blob; use parley::fontique::Blob;
use parley::{AlignmentOptions, FontContext, GlyphRun, Layout, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty};
use skrifa::GlyphId;
use skrifa::instance::{LocationRef, NormalizedCoord, Size};
use skrifa::outline::{DrawSettings, OutlinePen};
use skrifa::raw::FontRef as ReadFontsRef;
use skrifa::{MetadataProvider, OutlineGlyph};
use std::sync::Arc; use std::sync::Arc;
// Thread-local storage avoids expensive re-initialization of font and layout contexts pub fn to_path(text: &str, font: &Font, font_cache: &FontCache, typesetting: TypesettingConfig, per_glyph_instances: bool) -> Table<Vector> {
// across multiple text rendering operations within the same thread TextContext::with_thread_local(|ctx| ctx.to_path(text, font, font_cache, typesetting, per_glyph_instances))
thread_local! {
static FONT_CONTEXT: RefCell<FontContext> = RefCell::new(FontContext::new());
static LAYOUT_CONTEXT: RefCell<LayoutContext<()>> = RefCell::new(LayoutContext::new());
} }
struct PathBuilder { pub fn bounding_box(text: &str, font: &Font, font_cache: &FontCache, typesetting: TypesettingConfig, for_clipping_test: bool) -> DVec2 {
current_subpath: Subpath<PointId>, TextContext::with_thread_local(|ctx| ctx.bounding_box(text, font, font_cache, typesetting, for_clipping_test))
origin: DVec2,
glyph_subpaths: Vec<Subpath<PointId>>,
vector_table: Table<Vector>,
scale: f64,
id: PointId,
}
impl PathBuilder {
fn point(&self, x: f32, y: f32) -> DVec2 {
DVec2::new(self.origin.x + x as f64, self.origin.y - y as f64) * self.scale
}
#[allow(clippy::too_many_arguments)]
fn draw_glyph(&mut self, glyph: &OutlineGlyph<'_>, size: f32, normalized_coords: &[NormalizedCoord], glyph_offset: DVec2, style_skew: Option<DAffine2>, skew: DAffine2, per_glyph_instances: bool) {
let location_ref = LocationRef::new(normalized_coords);
let settings = DrawSettings::unhinted(Size::new(size), location_ref);
glyph.draw(settings, self).unwrap();
// Apply transforms in correct order: style-based skew first, then user-requested skew
// This ensures font synthesis (italic) is applied before user transformations
for glyph_subpath in &mut self.glyph_subpaths {
if let Some(style_skew) = style_skew {
glyph_subpath.apply_transform(style_skew);
}
glyph_subpath.apply_transform(skew);
}
if per_glyph_instances {
self.vector_table.push(TableRow {
element: Vector::from_subpaths(core::mem::take(&mut self.glyph_subpaths), false),
transform: DAffine2::from_translation(glyph_offset),
..Default::default()
});
} else {
for subpath in self.glyph_subpaths.drain(..) {
// Unwrapping here is ok because `self.vector_table` is initialized with a single `Vector` table element
self.vector_table.get_mut(0).unwrap().element.append_subpath(subpath, false);
}
}
}
}
impl OutlinePen for PathBuilder {
fn move_to(&mut self, x: f32, y: f32) {
if !self.current_subpath.is_empty() {
self.glyph_subpaths.push(std::mem::replace(&mut self.current_subpath, Subpath::new(Vec::new(), false)));
}
self.current_subpath.push_manipulator_group(ManipulatorGroup::new_anchor_with_id(self.point(x, y), self.id.next_id()));
}
fn line_to(&mut self, x: f32, y: f32) {
self.current_subpath.push_manipulator_group(ManipulatorGroup::new_anchor_with_id(self.point(x, y), self.id.next_id()));
}
fn quad_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32) {
let [handle, anchor] = [self.point(x1, y1), self.point(x2, y2)];
self.current_subpath.last_manipulator_group_mut().unwrap().out_handle = Some(handle);
self.current_subpath.push_manipulator_group(ManipulatorGroup::new_with_id(anchor, None, None, self.id.next_id()));
}
fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x3: f32, y3: f32) {
let [handle1, handle2, anchor] = [self.point(x1, y1), self.point(x2, y2), self.point(x3, y3)];
self.current_subpath.last_manipulator_group_mut().unwrap().out_handle = Some(handle1);
self.current_subpath
.push_manipulator_group(ManipulatorGroup::new_with_id(anchor, Some(handle2), None, self.id.next_id()));
}
fn close(&mut self) {
self.current_subpath.set_closed(true);
self.glyph_subpaths.push(std::mem::replace(&mut self.current_subpath, Subpath::new(Vec::new(), false)));
}
}
#[derive(PartialEq, Clone, Copy, Debug, serde::Serialize, serde::Deserialize)]
pub struct TypesettingConfig {
pub font_size: f64,
pub line_height_ratio: f64,
pub character_spacing: f64,
pub max_width: Option<f64>,
pub max_height: Option<f64>,
pub tilt: f64,
pub align: TextAlign,
}
impl Default for TypesettingConfig {
fn default() -> Self {
Self {
font_size: 24.,
line_height_ratio: 1.2,
character_spacing: 0.,
max_width: None,
max_height: None,
tilt: 0.,
align: TextAlign::default(),
}
}
}
fn render_glyph_run(glyph_run: &GlyphRun<'_, ()>, path_builder: &mut PathBuilder, tilt: f64, per_glyph_instances: bool) {
let mut run_x = glyph_run.offset();
let run_y = glyph_run.baseline();
let run = glyph_run.run();
// User-requested tilt applied around baseline to avoid vertical displacement
// Translation ensures rotation point is at the baseline, not origin
let skew = if per_glyph_instances {
DAffine2::from_cols_array(&[1., 0., -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_translation(DVec2::new(0., -run_y as f64))
};
let synthesis = run.synthesis();
// Font synthesis (e.g., synthetic italic) applied separately from user transforms
// This preserves the distinction between font styling and user transformations
let style_skew = synthesis.skew().map(|angle| {
if per_glyph_instances {
DAffine2::from_cols_array(&[1., 0., -angle.to_radians().tan() as f64, 1., 0., 0.])
} else {
DAffine2::from_translation(DVec2::new(0., run_y as f64))
* DAffine2::from_cols_array(&[1., 0., -angle.to_radians().tan() as f64, 1., 0., 0.])
* DAffine2::from_translation(DVec2::new(0., -run_y as f64))
}
});
let font = run.font();
let font_size = run.font_size();
let normalized_coords = run.normalized_coords().iter().map(|coord| NormalizedCoord::from_bits(*coord)).collect::<Vec<_>>();
// TODO: This can be cached for better performance
let font_collection_ref = font.data.as_ref();
let font_ref = ReadFontsRef::from_index(font_collection_ref, font.index).unwrap();
let outlines = font_ref.outline_glyphs();
for glyph in glyph_run.glyphs() {
let glyph_offset = DVec2::new((run_x + glyph.x) as f64, (run_y - glyph.y) as f64);
run_x += glyph.advance;
let glyph_id = GlyphId::from(glyph.id);
if let Some(glyph_outline) = outlines.get(glyph_id) {
if !per_glyph_instances {
path_builder.origin = glyph_offset;
}
path_builder.draw_glyph(&glyph_outline, font_size, &normalized_coords, glyph_offset, style_skew, skew, per_glyph_instances);
}
}
}
fn layout_text(str: &str, font_data: Option<Blob<u8>>, typesetting: TypesettingConfig) -> Option<Layout<()>> {
FONT_CONTEXT.with_borrow_mut(|mut font_cx| {
LAYOUT_CONTEXT.with_borrow_mut(|layout_cx| {
let (font_family, font_info) = font_data.and_then(|font_data| {
let families = font_cx.collection.register_fonts(font_data, None);
families.first().and_then(|(family_id, fonts_info)| {
fonts_info
.first()
.and_then(|font_info| font_cx.collection.family_name(*family_id).map(|f| (f.to_string(), font_info.clone())))
})
})?;
const DISPLAY_SCALE: f32 = 1.;
let mut builder = layout_cx.ranged_builder(&mut font_cx, str, 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::FontStack(parley::FontStack::Single(parley::FontFamily::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(str);
layout.break_all_lines(typesetting.max_width.map(|mw| mw as f32));
layout.align(typesetting.max_width.map(|max_w| max_w as f32), typesetting.align.into(), AlignmentOptions::default());
Some(layout)
})
})
}
pub fn to_path(str: &str, font_data: Option<Blob<u8>>, typesetting: TypesettingConfig, per_glyph_instances: bool) -> Table<Vector> {
let Some(layout) = layout_text(str, font_data, typesetting) else {
return Table::new_from_element(Vector::default());
};
let mut path_builder = PathBuilder {
current_subpath: Subpath::new(Vec::new(), false),
glyph_subpaths: Vec::new(),
vector_table: if per_glyph_instances { Table::new() } else { Table::new_from_element(Vector::default()) },
scale: layout.scale() as f64,
id: PointId::ZERO,
origin: DVec2::default(),
};
for line in layout.lines() {
for item in line.items() {
if let PositionedLayoutItem::GlyphRun(glyph_run) = item {
render_glyph_run(&glyph_run, &mut path_builder, typesetting.tilt, per_glyph_instances);
}
}
}
if path_builder.vector_table.is_empty() {
path_builder.vector_table = Table::new_from_element(Vector::default());
}
path_builder.vector_table
}
pub fn bounding_box(str: &str, font_data: Option<Blob<u8>>, typesetting: TypesettingConfig, for_clipping_test: bool) -> DVec2 {
if !for_clipping_test {
if let (Some(max_height), Some(max_width)) = (typesetting.max_height, typesetting.max_width) {
return DVec2::new(max_width, max_height);
}
}
let Some(layout) = layout_text(str, font_data, typesetting) else { return DVec2::ZERO };
DVec2::new(layout.full_width() as f64, layout.height() as f64)
} }
pub fn load_font(data: &[u8]) -> Blob<u8> { pub fn load_font(data: &[u8]) -> Blob<u8> {
Blob::new(Arc::new(data.to_vec())) Blob::new(Arc::new(data.to_vec()))
} }
pub fn lines_clipping(str: &str, font_data: Option<Blob<u8>>, typesetting: TypesettingConfig) -> bool { pub fn lines_clipping(text: &str, font: &Font, font_cache: &FontCache, typesetting: TypesettingConfig) -> bool {
let Some(max_height) = typesetting.max_height else { return false }; TextContext::with_thread_local(|ctx| ctx.lines_clipping(text, font, font_cache, typesetting))
let bounds = bounding_box(str, font_data, typesetting, true);
max_height < bounds.y
} }

View File

@ -38,7 +38,5 @@ fn text<'i: 'n>(
align, align,
}; };
let font_data = editor.font_cache.get(&font_name).map(|f| load_font(f)); to_path(&text, &font_name, &editor.font_cache, typesetting, per_glyph_instances)
to_path(&text, font_data, typesetting, per_glyph_instances)
} }