use crate::render::text::decoration_segments; use crate::{ math::{Bounds, Matrix, Rect}, render::{default_font, DEFAULT_EMOJI_FONT}, utils::Browser, }; use core::f32; use macros::ToJs; use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle}; use skia_safe::{ self as skia, paint::{self, Paint}, textlayout::Affinity, textlayout::ParagraphBuilder, textlayout::ParagraphStyle, textlayout::PlaceholderAlignment, textlayout::PlaceholderStyle, textlayout::PositionWithAffinity, textlayout::TextBaseline, Contains, }; use std::borrow::Cow; use std::cell::Cell; use std::collections::HashSet; use std::rc::Rc; use super::FontFamily; use crate::math::Point; use crate::shapes::{self, merge_fills, Shape, Type, VerticalAlign}; use crate::utils::{get_fallback_fonts, get_font_collection}; use crate::Uuid; // TODO: maybe move this to the wasm module? pub type ParagraphBuilderGroup = Vec; /// True when the modifier changes the text layout container (resize), as opposed /// to rotation/move where glyph layout can be reused. pub fn modifier_changes_text_layout(base: &Shape, modifier: &Matrix) -> bool { let Type::Text(text_content) = &base.shape_type else { return false; }; let before = oriented_container_bounds(base); let after = before.transform(modifier); match text_content.grow_type() { GrowType::AutoWidth => !crate::math::is_close_to(before.height(), after.height()), GrowType::AutoHeight | GrowType::Fixed => { !crate::math::is_close_to(before.width(), after.width()) } } } fn oriented_container_bounds(shape: &Shape) -> Bounds { let selrect = shape.selrect(); let mut bounds = Bounds::new( Point::new(selrect.x(), selrect.y()), Point::new(selrect.x() + selrect.width(), selrect.y()), Point::new( selrect.x() + selrect.width(), selrect.y() + selrect.height(), ), Point::new(selrect.x(), selrect.y() + selrect.height()), ); if !shape.transform.is_identity() { let mut matrix = shape.transform; let center = shape.center(); matrix.post_translate(center); matrix.pre_translate(-center); bounds.transform_mut(&matrix); } bounds } #[repr(u8)] #[derive(Debug, PartialEq, Clone, Copy, ToJs)] pub enum GrowType { Fixed = 0, AutoWidth = 1, AutoHeight = 2, } #[derive(Debug, PartialEq, Copy, Clone)] pub struct TextContentSize { pub width: f32, pub height: f32, pub max_width: f32, pub normalized_line_height: f32, } const DEFAULT_TEXT_CONTENT_SIZE: f32 = 0.01; /// Matches `marginRight: "1px"` on `.paragraph-set` in the HTML text renderer /// (`frontend/src/app/main/ui/shapes/text/styles.cljs`). DOM `getBoundingClientRect` /// includes that margin in auto-width measurements; Skia `longest_line()` does not. const PARAGRAPH_SET_MARGIN_RIGHT: f32 = 1.0; impl TextContentSize { pub fn default() -> Self { Self { width: DEFAULT_TEXT_CONTENT_SIZE, height: DEFAULT_TEXT_CONTENT_SIZE, max_width: DEFAULT_TEXT_CONTENT_SIZE, normalized_line_height: 0.0, } } pub fn new_with_size(width: f32, height: f32) -> Self { Self { width, height, max_width: DEFAULT_TEXT_CONTENT_SIZE, normalized_line_height: 0.0, } } pub fn new_with_normalized_line_height( width: f32, height: f32, max_width: f32, normalized_line_height: f32, ) -> Self { Self { width, height, max_width, normalized_line_height, } } pub fn set_size(&mut self, width: f32, height: f32) { self.width = width; self.height = height; } pub fn copy_finite_size( &mut self, size: TextContentSize, default_height: f32, default_width: f32, ) { if f32::is_finite(size.width) { self.width = size.width; } else { self.width = default_width; } if f32::is_finite(size.max_width) { self.max_width = size.max_width; } else { self.max_width = default_width } if f32::is_finite(size.height) { self.height = size.height; } else { self.height = default_height; } if f32::is_finite(size.normalized_line_height) { self.normalized_line_height = size.normalized_line_height; } } } #[derive(Debug, Clone, Copy, Default)] pub struct TextPositionWithAffinity { pub position_with_affinity: PositionWithAffinity, pub paragraph: usize, pub offset: usize, } impl PartialEq for TextPositionWithAffinity { fn eq(&self, other: &Self) -> bool { self.paragraph == other.paragraph && self.offset == other.offset } } impl TextPositionWithAffinity { pub fn new( position_with_affinity: PositionWithAffinity, paragraph: usize, offset: usize, ) -> Self { Self { position_with_affinity, paragraph, offset, } } pub fn empty() -> Self { Self { position_with_affinity: PositionWithAffinity { position: 0, affinity: Affinity::Downstream, }, paragraph: 0, offset: 0, } } pub fn new_downstream_affinity(paragraph: usize, offset: usize) -> Self { Self { position_with_affinity: PositionWithAffinity { position: offset as i32, affinity: Affinity::Downstream, }, paragraph, offset, } } pub fn new_upstream_affinity(paragraph: usize, offset: usize) -> Self { Self { position_with_affinity: PositionWithAffinity { position: offset as i32, affinity: Affinity::Upstream, }, paragraph, offset, } } pub fn reset(&mut self) { self.position_with_affinity.position = 0; self.position_with_affinity.affinity = Affinity::Downstream; self.paragraph = 0; self.offset = 0; } } #[derive(Debug)] pub struct TextContentLayoutResult( Vec, Vec>, TextContentSize, ); /// Cached extrect stored as offsets from the selrect origin, /// keyed by the selrect dimensions (width, height) and vertical alignment /// used to compute it. #[derive(Debug, Clone, Copy)] struct CachedExtrect { selrect_width: f32, selrect_height: f32, valign: u8, left: f32, top: f32, right: f32, bottom: f32, } #[derive(Debug)] pub struct TextContentLayout { pub paragraph_builders: Vec, /// Shared across shape clones (e.g. modifier transforms) so rotation/pan /// can paint without rebuilding Skia layout. Cleared builders on clone are OK. pub paragraphs: Rc>>, cached_extrect: Cell>, } impl Default for TextContentLayout { fn default() -> Self { Self::new() } } impl Clone for TextContentLayout { fn clone(&self) -> Self { Self { paragraph_builders: vec![], paragraphs: Rc::clone(&self.paragraphs), cached_extrect: Cell::new(self.cached_extrect.get()), } } } impl PartialEq for TextContentLayout { fn eq(&self, _other: &Self) -> bool { true } } impl TextContentLayout { pub fn new() -> Self { Self { paragraph_builders: vec![], paragraphs: Rc::new(Vec::new()), cached_extrect: Cell::new(None), } } pub fn set( &mut self, paragraph_builders: Vec, paragraphs: Vec>, ) { self.paragraph_builders = paragraph_builders; self.paragraphs = Rc::new(paragraphs); self.cached_extrect.set(None); } pub fn clear(&mut self) { self.paragraph_builders.clear(); self.paragraphs = Rc::new(Vec::new()); self.cached_extrect.set(None); } pub fn needs_update(&self) -> bool { self.paragraphs.is_empty() } } #[derive(Debug, Clone)] pub struct TextDecorationSegment { #[allow(dead_code)] pub kind: skia::textlayout::TextDecoration, pub text_style: skia::textlayout::TextStyle, pub y: f32, pub thickness: f32, pub left: f32, pub width: f32, } impl TextDecorationSegment { /// The bar to paint, centered on `y`. pub fn rect(&self) -> Rect { Rect::new( self.left, self.y - self.thickness / 2.0, self.left + self.width, self.y + self.thickness / 2.0, ) } } pub fn vertical_align_offset(container_h: f32, content_h: f32, valign: VerticalAlign) -> f32 { match valign { VerticalAlign::Center => (container_h - content_h) / 2.0, VerticalAlign::Bottom => container_h - content_h, _ => 0.0, } } fn intersects(paragraph: &skia_safe::textlayout::Paragraph, x: f32, y: f32) -> bool { if y < 0.0 || y > paragraph.height() { return false; } let pos = paragraph.get_glyph_position_at_coordinate((x, y)); let idx = pos.position as usize; let rects = paragraph.get_rects_for_range(0..idx + 1, RectHeightStyle::Tight, RectWidthStyle::Tight); rects.iter().any(|r| r.rect.contains(&Point::new(x, y))) } fn paragraph_intersects<'a>( paragraphs: impl Iterator, x_pos: f32, y_pos: f32, ) -> bool { paragraphs .scan(0.0_f32, |height, p| { let prev_height = *height; *height += p.height(); Some((prev_height, p)) }) .any(|(height, p)| intersects(p, x_pos, y_pos - height)) } // Performs a text auto layout without width limits. // This should be the same as text_auto_layout. pub fn build_paragraphs_from_paragraph_builders( paragraph_builders: &mut [ParagraphBuilderGroup], width: f32, ) -> Vec> { let paragraphs = paragraph_builders .iter_mut() .map(|builders| { builders .iter_mut() .map(|builder| { let mut paragraph = builder.build(); // For auto-width, always layout with infinite width first to get intrinsic width paragraph.layout(width); paragraph }) .collect() }) .collect(); paragraphs } /// Calculate the normalized line height from paragraph builders pub fn calculate_normalized_line_height( paragraph_builders: &mut [ParagraphBuilderGroup], width: f32, ) -> f32 { let mut normalized_line_height = 0.0; for paragraph_builder_group in paragraph_builders.iter_mut() { for paragraph_builder in paragraph_builder_group.iter_mut() { let mut paragraph = paragraph_builder.build(); paragraph.layout(width); let baseline = paragraph.ideographic_baseline(); if baseline > normalized_line_height { normalized_line_height = baseline; } } } normalized_line_height } #[derive(Debug, Clone)] pub struct TextContent { pub paragraphs: Vec, pub bounds: Rect, pub grow_type: GrowType, pub size: TextContentSize, pub layout: TextContentLayout, content_version: u64, layout_version: u64, layout_width: Option, /// Canvas origin used when absolute fill shaders (image/gradient) were baked /// into cached Skia paragraphs. Kept across move clones so paint can /// translate glyphs + shaders together. See `cached_layout_paint_offset`. layout_paint_origin: Option, } impl PartialEq for TextContent { fn eq(&self, other: &Self) -> bool { self.paragraphs == other.paragraphs && self.bounds == other.bounds && self.grow_type == other.grow_type && self.size == other.size && self.layout == other.layout } } impl TextContent { pub fn new(bounds: Rect, grow_type: GrowType) -> Self { Self { paragraphs: Vec::new(), bounds, grow_type, size: TextContentSize::default(), layout: TextContentLayout::new(), content_version: 0, layout_version: 0, layout_width: None, layout_paint_origin: None, } } pub fn new_bounds(&self, bounds: Rect) -> Self { let paragraphs = self.paragraphs.clone(); let grow_type = self.grow_type; Self { paragraphs, bounds, grow_type, size: TextContentSize::new_with_size(bounds.width(), bounds.height()), layout: TextContentLayout::new(), content_version: 0, layout_version: 0, layout_width: None, layout_paint_origin: None, } } pub fn bounds(&self) -> Rect { self.bounds } /// Anchor used when painting from the layout cache. Absolute image/gradient /// shaders were built in this coordinate space; paint glyphs here and apply /// [`cached_layout_paint_offset`] on the canvas so both move together. pub fn cached_layout_paint_anchor(&self, selrect: &Rect) -> Point { self.layout_paint_origin .unwrap_or_else(|| Point::new(self.selrect_origin_x(selrect), selrect.y())) } /// Canvas translation from the baked paint origin to the current selrect. /// Zero when there is no recorded origin (fall back to painting at selrect). pub fn cached_layout_paint_offset(&self, selrect: &Rect) -> Point { match self.layout_paint_origin { Some(origin) => Point::new( self.selrect_origin_x(selrect) - origin.x, selrect.y() - origin.y, ), None => Point::new(0.0, 0.0), } } /// [`layout_origin_x`] for this content's own measured width. fn selrect_origin_x(&self, selrect: &Rect) -> f32 { self.layout_origin_x(selrect, self.size.width) } /// Text content for paint when [`Rect`] size may differ from stored bounds /// (e.g. modifier transform). Reuses `self` when width/height match; otherwise /// clones paragraphs into a rebound copy with an empty layout cache. pub fn paint_content_for_selrect<'a>(&'a self, selrect: Rect) -> Cow<'a, Self> { let stored_bounds = self.bounds(); if (stored_bounds.width() - selrect.width()).abs() < 0.01 && (stored_bounds.height() - selrect.height()).abs() < 0.01 { Cow::Borrowed(self) } else { Cow::Owned(self.new_bounds(selrect)) } } pub fn set_xywh(&mut self, x: f32, y: f32, w: f32, h: f32) { self.bounds = Rect::from_xywh(x, y, w, h); } /// Distinct font families referenced by this content's spans, in first-seen /// order. pub fn font_families(&self) -> Vec { let mut seen: Vec = Vec::new(); for paragraph in &self.paragraphs { for span in paragraph.children() { if !seen.contains(&span.font_family) { seen.push(span.font_family); } } } seen } pub fn add_paragraph(&mut self, mut paragraph: Paragraph) { let index = self.paragraphs.len() as u32; paragraph.set_span_positions(index); self.paragraphs.push(paragraph); self.content_version = self.content_version.wrapping_add(1); } pub fn reset_span_positions(&mut self) { for (index, paragraph) in self.paragraphs.iter_mut().enumerate() { paragraph.set_span_positions(index as u32); } } pub fn paragraphs(&self) -> &[Paragraph] { &self.paragraphs } pub fn has_non_ascii(&self) -> bool { self.paragraphs .iter() .flat_map(|p| p.children()) .any(|span| !span.text.is_ascii()) } pub fn paragraphs_mut(&mut self) -> &mut Vec { self.content_version = self.content_version.wrapping_add(1); &mut self.paragraphs } pub fn width(&self) -> f32 { self.size.width } pub fn normalized_line_height(&self) -> f32 { self.size.normalized_line_height } pub fn grow_type(&self) -> GrowType { self.grow_type } pub fn set_grow_type(&mut self, grow_type: GrowType) { if self.grow_type != grow_type { self.grow_type = grow_type; self.content_version = self.content_version.wrapping_add(1); } } /// Non-empty and every paragraph RTL. Mixed content counts as LTR, mirroring /// `app.common.types.text/rtl-content?` on the ClojureScript side. pub fn is_rtl(&self) -> bool { !self.paragraphs.is_empty() && self .paragraphs .iter() .all(|paragraph| paragraph.text_direction() == TextDirection::RTL) } /// Left edge of the laid-out text of `width` inside `selrect`. RTL auto-width /// text anchors right so the box extends leftward as it grows. pub fn layout_origin_x(&self, selrect: &Rect, width: f32) -> f32 { if self.grow_type() == GrowType::AutoWidth && self.is_rtl() { selrect.right() - width } else { selrect.x() } } /// Compute a tight text rect from laid-out Skia paragraphs using glyph /// metrics (fm.top for overshoot, line descent for bottom, line left/width /// for horizontal extent). fn rect_from_paragraphs(&self, selrect: &Rect, valign: VerticalAlign) -> Option { let paragraphs = &self.layout.paragraphs; let x = selrect.x(); let base_y = selrect.y(); let total_height: f32 = paragraphs .iter() .filter_map(|group| group.first()) .map(|p| p.height()) .sum(); let vertical_offset = vertical_align_offset(selrect.height(), total_height, valign); let mut min_x = f32::MAX; let mut min_y = f32::MAX; let mut max_x = f32::MIN; let mut max_y = f32::MIN; let mut has_lines = false; let mut y_accum = base_y + vertical_offset; for group in paragraphs.iter() { if let Some(paragraph) = group.first() { let line_metrics = paragraph.get_line_metrics(); for line in &line_metrics { let line_baseline = y_accum + line.baseline as f32; // Use per-glyph fm.top for tighter vertical bounds when // available; fall back to line-level ascent for empty lines // (where get_style_metrics returns nothing). let style_metrics = line.get_style_metrics(line.start_index..line.end_index); if style_metrics.is_empty() { min_y = min_y.min(line_baseline - line.ascent as f32); } else { for (_start, style_metric) in &style_metrics { let fm = &style_metric.font_metrics; min_y = min_y.min(line_baseline + fm.top); } } // Bottom uses line-level descent (includes descender space // for the whole line, not just present glyphs). max_y = max_y.max(line_baseline + line.descent as f32); min_x = min_x.min(x + line.left as f32); max_x = max_x.max(x + line.left as f32 + line.width as f32); has_lines = true; } y_accum += paragraph.height(); } } if has_lines { Some(Rect::from_ltrb(min_x, min_y, max_x, max_y)) } else { None } } fn compute_and_cache_extrect( &self, shape: &Shape, selrect: &Rect, valign: VerticalAlign, ) -> Rect { // AutoWidth paragraphs are laid out with f32::MAX, so line metrics // (line.left) reflect alignment within that huge width and are // unusable for tight bounds. Fall back to content_rect. if self.grow_type() == GrowType::AutoWidth { return self.content_rect(selrect, valign); } let layout_matches_container = self .layout_width .is_some_and(|w| w.ceil() == self.get_width(selrect.width()).ceil()); let tight = if !self.layout.paragraphs.is_empty() && layout_matches_container { self.rect_from_paragraphs(selrect, valign) } else { let mut text_content = self.clone(); text_content.update_layout(shape.selrect); text_content.rect_from_paragraphs(selrect, valign) } .unwrap_or_else(|| self.content_rect(selrect, valign)); // Cache as offsets from selrect origin so it's position-independent. let sx = selrect.x(); let sy = selrect.y(); self.layout.cached_extrect.set(Some(CachedExtrect { selrect_width: selrect.width(), selrect_height: selrect.height(), valign: valign as u8, left: tight.left() - sx, top: tight.top() - sy, right: tight.right() - sx, bottom: tight.bottom() - sy, })); tight } pub fn calculate_bounds(&self, shape: &Shape, apply_transform: bool) -> Bounds { let transform = &shape.transform; let center = &shape.center(); let selrect = shape.selrect(); let valign = shape.vertical_align(); let sw = selrect.width(); let sh = selrect.height(); let sx = selrect.x(); let sy = selrect.y(); // Try the cache first: if dimensions and valign match, just apply position offset. let text_rect = if let Some(cached) = self.layout.cached_extrect.get() { if (cached.selrect_width - sw).abs() < 0.1 && (cached.selrect_height - sh).abs() < 0.1 && cached.valign == valign as u8 { Rect::from_ltrb( sx + cached.left, sy + cached.top, sx + cached.right, sy + cached.bottom, ) } else { self.compute_and_cache_extrect(shape, &selrect, valign) } } else { self.compute_and_cache_extrect(shape, &selrect, valign) }; let mut bounds = Bounds::new( Point::new(text_rect.x(), text_rect.y()), Point::new(text_rect.x() + text_rect.width(), text_rect.y()), Point::new( text_rect.x() + text_rect.width(), text_rect.y() + text_rect.height(), ), Point::new(text_rect.x(), text_rect.y() + text_rect.height()), ); if apply_transform && !transform.is_identity() { let mut matrix = *transform; matrix.post_translate(*center); matrix.pre_translate(-*center); bounds.transform_mut(&matrix); } bounds } pub fn content_rect(&self, selrect: &Rect, valign: VerticalAlign) -> Rect { let mut y = selrect.y(); let width = if self.grow_type() == GrowType::AutoWidth { self.size.width } else { selrect.width() }; let x = self.layout_origin_x(selrect, width); let height = if self.size.width.round() != width.round() { self.get_height(width) } else { self.size.height }; let offset_y = vertical_align_offset(selrect.height(), height, valign); y += offset_y; Rect::from_xywh(x, y, width, height) } pub fn transform(&mut self, transform: &Matrix) { let left = self.bounds.left(); let right = self.bounds.right(); let top = self.bounds.top(); let bottom = self.bounds.bottom(); let p1 = transform.map_point(skia::Point::new(left, top)); let p2 = transform.map_point(skia::Point::new(right, bottom)); self.bounds = Rect::from_ltrb(p1.x, p1.y, p2.x, p2.y); } pub fn get_caret_position_from_shape_coords( &self, point: &Point, ) -> Option { let mut offset_y = 0.0; let layout_paragraphs = self.layout.paragraphs.iter().flatten(); for (paragraph_index, layout_paragraph) in layout_paragraphs.enumerate() { let start_y = offset_y; let end_y = offset_y + layout_paragraph.height(); // We only test against paragraphs that can contain the current y // coordinate. Use >= for start and handle zero-height paragraphs. let paragraph_height = layout_paragraph.height(); let matches = if paragraph_height > 0.0 { point.y >= start_y && point.y < end_y } else { // For zero-height paragraphs (empty lines), match if we're at the start position point.y >= start_y && point.y <= start_y + 1.0 }; if matches { // Skia's get_glyph_position_at_coordinate expects coordinates relative to // the paragraph's top-left. For multi-paragraph or wrapped text, each // paragraph has its own origin; subtract start_y so we pass paragraph-local coords. let para_pt = Point::new(point.x, point.y - start_y); let position_with_affinity = layout_paragraph.get_glyph_position_at_coordinate((para_pt.x, para_pt.y)); if let Some(paragraph) = self.paragraphs().get(paragraph_index) { // Skia reports UTF-16 code units, the model counts characters. let offset = paragraph.utf16_offset_to_char(position_with_affinity.position as usize); return Some(TextPositionWithAffinity::new( position_with_affinity, paragraph_index, offset, )); } } offset_y += layout_paragraph.height(); } // Handle completely empty text shapes: if there are no paragraphs or all paragraphs // are empty, and the click is within the text shape bounds, return a default position if (self.paragraphs().is_empty() || self.layout.paragraphs.is_empty()) && self.bounds.contains(*point) { // Create a default position at the start of the text use skia_safe::textlayout::Affinity; let default_position = PositionWithAffinity { position: 0, affinity: Affinity::Downstream, }; return Some(TextPositionWithAffinity::new( default_position, 0, // paragraph 0 0, // offset 0 )); } None } pub fn get_caret_position_from_screen_coords( &self, point: &Point, view_matrix: &Matrix, shape_matrix: &Matrix, ) -> Option { let shape_rel_point = Shape::get_relative_point(point, view_matrix, shape_matrix)?; self.get_caret_position_from_shape_coords(&shape_rel_point) } /// Builds the ParagraphBuilders necessary to render /// this text. pub fn paragraph_builder_group_from_text( &self, use_shadow: Option, ) -> Vec { self.paragraph_builders(use_shadow, false, None, None, None, None) } /// Creates paragraph builders with always-opaque paint (BLACK @ alpha 255). /// Used as a clip mask for inner stroke rendering. pub fn paragraph_builder_group_opaque(&self) -> Vec { self.paragraph_builders(None, true, None, None, None, None) } /// Maximum number of stacked fills across every span in this text block. pub fn max_fill_layers(&self) -> usize { self.paragraphs() .iter() .flat_map(|p| p.children()) .map(|s| s.fills.len()) .max() .unwrap_or(0) } /// Builds paragraph builders that paint a single fill layer per span for SVG /// export. `layer_from_bottom` is 0 for the bottommost fill (fills[last]). pub fn paragraph_builder_group_for_fill_layer( &self, layer_from_bottom: usize, ) -> Vec { self.paragraph_builders(None, false, None, Some(layer_from_bottom), None, None) } /// Like [`paragraph_builder_group_for_fill_layer`], but spans whose fill at /// this layer is an image in `skip_image_ids` get transparent paint (those /// fills are re-emitted as linked SVG `` elements). pub fn paragraph_builder_group_for_fill_layer_skipping_images( &self, layer_from_bottom: usize, skip_image_ids: &HashSet, ) -> Vec { self.paragraph_builders( None, false, None, Some(layer_from_bottom), None, Some(skip_image_ids), ) } /// Opaque black glyphs only for spans whose fill at `layer_from_bottom` is /// the given image — used as an SVG `` for linked image fills. pub fn paragraph_builder_group_opaque_for_image_layer( &self, layer_from_bottom: usize, image_id: Uuid, ) -> Vec { self.paragraph_builders( None, false, None, None, Some((layer_from_bottom, image_id)), None, ) } fn paragraph_builders( &self, use_shadow: Option, opaque: bool, align_override: Option, fill_layer: Option, opaque_image_layer: Option<(usize, Uuid)>, skip_image_ids: Option<&HashSet>, ) -> Vec { let fonts = get_font_collection(); let fallback_fonts = get_fallback_fonts(); let mut paragraph_group = Vec::new(); for paragraph in self.paragraphs() { let mut paragraph_style = paragraph.paragraph_to_style(); if let Some(align) = align_override { paragraph_style.set_text_align(align); } let mut builder = ParagraphBuilder::new(¶graph_style, fonts); let mut has_text = false; for span in paragraph.children() { let text_style = if let Some((layer, image_id)) = opaque_image_layer { let mut style = span.to_style( &self.bounds(), fallback_fonts, false, paragraph.line_height(), ); let mut paint = paint::Paint::default(); match span.fills_from_bottom(layer) { Some(shapes::Fill::Image(img)) if img.id() == image_id => { paint.set_color(skia::Color::BLACK); paint.set_alpha(255); } _ => { paint.set_color(skia::Color::TRANSPARENT); } } style.set_foreground_paint(&paint); style } else if let (Some(layer), Some(skip)) = (fill_layer, skip_image_ids) { let skip_span = matches!( span.fills_from_bottom(layer), Some(shapes::Fill::Image(img)) if skip.contains(&img.id()) ); if skip_span { let mut style = span.to_style( &self.bounds(), fallback_fonts, false, paragraph.line_height(), ); let mut paint = paint::Paint::default(); paint.set_color(skia::Color::TRANSPARENT); style.set_foreground_paint(&paint); style } else { let remove_alpha = opaque || (use_shadow.unwrap_or(false) && !span.is_transparent()); span.to_style_with_paint( &self.bounds(), fallback_fonts, remove_alpha, paragraph.line_height(), fill_layer, ) } } else { let remove_alpha = opaque || (use_shadow.unwrap_or(false) && !span.is_transparent()); span.to_style_with_paint( &self.bounds(), fallback_fonts, remove_alpha, paragraph.line_height(), fill_layer, ) }; let text: String = span.apply_text_transform(); if !text.is_empty() { has_text = true; } builder.push_style(&text_style); add_text_with_tabs(&mut builder, &text, span.font_size); } if !has_text { builder.add_text(" "); } paragraph_group.push(vec![builder]); } paragraph_group } /// Performs an Auto Width text layout. fn text_layout_auto_width(&self) -> TextContentLayoutResult { // Left-aligned MAX-width pass: longest_line() is glyph width, not the huge container. let mut measure_builders = self.paragraph_builders( None, false, Some(skia::textlayout::TextAlign::Left), None, None, None, ); let normalized_line_height = calculate_normalized_line_height(&mut measure_builders, f32::MAX); let measure_paragraphs = build_paragraphs_from_paragraph_builders(&mut measure_builders, f32::MAX); let content_width = measure_paragraphs .iter() .flatten() .fold(0.0_f32, |auto_width, paragraph| { f32::max(paragraph.longest_line(), auto_width) }) .ceil(); // Re-layout at the intrinsic width (without the HTML margin slack). let mut paragraph_builders = self.paragraph_builder_group_from_text(None); let paragraphs = build_paragraphs_from_paragraph_builders(&mut paragraph_builders, content_width); let height = paragraphs .iter() .flatten() .fold(0.0_f32, |auto_height, paragraph| { auto_height + paragraph.height() }); let reported_width = content_width + PARAGRAPH_SET_MARGIN_RIGHT; let size = TextContentSize::new_with_normalized_line_height( reported_width, height.ceil(), reported_width, normalized_line_height, ); TextContentLayoutResult(paragraph_builders, paragraphs, size) } /// Private function that performs /// Performs an Auto Height text layout. fn text_layout_auto_height(&self) -> TextContentLayoutResult { let width = self.width(); let mut paragraph_builders = self.paragraph_builder_group_from_text(None); let normalized_line_height = calculate_normalized_line_height(&mut paragraph_builders, width); let paragraphs = build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width); let height = paragraphs .iter() .flatten() .fold(0.0, |auto_height, paragraph| { auto_height + paragraph.height() }); let size = TextContentSize::new_with_normalized_line_height( width, height.ceil(), DEFAULT_TEXT_CONTENT_SIZE, normalized_line_height, ); TextContentLayoutResult(paragraph_builders, paragraphs, size) } /// Performs a Fixed text layout. fn text_layout_fixed(&self) -> TextContentLayoutResult { let width = self.width(); let mut paragraph_builders = self.paragraph_builder_group_from_text(None); let normalized_line_height = calculate_normalized_line_height(&mut paragraph_builders, width); let paragraphs = build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width); let paragraph_height = paragraphs .iter() .flatten() .fold(0.0, |auto_height, paragraph| { auto_height + paragraph.height() }); let size = TextContentSize::new_with_normalized_line_height( width.ceil(), paragraph_height.ceil(), DEFAULT_TEXT_CONTENT_SIZE, normalized_line_height, ); TextContentLayoutResult(paragraph_builders, paragraphs, size) } pub fn get_width(&self, width: f32) -> f32 { if self.grow_type() == GrowType::AutoWidth { self.size.width } else { width } } pub fn get_height(&self, width: f32) -> f32 { let mut paragraph_builders = self.paragraph_builder_group_from_text(None); let paragraphs = build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width); let paragraph_height = paragraphs .iter() .flatten() .fold(0.0, |auto_height, paragraph| { auto_height + paragraph.height() }); paragraph_height } pub fn needs_update_layout(&self) -> bool { self.layout.needs_update() } /// True when cached Skia paragraphs can be painted as-is (no rebuild/layout). pub fn has_usable_paint_layout(&self, shape: &Shape) -> bool { if self.layout.needs_update() || self.layout_version != self.content_version { return false; } self.layout_matches_paint_container(shape) } pub(crate) fn layout_cache_versions_match(&self) -> bool { !self.layout.needs_update() && self.layout_version == self.content_version } pub(crate) fn layout_matches_paint_container(&self, shape: &Shape) -> bool { if self.grow_type() == GrowType::AutoWidth { return true; } let Some(layout_w) = self.layout_width else { return false; }; let container_w = self.get_width(shape.selrect().width()); (layout_w - container_w).abs() < f32::EPSILON } /// True when any span requests underline/overline/line-through (custom draw path). pub fn has_text_decorations(&self) -> bool { self.paragraphs().iter().any(|paragraph| { paragraph.children().iter().any(|span| { matches!( span.text_decoration, Some(d) if d != skia::textlayout::TextDecoration::NO_DECORATION ) }) }) } pub fn set_layout_from_result( &mut self, result: TextContentLayoutResult, default_width: f32, default_height: f32, ) { self.layout.set(result.0, result.1); self.size .copy_finite_size(result.2, default_width, default_height); // Absolute image/gradient shaders are built against `self.bounds()`, so the // origin matches them and `cached_layout_paint_offset` carries any rtl shift. self.layout_paint_origin = Some(Point::new(self.bounds.x(), self.bounds.y())); } pub fn force_next_layout_update(&mut self) { self.layout_width = None; self.layout_paint_origin = None; self.layout.cached_extrect.set(None); // Bump the content version so update_layout can't early-return: auto-width // shapes always match their container and clearing the cache above doesn't // flip needs_update(), so a late font resolution would otherwise be skipped. self.content_version = self.content_version.wrapping_add(1); } pub fn update_layout(&mut self, selrect: Rect) -> TextContentSize { // Keep bounds in sync before building paints so absolute fill shaders // match the container we are laying out for. self.set_xywh(selrect.x(), selrect.y(), selrect.width(), selrect.height()); // Auto-width ignores selrect width so get-text-dimensions can reuse the cached layout. let layout_matches_container = self.grow_type() == GrowType::AutoWidth || self .layout_width .is_some_and(|w| (w - selrect.width()).abs() < f32::EPSILON); if !self.layout.needs_update() && self.layout_version == self.content_version && layout_matches_container { return self.size; } self.size.set_size(selrect.width(), selrect.height()); match self.grow_type() { GrowType::AutoHeight => { let result = self.text_layout_auto_height(); self.layout_width = Some(result.2.width); self.set_layout_from_result(result, selrect.width(), selrect.height()); } GrowType::AutoWidth => { let result = self.text_layout_auto_width(); self.layout_width = Some(result.2.width); self.set_layout_from_result(result, selrect.width(), selrect.height()); } GrowType::Fixed => { let result = self.text_layout_fixed(); self.layout_width = Some(result.2.width); self.set_layout_from_result(result, selrect.width(), selrect.height()); } } if self.is_empty() { let (placeholder_width, placeholder_height) = self.placeholder_dimensions(selrect); self.size.width = placeholder_width; self.size.height = placeholder_height; self.size.max_width = placeholder_width; } self.layout_version = self.content_version; self.size } /// Return true when the content represents a freshly created empty text. /// We consider it empty only if there is exactly one paragraph with a single /// span whose text buffer is empty. Any additional paragraphs or characters /// mean the user has already entered content. fn is_empty(&self) -> bool { if self.paragraphs.len() != 1 { return false; } let paragraph = match self.paragraphs.first() { Some(paragraph) => paragraph, None => return true, }; if paragraph.children().len() != 1 { return false; } let span = match paragraph.children().first() { Some(span) => span, None => return true, }; span.text.is_empty() } /// Compute the placeholder size used while the text is still empty. We ask /// Skia to measure a single glyph using the span's typography so the editor /// shows a caret-sized box that reflects the selected font, size and spacing. /// If that fails we fall back to the previous WASM size or the incoming /// selrect dimensions. fn placeholder_dimensions(&self, selrect: Rect) -> (f32, f32) { if let Some(paragraph) = self.paragraphs.first() { if let Some(span) = paragraph.children().first() { let fonts = get_font_collection(); let fallback_fonts = get_fallback_fonts(); let paragraph_style = paragraph.paragraph_to_style(); let mut builder = ParagraphBuilder::new(¶graph_style, fonts); let text_style = span.to_style( &self.bounds(), fallback_fonts, false, paragraph.line_height(), ); builder.push_style(&text_style); builder.add_text("0"); let mut paragraph_layout = builder.build(); paragraph_layout.layout(f32::MAX); let width = paragraph_layout.max_intrinsic_width(); let height = paragraph_layout.height(); return (width, height); } } let fallback_width = selrect.width().max(self.size.width); let fallback_height = selrect.height().max(self.size.height); (fallback_width, fallback_height) } #[allow(dead_code)] pub fn intersect_position_in_shape(&self, shape: &Shape, x_pos: f32, y_pos: f32) -> bool { let rect = shape.selrect; let mut matrix = Matrix::new_identity(); let center = shape.center(); let Some(inv_transform) = &shape.transform.invert() else { return false; }; matrix.pre_translate(center); matrix.pre_concat(inv_transform); matrix.pre_translate(-center); let result = matrix.map_point((x_pos, y_pos)); let x_pos = result.x; let y_pos = result.y; x_pos >= rect.x() && x_pos <= rect.right() && y_pos >= rect.y() && y_pos <= rect.bottom() } pub fn intersect_position_in_text(&self, shape: &Shape, x_pos: f32, y_pos: f32) -> bool { let rect = self.content_rect(&shape.selrect, shape.vertical_align); let mut matrix = Matrix::new_identity(); let center = shape.center(); let Some(inv_transform) = &shape.transform.invert() else { return false; }; matrix.pre_translate(center); matrix.pre_concat(inv_transform); matrix.pre_translate(-center); let result = matrix.map_point((x_pos, y_pos)); // Change coords to content space let x_pos = result.x - rect.x(); let y_pos = result.y - rect.y(); if !self.layout.paragraphs.is_empty() { // Reuse stored laid-out paragraphs paragraph_intersects( self.layout .paragraphs .iter() .flat_map(|group| group.first()), x_pos, y_pos, ) } else { let width = self.width(); let mut paragraph_builders = self.paragraph_builder_group_from_text(None); let paragraphs = build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width); paragraph_intersects(paragraphs.iter().flatten(), x_pos, y_pos) } } } impl Default for TextContent { fn default() -> Self { Self { paragraphs: vec![], bounds: Rect::default(), grow_type: GrowType::Fixed, size: TextContentSize::default(), layout: TextContentLayout::new(), content_version: 0, layout_version: 0, layout_width: None, layout_paint_origin: None, } } } pub type TextAlign = skia::textlayout::TextAlign; pub type TextDirection = skia::textlayout::TextDirection; pub type TextDecoration = skia::textlayout::TextDecoration; #[derive(Debug, PartialEq, Clone, Copy)] pub enum TextTransform { Lowercase, Uppercase, Capitalize, } // FIXME: Rethink this type. We'll probably need to move the serialization to the // wasm module and store here meaningful model values (and/or skia type aliases) #[derive(Debug, PartialEq, Clone)] pub struct Paragraph { text_align: TextAlign, text_direction: TextDirection, text_decoration: Option, text_transform: Option, line_height: f32, letter_spacing: f32, children: Vec, } impl Default for Paragraph { fn default() -> Self { Self { text_align: TextAlign::default(), text_direction: TextDirection::LTR, text_decoration: None, text_transform: None, line_height: 1.0, letter_spacing: 0.0, children: vec![], } } } impl Paragraph { #[allow(clippy::too_many_arguments)] pub fn new( text_align: TextAlign, text_direction: TextDirection, text_decoration: Option, text_transform: Option, line_height: f32, letter_spacing: f32, children: Vec, ) -> Self { Self { text_align, text_direction, text_decoration, text_transform, line_height, letter_spacing, children, } } pub fn children(&self) -> &[TextSpan] { &self.children } pub fn children_mut(&mut self) -> &mut Vec { &mut self.children } fn set_span_positions(&mut self, index: u32) { for (span_index, span) in self.children.iter_mut().enumerate() { span.set_position(index, span_index as u32); } } fn char_count(&self) -> usize { self.children .iter() .map(|span| span.text.chars().count()) .sum() } /// Translate a character offset into the UTF-16 offset Skia indexes by. /// Both differ on astral-plane characters (emoji) and whenever a text /// transform changes the length of the laid out text (`ß` -> `SS`). pub fn char_offset_to_utf16(&self, char_offset: usize) -> usize { let mut remaining = char_offset; let mut utf16 = 0; for span in &self.children { if remaining == 0 { break; } let span_len = span.text.chars().count(); let take = remaining.min(span_len); let prefix: String = span.text.chars().take(take).collect(); utf16 += span.transform_text(&prefix).encode_utf16().count(); remaining -= take; } utf16 } /// Translate a UTF-16 offset coming from Skia into a character offset. /// An offset inside a character rounds up, so it never splits a glyph. pub fn utf16_offset_to_char(&self, utf16_offset: usize) -> usize { let (mut low, mut high) = (0, self.char_count()); while low < high { let middle = (low + high) / 2; if self.char_offset_to_utf16(middle) < utf16_offset { low = middle + 1; } else { high = middle; } } low } /// UTF-16 length of the character at `char_offset`, so a caret range covers /// the whole glyph. pub fn char_utf16_len_at(&self, char_offset: usize) -> usize { self.char_offset_to_utf16(char_offset + 1) - self.char_offset_to_utf16(char_offset) } pub fn line_height(&self) -> f32 { self.line_height } pub fn letter_spacing(&self) -> f32 { self.letter_spacing } pub fn text_align(&self) -> TextAlign { self.text_align } pub fn text_direction(&self) -> TextDirection { self.text_direction } pub fn text_decoration(&self) -> Option { self.text_decoration } pub fn text_transform(&self) -> Option { self.text_transform } pub fn paragraph_to_style(&self) -> ParagraphStyle { let mut style = ParagraphStyle::default(); style.set_height(self.line_height); style.set_text_align(self.text_align); style.set_text_direction(self.text_direction); style.set_replace_tab_characters(false); style.set_apply_rounding_hack(true); style.set_text_height_behavior(skia::textlayout::TextHeightBehavior::All); style } pub fn scale_content(&mut self, value: f32) { self.letter_spacing *= value; self.children .iter_mut() .for_each(|l| l.scale_content(value)); } } /// Capitalize the first letter of each word, preserving all original whitespace. /// Matches CSS `text-transform: capitalize` behavior: a "word" starts after /// any non-letter character (whitespace, punctuation, digits, symbols). fn capitalize_words(text: &str) -> String { let mut result = String::with_capacity(text.len()); let mut capitalize_next = true; for c in text.chars() { if c.is_alphabetic() { if capitalize_next { result.extend(c.to_uppercase()); } else { result.push(c); } capitalize_next = false; } else { result.push(c); capitalize_next = true; } } result } /// Add `text`, pushing every '\t' as a one em wide placeholder. pub fn add_text_with_tabs(builder: &mut ParagraphBuilder, text: &str, font_size: f32) { let tab = PlaceholderStyle::new( font_size, 0.0, PlaceholderAlignment::Baseline, TextBaseline::Alphabetic, 0.0, ); for (index, segment) in text.split('\t').enumerate() { if index > 0 { builder.add_placeholder(&tab); } builder.add_text(segment); } } /// Filter control characters below U+0020, preserving tabs and line breaks. /// Browser-dependent: Firefox drops them, others replace with space. fn process_ignored_chars(text: &str, browser: u8) -> String { text.chars() .filter_map(|c| { if c == '\t' || c == '\n' || c == '\r' || c == '\u{2028}' || c == '\u{2029}' { return Some(c); } if c < '\u{0020}' { if browser == Browser::Firefox as u8 { None } else { Some(' ') } } else { Some(c) } }) .collect() } #[derive(Debug, PartialEq, Clone)] pub struct TextSpan { pub text: String, pub font_family: FontFamily, pub font_size: f32, pub line_height: f32, pub letter_spacing: f32, pub font_weight: i32, pub font_variant_id: Uuid, pub text_decoration: Option, pub text_transform: Option, pub text_direction: TextDirection, pub fills: Vec, pub paragraph_position: u32, pub span_position: u32, } impl TextSpan { /// Fill at `layer` counting from the bottom (`0` = last / bottommost fill). pub fn fills_from_bottom(&self, layer: usize) -> Option<&shapes::Fill> { if layer < self.fills.len() { Some(&self.fills[self.fills.len() - 1 - layer]) } else { None } } #[allow(clippy::too_many_arguments)] pub fn new( text: String, font_family: FontFamily, font_size: f32, line_height: f32, letter_spacing: f32, text_decoration: Option, text_transform: Option, text_direction: TextDirection, font_weight: i32, font_variant_id: Uuid, fills: Vec, ) -> Self { Self { text, font_family, font_size, line_height, letter_spacing, text_decoration, text_transform, text_direction, font_weight, font_variant_id, fills, paragraph_position: u32::MAX, span_position: u32::MAX, } } pub fn set_text(&mut self, text: String) { self.text = text; } pub fn set_position(&mut self, paragraph: u32, span: u32) { self.paragraph_position = paragraph; self.span_position = span; } pub fn to_style( &self, content_bounds: &Rect, fallback_fonts: &HashSet, remove_alpha: bool, paragraph_line_height: f32, ) -> skia::textlayout::TextStyle { self.to_style_with_paint( content_bounds, fallback_fonts, remove_alpha, paragraph_line_height, None, ) } fn to_style_with_paint( &self, content_bounds: &Rect, fallback_fonts: &HashSet, remove_alpha: bool, paragraph_line_height: f32, fill_layer_from_bottom: Option, ) -> skia::textlayout::TextStyle { let mut style = skia::textlayout::TextStyle::default(); let paint = if remove_alpha { let mut paint = paint::Paint::default(); paint.set_color(skia::Color::BLACK); paint.set_alpha(255); paint } else if let Some(layer) = fill_layer_from_bottom { if layer < self.fills.len() { let fill_idx = self.fills.len() - 1 - layer; self.fills[fill_idx].to_paint(content_bounds, true) } else { let mut paint = paint::Paint::default(); paint.set_color(skia::Color::TRANSPARENT); paint } } else { merge_fills(&self.fills, *content_bounds) }; let max_line_height = f32::max(paragraph_line_height, self.line_height); style.set_height(max_line_height); style.set_height_override(true); style.set_foreground_paint(&paint); style.set_decoration_type(match self.text_decoration { Some(text_decoration) => text_decoration, None => skia::textlayout::TextDecoration::NO_DECORATION, }); // Trick to avoid showing the text decoration style.set_decoration_thickness_multiplier(0.0); let mut font_families = vec![ self.serialized_font_family(), default_font(), DEFAULT_EMOJI_FONT.to_string(), ]; font_families.extend(fallback_fonts.iter().cloned()); style.set_font_families(&font_families); style.set_font_size(self.font_size); style.set_letter_spacing(self.letter_spacing); style.set_half_leading(true); style } pub fn to_stroke_style( &self, stroke_paint: &Paint, fallback_fonts: &HashSet, remove_alpha: bool, paragraph_line_height: f32, ) -> skia::textlayout::TextStyle { let mut style = self.to_style( &Rect::default(), fallback_fonts, remove_alpha, paragraph_line_height, ); if remove_alpha { let mut paint = skia::Paint::default(); paint.set_style(stroke_paint.style()); paint.set_stroke_width(stroke_paint.stroke_width()); paint.set_color(skia::Color::BLACK); paint.set_alpha(255); style.set_foreground_paint(&paint); } else { style.set_foreground_paint(stroke_paint); } style.set_font_size(self.font_size); style.set_letter_spacing(self.letter_spacing); style.set_decoration_type(match self.text_decoration { Some(text_decoration) => text_decoration, None => skia::textlayout::TextDecoration::NO_DECORATION, }); style } fn serialized_font_family(&self) -> String { format!("{}", self.font_family) } pub fn transform_text(&self, text: &str) -> String { let text = process_ignored_chars(text, crate::globals::current_browser()); match self.text_transform { Some(TextTransform::Uppercase) => text.to_uppercase(), Some(TextTransform::Lowercase) => text.to_lowercase(), Some(TextTransform::Capitalize) => capitalize_words(&text), None => text, } } pub fn apply_text_transform(&self) -> String { self.transform_text(&self.text) } pub fn scale_content(&mut self, value: f32) { self.font_size *= value; } pub fn is_transparent(&self) -> bool { self.fills.iter().all(|fill| match fill { shapes::Fill::Solid(shapes::SolidColor(color)) => color.a() == 0, _ => false, }) } } #[derive(Debug, Copy, Clone)] pub struct PositionData { pub paragraph: u32, pub span: u32, pub start_pos: u32, pub end_pos: u32, pub x: f32, pub y: f32, pub width: f32, pub height: f32, pub direction: u32, } #[derive(Debug)] pub struct ParagraphLayout { pub paragraph: skia::textlayout::Paragraph, pub x: f32, pub y: f32, pub decorations: Vec, } #[derive(Debug)] pub struct TextLayoutData { pub position_data: Vec, pub paragraphs: Vec, } fn direction_to_int(direction: TextDirection) -> u32 { match direction { TextDirection::RTL => 0, TextDirection::LTR => 1, } } pub fn calculate_text_layout_data( shape: &Shape, text_content: &TextContent, paragraph_builder_groups: &mut [ParagraphBuilderGroup], skip_position_data: bool, ) -> TextLayoutData { let selrect_width = shape.selrect().width(); let text_width = text_content.get_width(selrect_width); let selrect_height = shape.selrect().height(); let x = text_content.layout_origin_x(&shape.selrect, text_width); let base_y = shape.selrect.y(); let mut position_data: Vec = Vec::new(); let mut previous_line_height = text_content.normalized_line_height(); let text_paragraphs = text_content.paragraphs(); // 1. Build + layout each paragraph once, recording heights as we go. let mut paragraph_heights: Vec = Vec::new(); let mut built_groups: Vec> = Vec::with_capacity(paragraph_builder_groups.len()); for paragraph_builder_group in paragraph_builder_groups.iter_mut() { let group_len = paragraph_builder_group.len(); let mut paragraph_offset_y = previous_line_height; let mut group_paragraphs: Vec = Vec::with_capacity(group_len); for (builder_index, paragraph_builder) in paragraph_builder_group.iter_mut().enumerate() { let mut skia_paragraph = paragraph_builder.build(); skia_paragraph.layout(text_width); if builder_index == group_len - 1 { if skia_paragraph.get_line_metrics().is_empty() { paragraph_offset_y = skia_paragraph.ideographic_baseline(); } else { paragraph_offset_y = skia_paragraph.height(); } } if builder_index == 0 { paragraph_heights.push(skia_paragraph.height()); } group_paragraphs.push(skia_paragraph); } previous_line_height = paragraph_offset_y; built_groups.push(group_paragraphs); } // 2. Position each built paragraph using the heights from step 1. let total_text_height: f32 = paragraph_heights.iter().sum(); let vertical_offset = vertical_align_offset(selrect_height, total_text_height, shape.vertical_align()); let mut paragraph_layouts: Vec = Vec::new(); let mut y_accum = base_y + vertical_offset; for (i, group_paragraphs) in built_groups.into_iter().enumerate() { // For each paragraph in the group (e.g., fill, stroke, etc.) for skia_paragraph in group_paragraphs.into_iter() { let decorations = text_paragraphs .get(i) .map(|text_paragraph| { decoration_segments(&skia_paragraph, text_paragraph, x, y_accum) }) .unwrap_or_default(); paragraph_layouts.push(ParagraphLayout { paragraph: skia_paragraph, x, y: y_accum, decorations, }); } y_accum += paragraph_heights[i]; } // Calculate position data from paragraph_layouts if !skip_position_data { for (paragraph_index, para_layout) in paragraph_layouts.iter().enumerate() { let current_y = para_layout.y; let text_paragraph = text_paragraphs.get(paragraph_index); if let Some(text_para) = text_paragraph { let mut span_ranges: Vec<(usize, usize, usize)> = vec![]; let mut cur = 0; for (span_index, span) in text_para.children().iter().enumerate() { let text: String = span.apply_text_transform(); let text_len = text.encode_utf16().count(); span_ranges.push((cur, cur + text_len, span_index)); cur += text_len; } for (start, end, span_index) in span_ranges { let rects = para_layout.paragraph.get_rects_for_range( start..end, RectHeightStyle::Tight, RectWidthStyle::Tight, ); for textbox in rects { let direction = textbox.direct; let mut rect = textbox.rect; let cy = rect.top + rect.height() / 2.0; // Get byte positions from Skia's transformed text layout let start_pos = para_layout .paragraph .get_glyph_position_at_coordinate((rect.left + 0.1, cy)) .position as usize - start; let end_pos = para_layout .paragraph .get_glyph_position_at_coordinate((rect.right - 0.1, cy)) .position as usize - start; rect.offset((x, current_y)); position_data.push(PositionData { paragraph: paragraph_index as u32, span: span_index as u32, start_pos: start_pos as u32, end_pos: end_pos as u32, x: rect.x(), y: rect.y(), width: rect.width(), height: rect.height(), direction: direction_to_int(direction), }); } } } } } TextLayoutData { position_data, paragraphs: paragraph_layouts, } } pub fn calculate_position_data( shape: &Shape, text_content: &TextContent, skip_position_data: bool, ) -> Vec { let mut text_content = text_content.clone(); text_content.update_layout(shape.selrect); let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None); let layout_info = calculate_text_layout_data( shape, &text_content, &mut paragraph_builders, skip_position_data, ); layout_info.position_data } #[cfg(test)] mod tests { use super::*; #[test] fn vertical_align_top_keeps_the_content_at_the_origin() { assert_eq!(vertical_align_offset(200.0, 60.0, VerticalAlign::Top), 0.0); } #[test] fn vertical_align_center_takes_half_the_slack() { assert_eq!( vertical_align_offset(200.0, 60.0, VerticalAlign::Center), 70.0 ); } #[test] fn vertical_align_bottom_takes_all_the_slack() { assert_eq!( vertical_align_offset(200.0, 60.0, VerticalAlign::Bottom), 140.0 ); } #[test] fn vertical_align_offset_is_negative_when_content_overflows() { assert_eq!( vertical_align_offset(60.0, 200.0, VerticalAlign::Center), -70.0 ); assert_eq!( vertical_align_offset(60.0, 200.0, VerticalAlign::Bottom), -140.0 ); } #[test] fn capitalize_basic_words() { assert_eq!(capitalize_words("hello world"), "Hello World"); } #[test] fn capitalize_preserves_leading_whitespace() { assert_eq!(capitalize_words(" hello"), " Hello"); } #[test] fn capitalize_preserves_trailing_whitespace() { assert_eq!(capitalize_words("hello "), "Hello "); } #[test] fn capitalize_preserves_multiple_spaces() { assert_eq!(capitalize_words("hello world"), "Hello World"); } #[test] fn capitalize_whitespace_only() { assert_eq!(capitalize_words(" "), " "); assert_eq!(capitalize_words(" "), " "); } #[test] fn capitalize_empty_string() { assert_eq!(capitalize_words(""), ""); } #[test] fn capitalize_single_char() { assert_eq!(capitalize_words("a"), "A"); } #[test] fn capitalize_already_uppercase() { assert_eq!(capitalize_words("HELLO WORLD"), "HELLO WORLD"); } #[test] fn capitalize_preserves_tabs_and_newlines() { assert_eq!(capitalize_words("hello\tworld"), "Hello\tWorld"); assert_eq!(capitalize_words("hello\nworld"), "Hello\nWorld"); } #[test] fn capitalize_after_punctuation() { assert_eq!(capitalize_words("(readonly)"), "(Readonly)"); assert_eq!(capitalize_words("hello-world"), "Hello-World"); assert_eq!(capitalize_words("one/two/three"), "One/Two/Three"); } #[test] fn capitalize_after_digits() { assert_eq!(capitalize_words("item1name"), "Item1Name"); } #[test] fn process_ignored_chars_preserves_spaces() { assert_eq!(process_ignored_chars("hello world", 0), "hello world"); } #[test] fn process_ignored_chars_preserves_line_breaks() { assert_eq!(process_ignored_chars("hello\nworld", 0), "hello\nworld"); assert_eq!(process_ignored_chars("hello\rworld", 0), "hello\rworld"); } #[test] fn process_ignored_chars_preserves_tabs() { assert_eq!(process_ignored_chars("hello\tworld", 0), "hello\tworld"); assert_eq!( process_ignored_chars("hello\tworld", Browser::Firefox as u8), "hello\tworld" ); } #[test] fn process_ignored_chars_replaces_control_chars_chrome() { // U+0001 (SOH) should become space in non-Firefox assert_eq!( process_ignored_chars("a\x01b", Browser::Chrome as u8), "a b" ); } #[test] fn process_ignored_chars_removes_control_chars_firefox() { assert_eq!( process_ignored_chars("a\x01b", Browser::Firefox as u8), "ab" ); } fn test_paragraph(texts: &[&str]) -> Paragraph { let spans = texts .iter() .map(|text| { TextSpan::new( text.to_string(), FontFamily::new(Uuid::nil(), 400, crate::shapes::FontStyle::Normal), 14.0, 1.2, 0.0, None, None, TextDirection::LTR, 400, Uuid::nil(), vec![], ) }) .collect(); Paragraph::new( TextAlign::Left, TextDirection::LTR, None, None, 1.2, 0.0, spans, ) } #[test] fn char_offsets_match_utf16_offsets_for_bmp_text() { let para = test_paragraph(&["Añadir"]); for offset in 0..=6 { assert_eq!(para.char_offset_to_utf16(offset), offset); assert_eq!(para.utf16_offset_to_char(offset), offset); } } #[test] fn char_offsets_account_for_astral_characters() { let para = test_paragraph(&["a", "😀b"]); assert_eq!(para.char_offset_to_utf16(0), 0); assert_eq!(para.char_offset_to_utf16(1), 1); assert_eq!(para.char_offset_to_utf16(2), 3); assert_eq!(para.char_offset_to_utf16(3), 4); assert_eq!(para.utf16_offset_to_char(0), 0); assert_eq!(para.utf16_offset_to_char(1), 1); assert_eq!(para.utf16_offset_to_char(3), 2); assert_eq!(para.utf16_offset_to_char(4), 3); } #[test] fn utf16_offset_inside_a_surrogate_pair_rounds_to_a_char_boundary() { let para = test_paragraph(&["a😀b"]); assert_eq!(para.utf16_offset_to_char(2), 2); } #[test] fn char_offsets_account_for_text_transforms() { // Skia lays out the transformed text, where "Straße" is "STRASSE". let mut para = test_paragraph(&["Straße"]); para.children_mut()[0].text_transform = Some(TextTransform::Uppercase); assert_eq!(para.char_offset_to_utf16(4), 4); assert_eq!(para.char_offset_to_utf16(6), 7); assert_eq!(para.char_utf16_len_at(4), 2); assert_eq!(para.utf16_offset_to_char(7), 6); } #[test] fn char_utf16_len_at_covers_the_whole_glyph() { let para = test_paragraph(&["a😀b"]); assert_eq!(para.char_utf16_len_at(0), 1); assert_eq!(para.char_utf16_len_at(1), 2); assert_eq!(para.char_utf16_len_at(2), 1); } fn sample_text_content() -> TextContent { let bounds = Rect::from_xywh(0.0, 0.0, 200.0, 100.0); let mut content = TextContent::new(bounds, GrowType::Fixed); content.add_paragraph(test_paragraph(&["hello"])); content } #[test] fn has_usable_paint_layout_false_when_paragraphs_empty() { let content = TextContent::new(Rect::from_xywh(0.0, 0.0, 100.0, 50.0), GrowType::Fixed); let shape = Shape::new(Uuid::nil()); assert!(!content.has_usable_paint_layout(&shape)); } #[test] fn has_usable_paint_layout_false_when_versions_mismatch() { let mut content = sample_text_content(); content.layout.paragraphs = Rc::new(vec![vec![]]); content.layout_width = Some(200.0); content.layout_version = 1; content.content_version = 2; let mut shape = Shape::new(Uuid::nil()); shape.set_selrect(0.0, 0.0, 200.0, 100.0); assert!(!content.has_usable_paint_layout(&shape)); } #[test] fn has_usable_paint_layout_true_when_cached_and_versions_match() { let mut content = sample_text_content(); content.layout.paragraphs = Rc::new(vec![vec![]]); content.layout_width = Some(200.0); content.layout_version = 3; content.content_version = 3; let mut shape = Shape::new(Uuid::nil()); shape.set_selrect(0.0, 0.0, 200.0, 100.0); assert!(content.has_usable_paint_layout(&shape)); } #[test] fn has_usable_paint_layout_false_when_selrect_width_changed() { let mut content = sample_text_content(); content.layout.paragraphs = Rc::new(vec![vec![]]); content.layout_width = Some(200.0); content.layout_version = 3; content.content_version = 3; let mut shape = Shape::new(Uuid::nil()); shape.set_selrect(0.0, 0.0, 300.0, 100.0); assert!(!content.has_usable_paint_layout(&shape)); } fn text_shape_with_cached_layout(content: TextContent) -> Shape { let mut shape = Shape::new(Uuid::nil()); shape.set_shape_type(shapes::Type::Text(content)); shape.set_selrect(0.0, 0.0, 200.0, 100.0); shape } #[test] fn has_usable_paint_layout_false_when_rotated_and_resized() { let mut content = sample_text_content(); content.layout.paragraphs = Rc::new(vec![vec![]]); content.layout_width = Some(200.0); content.layout_version = 3; content.content_version = 3; let base_shape = text_shape_with_cached_layout(content); let rotate = Matrix::rotate_deg(45.0); let resize = Matrix::scale((1.5, 1.0)); let mut modifier = rotate; modifier.pre_concat(&resize); assert!(modifier_changes_text_layout(&base_shape, &modifier)); } #[test] fn has_text_decorations_detects_underline() { let mut content = sample_text_content(); content.paragraphs_mut()[0].children_mut()[0].text_decoration = Some(skia::textlayout::TextDecoration::UNDERLINE); assert!(content.has_text_decorations()); } #[test] fn has_text_decorations_false_for_plain_text() { let content = sample_text_content(); assert!(!content.has_text_decorations()); } #[test] fn paint_content_for_selrect_borrows_when_bounds_match() { let content = sample_text_content(); let selrect = Rect::from_xywh(10.0, 20.0, 200.0, 100.0); match content.paint_content_for_selrect(selrect) { Cow::Borrowed(_) => {} Cow::Owned(_) => panic!("expected borrowed content"), } } #[test] fn paint_content_for_selrect_rebounds_when_size_differs() { let content = sample_text_content(); let selrect = Rect::from_xywh(0.0, 0.0, 300.0, 100.0); match content.paint_content_for_selrect(selrect) { Cow::Owned(rebound) => { assert_eq!(rebound.bounds().width(), 300.0); assert!(rebound.layout.needs_update()); } Cow::Borrowed(_) => panic!("expected rebound content"), } } #[test] fn layout_paint_origin_set_when_layout_result_applied() { let mut content = sample_text_content(); content.set_xywh(40.0, 60.0, 200.0, 100.0); let empty = TextContentLayoutResult(vec![], vec![], TextContentSize::new_with_size(200.0, 100.0)); content.set_layout_from_result(empty, 200.0, 100.0); let selrect = Rect::from_xywh(40.0, 60.0, 200.0, 100.0); assert_eq!( content.cached_layout_paint_anchor(&selrect), Point::new(40.0, 60.0) ); assert_eq!( content.cached_layout_paint_offset(&selrect), Point::new(0.0, 0.0) ); } #[test] fn cached_layout_paint_offset_tracks_selrect_move() { let mut content = sample_text_content(); content.layout_paint_origin = Some(Point::new(10.0, 20.0)); // Simulate a move clone: bounds follow the new selrect, origin stays. content.set_xywh(110.0, 220.0, 200.0, 100.0); let selrect = Rect::from_xywh(110.0, 220.0, 200.0, 100.0); let offset = content.cached_layout_paint_offset(&selrect); assert_eq!(offset, Point::new(100.0, 200.0)); assert_eq!( content.cached_layout_paint_anchor(&selrect), Point::new(10.0, 20.0) ); } #[test] fn cached_layout_paint_offset_zero_without_origin() { let content = sample_text_content(); let selrect = Rect::from_xywh(50.0, 75.0, 200.0, 100.0); assert_eq!( content.cached_layout_paint_offset(&selrect), Point::new(0.0, 0.0) ); assert_eq!( content.cached_layout_paint_anchor(&selrect), Point::new(50.0, 75.0) ); } #[test] fn layout_paint_origin_survives_bounds_transform_on_clone() { let mut content = sample_text_content(); content.set_xywh(10.0, 20.0, 200.0, 100.0); content.layout_paint_origin = Some(Point::new(10.0, 20.0)); content.layout.paragraphs = Rc::new(vec![vec![]]); content.layout_width = Some(200.0); content.layout_version = 1; content.content_version = 1; let mut moved = content.clone(); let mut move_matrix = Matrix::new_identity(); move_matrix.set_translate_x(50.0); move_matrix.set_translate_y(30.0); moved.transform(&move_matrix); assert_eq!(moved.bounds().x(), 60.0); assert_eq!(moved.bounds().y(), 50.0); assert!(Rc::ptr_eq( &content.layout.paragraphs, &moved.layout.paragraphs )); let selrect = Rect::from_xywh(60.0, 50.0, 200.0, 100.0); assert_eq!( moved.cached_layout_paint_anchor(&selrect), Point::new(10.0, 20.0) ); assert_eq!( moved.cached_layout_paint_offset(&selrect), Point::new(50.0, 30.0) ); } #[test] fn layout_clone_shares_skia_paragraphs() { let mut layout = TextContentLayout::new(); layout.paragraphs = Rc::new(vec![vec![]]); let cloned = layout.clone(); assert!(Rc::ptr_eq(&layout.paragraphs, &cloned.paragraphs)); assert!(cloned.paragraph_builders.is_empty()); } #[test] fn layout_clear_empties_paragraphs() { let mut layout = TextContentLayout::new(); layout.paragraphs = Rc::new(vec![vec![]]); layout.clear(); assert!(layout.needs_update()); } // ----------------------------------------------------------------------- // RTL auto-width growth anchor // ----------------------------------------------------------------------- fn directed_paragraph(direction: TextDirection) -> Paragraph { let span = TextSpan::new( "hello".to_string(), FontFamily::new(Uuid::nil(), 400, crate::shapes::FontStyle::Normal), 14.0, 1.2, 0.0, None, None, direction, 400, Uuid::nil(), vec![], ); Paragraph::new(TextAlign::Left, direction, None, None, 1.2, 0.0, vec![span]) } /// Auto-width content measured wider than its stale selrect: mid-edit, where /// the anchor is observable. fn grown_content(directions: &[TextDirection], measured_width: f32) -> TextContent { let bounds = Rect::from_xywh(100.0, 50.0, 60.0, 20.0); let mut content = TextContent::new(bounds, GrowType::AutoWidth); for direction in directions { content.add_paragraph(directed_paragraph(*direction)); } content.size.width = measured_width; content.size.height = 20.0; content } #[test] fn is_rtl_false_when_no_paragraphs() { let content = TextContent::new(Rect::from_xywh(0.0, 0.0, 10.0, 10.0), GrowType::AutoWidth); assert!(!content.is_rtl()); } #[test] fn is_rtl_true_when_every_paragraph_is_rtl() { let content = grown_content(&[TextDirection::RTL, TextDirection::RTL], 120.0); assert!(content.is_rtl()); } #[test] fn is_rtl_false_when_directions_are_mixed() { let content = grown_content(&[TextDirection::RTL, TextDirection::LTR], 120.0); assert!(!content.is_rtl()); } #[test] fn is_rtl_false_when_every_paragraph_is_ltr() { let content = grown_content(&[TextDirection::LTR], 120.0); assert!(!content.is_rtl()); } #[test] fn layout_origin_x_right_anchors_rtl_auto_width() { let content = grown_content(&[TextDirection::RTL], 120.0); let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0); // right edge (160) minus the measured width (120) assert_eq!(content.layout_origin_x(&selrect, 120.0), 40.0); } #[test] fn layout_origin_x_left_anchors_ltr_auto_width() { let content = grown_content(&[TextDirection::LTR], 120.0); let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0); assert_eq!(content.layout_origin_x(&selrect, 120.0), 100.0); } #[test] fn layout_origin_x_left_anchors_mixed_direction_auto_width() { let content = grown_content(&[TextDirection::RTL, TextDirection::LTR], 120.0); let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0); assert_eq!(content.layout_origin_x(&selrect, 120.0), 100.0); } #[test] fn layout_origin_x_ignores_direction_for_fixed_and_auto_height() { let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0); for grow_type in [GrowType::Fixed, GrowType::AutoHeight] { let mut content = grown_content(&[TextDirection::RTL], 120.0); content.set_grow_type(grow_type); assert_eq!(content.layout_origin_x(&selrect, 120.0), 100.0); } } #[test] fn layout_origin_x_is_selrect_x_once_the_selrect_is_committed() { let content = grown_content(&[TextDirection::RTL], 60.0); let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0); assert_eq!(content.layout_origin_x(&selrect, 60.0), selrect.x()); } #[test] fn content_rect_right_anchors_grown_rtl_auto_width() { let content = grown_content(&[TextDirection::RTL], 120.0); let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0); let rect = content.content_rect(&selrect, VerticalAlign::Top); assert_eq!(rect.x(), 40.0); assert_eq!(rect.right(), selrect.right()); assert_eq!(rect.width(), 120.0); assert_eq!(rect.y(), selrect.y()); } #[test] fn content_rect_left_anchors_grown_ltr_auto_width() { let content = grown_content(&[TextDirection::LTR], 120.0); let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0); let rect = content.content_rect(&selrect, VerticalAlign::Top); assert_eq!(rect.x(), selrect.x()); assert_eq!(rect.width(), 120.0); } #[test] fn content_rect_unchanged_for_fixed_rtl_content() { // A mismatch sends `content_rect` into `get_height`, which needs global // font state tests do not have. let mut content = grown_content(&[TextDirection::RTL], 60.0); content.set_grow_type(GrowType::Fixed); let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0); let rect = content.content_rect(&selrect, VerticalAlign::Top); assert_eq!(rect.x(), selrect.x()); assert_eq!(rect.width(), selrect.width()); } #[test] fn cached_paint_anchor_plus_offset_lands_on_the_layout_origin() { let mut content = grown_content(&[TextDirection::RTL], 120.0); content.layout_paint_origin = Some(Point::new(100.0, 50.0)); let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0); let anchor = content.cached_layout_paint_anchor(&selrect); let offset = content.cached_layout_paint_offset(&selrect); assert_eq!( anchor.x + offset.x, content.layout_origin_x(&selrect, 120.0) ); } #[test] fn cached_paint_offset_stays_a_pure_translation_when_the_shape_moves() { let mut content = grown_content(&[TextDirection::RTL], 120.0); content.layout_paint_origin = Some(Point::new(100.0, 50.0)); let before = Rect::from_xywh(100.0, 50.0, 60.0, 20.0); let after = Rect::from_xywh(130.0, 70.0, 60.0, 20.0); let moved_by = Point::new( content.cached_layout_paint_offset(&after).x - content.cached_layout_paint_offset(&before).x, content.cached_layout_paint_offset(&after).y - content.cached_layout_paint_offset(&before).y, ); assert_eq!(moved_by, Point::new(30.0, 20.0)); } #[test] fn cached_paint_anchor_right_anchors_when_no_origin_was_baked() { let content = grown_content(&[TextDirection::RTL], 120.0); let selrect = Rect::from_xywh(100.0, 50.0, 60.0, 20.0); assert_eq!(content.layout_paint_origin, None); assert_eq!(content.cached_layout_paint_anchor(&selrect).x, 40.0); assert_eq!(content.cached_layout_paint_offset(&selrect).x, 0.0); } }