use crate::render::text::decoration_segments; use crate::{ math::{Bounds, Matrix, Rect}, render::{default_font, DEFAULT_EMOJI_FONT}, utils::Browser, }; 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, RefCell}; use std::collections::HashSet; use std::rc::Rc; use unicode_segmentation::UnicodeSegmentation; use super::text_horizontal::*; use super::text_japanese::*; use super::{FontFamily, FontStyle}; use crate::math::Point; use crate::render::FallbackFonts; #[cfg(test)] use crate::shapes::kinsoku; 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 if text_content.is_vertical() => { !crate::math::is_close_to(before.height(), after.height()) } GrowType::AutoHeight => !crate::math::is_close_to(before.width(), after.width()), GrowType::Fixed if text_content.is_vertical() => { !crate::math::is_close_to(before.width(), after.width()) || !crate::math::is_close_to(before.height(), after.height()) } 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, } /// Vertical layout with the content version and box it was laid out for. #[derive(Clone)] struct VerticalLayoutCache { version: u64, rect: Rect, layout: Rc, } impl std::fmt::Debug for VerticalLayoutCache { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("VerticalLayoutCache") .field("version", &self.version) .field("rect", &self.rect) .finish_non_exhaustive() } } /// Horizontal paragraph plans with the content version they were built for. #[derive(Clone)] struct HorizontalPlansCache { version: u64, width: f32, plans: Rc>, } /// Line adjustment of one horizontal paragraph at the layout width, /// per span: the sheds that squeeze its lines and the letter-spacing that /// justifies them. #[derive(Debug, Clone, Default)] pub(crate) struct HorizontalAdjustment { pub(crate) sheds: Vec>, pub(crate) spacing: Vec>, } /// Per paragraph; `None` for paragraphs laid out by Skia alone. type HorizontalAdjustments = Rc>>; #[derive(Debug, Clone)] struct LineAdjustmentCache { version: u64, width: f32, adjustments: HorizontalAdjustments, } impl std::fmt::Debug for HorizontalPlansCache { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("HorizontalPlansCache") .field("version", &self.version) .finish_non_exhaustive() } } #[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>, /// Last vertical layout, shared across clones like `paragraphs`. vertical: RefCell>, /// Last horizontal paragraph plans, shared across clones. horizontal: RefCell>, /// Last horizontal line adjustment, shared across clones. line_adjustment: RefCell>, } 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()), vertical: RefCell::new(self.vertical.borrow().clone()), horizontal: RefCell::new(self.horizontal.borrow().clone()), line_adjustment: RefCell::new(self.line_adjustment.borrow().clone()), } } } 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), vertical: RefCell::new(None), horizontal: RefCell::new(None), line_adjustment: RefCell::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, x_offset)| { let prev_height = *height; *height += p.height(); Some((prev_height, p, x_offset)) }) .any(|(height, p, x_offset)| intersects(p, x_pos - x_offset, y_pos - height)) } // Performs a text auto layout without width limits. // This should be the same as text_auto_layout. pub(crate) fn build_paragraphs_from_paragraph_builders( paragraph_builders: &mut [ParagraphBuilderGroup], width: f32, plans: &[HorizontalParagraphPlan], ) -> Vec> { let paragraphs = paragraph_builders .iter_mut() .enumerate() .map(|(index, 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( plans .get(index) .map_or(width, |plan| plan.layout_width(width)), ); paragraph }) .collect() }) .collect(); paragraphs } /// Calculate the normalized line height from paragraph builders pub(crate) fn calculate_normalized_line_height( paragraph_builders: &mut [ParagraphBuilderGroup], width: f32, plans: &[HorizontalParagraphPlan], ) -> f32 { let mut normalized_line_height = 0.0; for (index, paragraph_builder_group) in paragraph_builders.iter_mut().enumerate() { for paragraph_builder in paragraph_builder_group.iter_mut() { let mut paragraph = paragraph_builder.build(); paragraph.layout( plans .get(index) .map_or(width, |plan| plan.layout_width(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, /// How over-long lines are fitted, for the whole shape. line_adjustment: LineAdjustment, pub size: TextContentSize, pub layout: TextContentLayout, content_version: u64, layout_version: u64, layout_width: Option, layout_height: 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, /// Box size the gradient and image shaders were built for. They are relative /// to the whole box, so a new size must rebuild them. layout_paint_size: Option<(f32, f32)>, } 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, line_adjustment: LineAdjustment::default(), size: TextContentSize::default(), layout: TextContentLayout::new(), content_version: 0, layout_version: 0, layout_width: None, layout_height: None, layout_paint_origin: None, layout_paint_size: 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, line_adjustment: self.line_adjustment, size: TextContentSize::new_with_size(bounds.width(), bounds.height()), layout: TextContentLayout::new(), content_version: 0, layout_version: 0, layout_width: None, layout_height: None, layout_paint_origin: None, layout_paint_size: 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(selrect.x(), 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(selrect.x() - origin.x, selrect.y() - origin.y), None => Point::new(0.0, 0.0), } } /// 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 } /// True when some span holds an emoji: an emoji code point, or an emoji /// presentation selector or keycap mark (`©️`, `#️⃣`). Color emoji need an /// overlay above strokes. pub fn has_emoji(&self) -> bool { self.paragraphs .iter() .flat_map(|p| p.children()) .flat_map(|span| span.text.chars()) .any(|c| super::text_vertical::is_emoji_char(c) || matches!(c, '\u{FE0F}' | '\u{20E3}')) } 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 } /// Vertical layout for the box `rect`: its height is the column-wrap /// budget and span paints resolve against it. Cached until the content or /// the box changes; clones share it. pub fn vertical_layout(&self, rect: &Rect) -> Rc { if let Some(entry) = self.layout.vertical.borrow().as_ref() { if entry.version == self.content_version && entry.rect == *rect { return Rc::clone(&entry.layout); } } let layout = Rc::new(super::text_vertical::layout_for_rect(self, rect)); *self.layout.vertical.borrow_mut() = Some(VerticalLayoutCache { version: self.content_version, rect: *rect, layout: Rc::clone(&layout), }); layout } /// True when a horizontal paragraph gets its lines adjusted here rather /// than by Skia: Japanese squeezing or policy-based CJK justification. fn adjusts_lines(&self, paragraph: &Paragraph, plan: &HorizontalParagraphPlan) -> bool { let policy = paragraph.layout_policy(); (uses_japanese_layout(paragraph, &plan.texts) && (self.line_adjustment == LineAdjustment::PushInFirst || paragraph.text_align() == skia::textlayout::TextAlign::Justify)) || ((policy.is_chinese() || matches!( policy, super::text_policy::TextLayoutPolicy::KoreanSyllable | super::text_policy::TextLayoutPolicy::KoreanWords )) && paragraph.text_align() == skia::textlayout::TextAlign::Justify) } /// Squeeze sheds and justify spacing of each horizontal paragraph at /// the layout width, cached until the content or the width /// changes. `None` when no paragraph needs them. pub(crate) fn horizontal_line_adjustments(&self) -> Option { if self.is_vertical() || self.grow_type() == GrowType::AutoWidth { return None; } let plans = self.horizontal_plans(); let adjusted: Vec = self .paragraphs .iter() .zip(plans.iter()) .map(|(paragraph, plan)| self.adjusts_lines(paragraph, plan)) .collect(); if !adjusted.contains(&true) { return None; } let width = self.get_width(self.bounds().width()); if let Some(entry) = self.layout.line_adjustment.borrow().as_ref() { if entry.version == self.content_version && entry.width == width { return Some(Rc::clone(&entry.adjustments)); } } let adjustments: HorizontalAdjustments = Rc::new( self.paragraphs .iter() .zip(plans.iter()) .zip(&adjusted) .map(|((paragraph, plan), adjusted)| { adjusted.then(|| self.adjust_horizontal_lines(paragraph, plan, width)) }) .collect(), ); *self.layout.line_adjustment.borrow_mut() = Some(LineAdjustmentCache { version: self.content_version, width, adjustments: Rc::clone(&adjustments), }); Some(adjustments) } fn adjust_horizontal_lines( &self, paragraph: &Paragraph, plan: &HorizontalParagraphPlan, width: f32, ) -> HorizontalAdjustment { let lay_out = |extra_sheds: &[Vec<(usize, f32)>]| { let mut laid_out = self .horizontal_measure_builder(paragraph, plan, extra_sheds) .build(); laid_out.layout(plan.layout_width(width)); laid_out }; let (sheds, laid_out) = if paragraph.layout_policy() == super::text_policy::TextLayoutPolicy::Japanese && self.line_adjustment == LineAdjustment::PushInFirst { horizontal_squeeze_sheds(paragraph, plan, width, &lay_out) } else { let sheds = vec![Vec::new(); plan.texts.len()]; let laid_out = lay_out(&sheds); (sheds, laid_out) }; let spacing = if paragraph.text_align() == skia::textlayout::TextAlign::Justify { if paragraph.layout_policy() == super::text_policy::TextLayoutPolicy::Japanese { horizontal_justify_spacing(&laid_out, plan, width) } else { horizontal_policy_justify_spacing(paragraph, &laid_out, plan, width) } } else { vec![Vec::new(); plan.texts.len()] }; HorizontalAdjustment { sheds, spacing } } /// Left-aligned builder of one paragraph with plain fills, its plan's /// sheds plus `extra_sheds`, for measuring line adjustment. fn horizontal_measure_builder( &self, paragraph: &Paragraph, plan: &HorizontalParagraphPlan, extra_sheds: &[Vec<(usize, f32)>], ) -> ParagraphBuilder { let fonts = get_font_collection(); let fallback_fonts = get_fallback_fonts(); let mut paragraph_style = paragraph.paragraph_to_style(); paragraph_style.set_apply_rounding_hack(false); paragraph_style.set_text_align(skia::textlayout::TextAlign::Left); let mut builder = ParagraphBuilder::new(¶graph_style, fonts); for (span_index, ((span, text), sheds)) in paragraph .children() .iter() .zip(&plan.texts) .zip(&plan.sheds) .enumerate() { let combined: Vec<(usize, f32)> = sheds .iter() .chain(extra_sheds.get(span_index).into_iter().flatten()) .copied() .collect(); let text_style = span.to_style_with_paint( &self.bounds(), fallback_fonts, false, paragraph.line_height(), None, ); builder.push_style(&text_style); add_horizontal_span( &mut builder, span, text, &combined, &plan.ruby_spacing.adjustments[span_index], &text_style, fonts, ); } builder } /// Horizontal passes of every paragraph (kinsoku texts, offsets, aki /// sheds, ruby spacing), cached until content or width changes; clones /// share them. A font load bumps the content version for shaped spacing. pub(crate) fn horizontal_plans(&self) -> Rc> { let width = if self.grow_type() == GrowType::AutoWidth { f32::MAX } else { self.bounds().width() }; if let Some(entry) = self.layout.horizontal.borrow().as_ref() { if entry.version == self.content_version && entry.width == width { return Rc::clone(&entry.plans); } } let plans: Rc> = Rc::new( self.paragraphs .iter() .map(|paragraph| { let mut plan = HorizontalParagraphPlan::new(paragraph); plan.relax_overlong_words(paragraph, width, |plan| { let sheds = vec![Vec::new(); plan.texts.len()]; let mut measured = self .horizontal_measure_builder(paragraph, plan, &sheds) .build(); measured.layout(width); measured }); plan.retain_atomic_overflow(paragraph, width, |plan, width| { let sheds = vec![Vec::new(); plan.texts.len()]; let mut measured = self .horizontal_measure_builder(paragraph, plan, &sheds) .build(); measured.layout(width); measured }); plan }) .collect(), ); *self.layout.horizontal.borrow_mut() = Some(HorizontalPlansCache { version: self.content_version, width, plans: Rc::clone(&plans), }); plans } /// Writing mode applies to the whole shape: the first paragraph sets it. pub fn is_vertical(&self) -> bool { self.paragraphs .first() .is_some_and(|p| p.writing_mode() == WritingMode::VerticalRl) } /// Vertical writing paints from its own layout, not the cached /// horizontal paragraphs. Horizontal ruby, warichu and emphasis marks /// paint from the cached paragraphs like the base text. pub fn can_paint_from_layout_cache(&self) -> bool { !self.is_vertical() } pub fn grow_type(&self) -> GrowType { self.grow_type } pub fn line_adjustment(&self) -> LineAdjustment { self.line_adjustment } pub fn set_line_adjustment(&mut self, line_adjustment: LineAdjustment) { if self.line_adjustment != line_adjustment { self.line_adjustment = line_adjustment; self.content_version = self.content_version.wrapping_add(1); } } 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); } } /// Emphasis paints outside the base glyph metrics. Use its placement /// geometry for culling and export bounds as well as position data. fn horizontal_emphasis_bounds(&self, selrect: &Rect, valign: VerticalAlign) -> Option { if !self .paragraphs .iter() .flat_map(Paragraph::children) .any(|span| !span.text_emphasis.is_none()) { return None; } let total_height: f32 = self .layout .paragraphs .iter() .filter_map(|group| group.first()) .map(|paragraph| paragraph.height()) .sum(); let mut y = selrect.y() + vertical_align_offset(selrect.height(), total_height, valign); let plans = self.horizontal_plans(); let mut bounds = Rect::new_empty(); for ((group, model), plan) in self .layout .paragraphs .iter() .zip(&self.paragraphs) .zip(plans.iter()) { let Some(paragraph) = group.first() else { continue; }; let x = selrect.x() + plan.x_offset(model, selrect.width()); for placement in horizontal_emphasis_placements(model, &plan.offsets, paragraph) { let span = &model.children()[placement.span]; bounds.join(horizontal_emphasis_mark_box(span, &placement).with_offset((x, y))); } y += paragraph.height(); } (!bounds.is_empty()).then_some(bounds) } /// 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; let plans = self.horizontal_plans(); for (index, group) in paragraphs.iter().enumerate() { if let Some(paragraph) = group.first() { let x = x + plans[index].x_offset(&self.paragraphs[index], selrect.width()); 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 { let mut bounds = Rect::from_ltrb(min_x, min_y, max_x, max_y); if let Some(emphasis) = self.horizontal_emphasis_bounds(selrect, valign) { bounds.join(emphasis); } Some(bounds) } 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. // Vertical writing takes its bounds from content_rect; the // skparagraph line metrics below describe an unused horizontal layout. if self.is_vertical() { return self.vertical_extrect(selrect, valign); } if self.grow_type() == GrowType::AutoWidth { let mut bounds = self.content_rect(selrect, valign); if let Some(emphasis) = self.horizontal_emphasis_bounds(selrect, valign) { bounds.join(emphasis); } return bounds; } 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 } /// Content rect of vertical text grown to the laid-out column block, which /// overflows a fixed shape that is too narrow for its columns. fn vertical_extrect(&self, selrect: &Rect, valign: VerticalAlign) -> Rect { let mut rect = self.content_rect(selrect, valign); let layout = self.vertical_layout(selrect); let left = selrect.left() + super::text_vertical::block_axis_offset(selrect.width(), layout.width, valign); rect.join(Rect::from_xywh( left, selrect.top(), layout.width, layout.height, )); rect } pub fn content_rect(&self, selrect: &Rect, valign: VerticalAlign) -> Rect { // Auto-grow bounds follow the same block anchor as the vertical paint. if self.is_vertical() { let (width, height) = match self.grow_type() { GrowType::AutoWidth => (self.size.width, self.size.height), GrowType::AutoHeight => (self.size.width, selrect.height()), GrowType::Fixed => (selrect.width(), selrect.height()), }; let x = selrect.x() + super::text_vertical::block_axis_offset(selrect.width(), width, valign); return Rect::from_xywh(x, selrect.y(), width, height); } let x = selrect.x(); let mut y = selrect.y(); let width = if self.grow_type() == GrowType::AutoWidth { self.size.width } else { 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); } /// Caret position for a selrect-local point. Vertical text wraps its /// columns at the `selrect` height, as painting does; stored bounds can /// be taller than a fixed shape. pub fn get_caret_position_from_shape_coords( &self, point: &Point, selrect: &Rect, vertical_align: VerticalAlign, ) -> Option { // Vertical writing: resolve through the vertical pass. The content // block is right-anchored. if self.is_vertical() { let layout = self.vertical_layout(selrect); let cx = point.x - super::text_vertical::block_axis_offset( selrect.width(), layout.width, vertical_align, ); let (paragraph, offset) = super::text_vertical::caret_from_point(&layout, cx, point.y)?; return Some(TextPositionWithAffinity::new_downstream_affinity( paragraph, offset, )); } 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 plans = self.horizontal_plans(); let x_offset = plans .get(paragraph_index) .zip(self.paragraphs().get(paragraph_index)) .map_or(0.0, |(plan, model)| plan.x_offset(model, selrect.width())); let para_pt = Point::new(point.x - x_offset, point.y - start_y); if let Some(paragraph) = self.paragraphs().get(paragraph_index) { if let Some(original_position) = horizontal_warichu_hit_test(paragraph, layout_paragraph, para_pt) { return Some(TextPositionWithAffinity::new_downstream_affinity( paragraph_index, original_position, )); } } let position_with_affinity = layout_paragraph.get_glyph_position_at_coordinate((para_pt.x, para_pt.y)); if let Some(plan) = self.horizontal_plans().get(paragraph_index) { // Skia reports builder-text UTF-16 offsets (transformed and // kinsoku-shifted); the model counts source characters. let offset = plan .offsets .builder_to_source(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, selrect: &Rect, vertical_align: VerticalAlign, ) -> Option { let shape_rel_point = Shape::get_relative_point(point, view_matrix, shape_matrix)?; self.get_caret_position_from_shape_coords(&shape_rel_point, selrect, vertical_align) } /// 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 /// `image_fill` — used as an SVG `` for linked image fills. pub fn paragraph_builder_group_opaque_for_image_layer( &self, layer_from_bottom: usize, image_fill: &shapes::ImageFill, ) -> Vec { self.paragraph_builders( None, false, None, None, Some((layer_from_bottom, image_fill)), None, ) } fn paragraph_builders( &self, use_shadow: Option, opaque: bool, align_override: Option, fill_layer: Option, opaque_image_layer: Option<(usize, &shapes::ImageFill)>, skip_image_ids: Option<&HashSet>, ) -> Vec { let fonts = get_font_collection(); let fallback_fonts = get_fallback_fonts(); let mut paragraph_group = Vec::new(); let plans = self.horizontal_plans(); let adjustments = align_override .is_none() .then(|| self.horizontal_line_adjustments()) .flatten(); for (paragraph_index, (paragraph, plan)) in self.paragraphs().iter().zip(plans.iter()).enumerate() { let adjustment = adjustments .as_ref() .and_then(|adjustments| adjustments.get(paragraph_index)) .and_then(Option::as_ref); let mut paragraph_style = paragraph.paragraph_to_style(); if self.adjusts_lines(paragraph, plan) || plan.has_atomic_overflow() { // Line adjustment gives characters runs of their own; rounding // each run up would wrap the adjusted lines. The measuring // pass shares this style so both break alike. paragraph_style.set_apply_rounding_hack(false); } if let Some(align) = align_override { paragraph_style.set_text_align(align); } else if adjustment.is_some() && paragraph.text_align() == skia::textlayout::TextAlign::Justify { // The justify spacing already fills the lines. paragraph_style.set_text_align(skia::textlayout::TextAlign::Left); } let mut builder = ParagraphBuilder::new(¶graph_style, fonts); let mut has_text = false; for (span_index, (((span, text), plan_sheds), ruby_extra)) in paragraph .children() .iter() .zip(&plan.texts) .zip(&plan.sheds) .zip(&plan.ruby_spacing.adjustments) .enumerate() { let squeeze = adjustment.and_then(|adjustment| adjustment.sheds.get(span_index)); let justify = adjustment.and_then(|adjustment| adjustment.spacing.get(span_index)); let combined_sheds: Vec<(usize, f32)>; let sheds: &[(usize, f32)] = match squeeze { Some(squeeze) if !squeeze.is_empty() => { combined_sheds = plan_sheds.iter().chain(squeeze).copied().collect(); &combined_sheds } _ => plan_sheds, }; let combined: Vec<(usize, f32)>; let extra: &[(usize, f32)] = match justify { Some(justify) if !justify.is_empty() => { combined = ruby_extra.iter().chain(justify).copied().collect(); &combined } _ => ruby_extra, }; let text_style = if let Some((layer, image_fill)) = 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 == image_fill => { 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, ) }; if !text.is_empty() { has_text = true; } builder.push_style(&text_style); add_horizontal_span(&mut builder, span, text, sheds, extra, &text_style, fonts); } 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, &self.horizontal_plans(), ); let measure_paragraphs = build_paragraphs_from_paragraph_builders( &mut measure_builders, f32::MAX, &self.horizontal_plans(), ); 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, &self.horizontal_plans(), ); 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, &self.horizontal_plans(), ); let paragraphs = build_paragraphs_from_paragraph_builders( &mut paragraph_builders, width, &self.horizontal_plans(), ); 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, &self.horizontal_plans(), ); let paragraphs = build_paragraphs_from_paragraph_builders( &mut paragraph_builders, width, &self.horizontal_plans(), ); 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 { // A prospective width needs its own plan: frozen overflow breaks // from the current bounds must not determine a resized box's height. let mut content = self.clone(); content.set_xywh( self.bounds.x(), self.bounds.y(), width, self.bounds.height(), ); let mut paragraph_builders = content.paragraph_builder_group_from_text(None); let paragraphs = build_paragraphs_from_paragraph_builders( &mut paragraph_builders, width, &content.horizontal_plans(), ); 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) && self.paint_size_matches(&shape.selrect()) } /// True when some span has a fill laid over the whole box (gradient or image). fn has_box_relative_fills(&self) -> bool { self.paragraphs.iter().any(|paragraph| { paragraph.children().iter().any(|span| { span.fills .iter() .any(|fill| !matches!(fill, shapes::Fill::Solid(_))) }) }) } /// True when the cached fill shaders fit a box of `selrect`'s size. fn paint_size_matches(&self, selrect: &Rect) -> bool { if !self.has_box_relative_fills() { return true; } self.layout_paint_size.is_some_and(|(width, height)| { (width - selrect.width()).abs() < 0.01 && (height - selrect.height()).abs() < 0.01 }) } 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); // Paragraph paints (incl. absolute image/gradient shaders) were built // against `self.bounds()` in `paragraph_builder_group_from_text`. self.layout_paint_origin = Some(Point::new(self.bounds.x(), self.bounds.y())); self.layout_paint_size = Some((self.bounds.width(), self.bounds.height())); } pub fn force_next_layout_update(&mut self) { self.layout_width = None; self.layout_height = None; self.layout_paint_origin = None; self.layout_paint_size = 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()); // Vertical columns wrap by height, horizontal lines by width. let width_matches = self .layout_width .is_some_and(|w| (w - selrect.width()).abs() < f32::EPSILON); let height_matches = self .layout_height .is_some_and(|h| (h - selrect.height()).abs() < f32::EPSILON); let layout_matches_container = match self.grow_type() { GrowType::AutoWidth => true, GrowType::AutoHeight if self.is_vertical() => height_matches, GrowType::AutoHeight => width_matches, GrowType::Fixed if self.is_vertical() => width_matches && height_matches, GrowType::Fixed => width_matches, }; if !self.layout.needs_update() && self.layout_version == self.content_version && layout_matches_container && self.paint_size_matches(&selrect) { return self.size; } self.size.set_size(selrect.width(), selrect.height()); self.layout_height = Some(selrect.height()); if self.is_vertical() { // Vertical writing takes sizes from the vertical pass, so the // horizontal skparagraph layout is skipped. Empty paragraph slots // mark the layout as done. Auto-width fits both axes without // wrapping. Auto-height wraps at the shape height and grows width // as columns advance right-to-left. Fixed keeps both dimensions. self.layout.set( Vec::new(), (0..self.paragraphs.len().max(1)) .map(|_| Vec::new()) .collect(), ); self.layout_width = Some(selrect.width()); match self.grow_type() { GrowType::AutoWidth => { let layout = self.vertical_layout(&selrect); self.size.width = layout.width.ceil().max(DEFAULT_TEXT_CONTENT_SIZE); self.size.height = layout.height.ceil().max(DEFAULT_TEXT_CONTENT_SIZE); self.size.max_width = self.size.width; } GrowType::AutoHeight => { let layout = self.vertical_layout(&selrect); self.size.width = layout.width.ceil().max(DEFAULT_TEXT_CONTENT_SIZE); self.size.height = selrect.height(); self.size.max_width = self.size.width; } GrowType::Fixed => {} } } else { 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() && self.grow_type() != GrowType::Fixed { let (placeholder_width, placeholder_height) = self.placeholder_dimensions(selrect); if self.grow_type() == GrowType::AutoWidth || self.is_vertical() { self.size.width = placeholder_width; self.size.max_width = placeholder_width; } if self.grow_type() == GrowType::AutoWidth || !self.is_vertical() { self.size.height = placeholder_height; } } 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 self.is_vertical() { let layout = self.vertical_layout(&selrect); let font_size = self .paragraphs .first() .and_then(|p| p.children().first()) .map_or(14.0, |s| s.font_size); return ( layout.width.ceil().max(DEFAULT_TEXT_CONTENT_SIZE), font_size, ); } 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)); // Vertical writing: hit-test against the laid-out cells (absolute // coordinates, right-anchored to the selrect). if self.is_vertical() { let layout = self.vertical_layout(&shape.selrect); return super::text_vertical::intersects( &layout, &shape.selrect, shape.vertical_align(), result.x, result.y, ); } // Change coords to content space let x_pos = result.x - rect.x(); let y_pos = result.y - rect.y(); let plans = self.horizontal_plans(); if !self.layout.paragraphs.is_empty() { // Reuse stored laid-out paragraphs paragraph_intersects( self.layout .paragraphs .iter() .enumerate() .filter_map(|(index, group)| { group.first().map(|paragraph| { ( paragraph, plans[index].x_offset(&self.paragraphs[index], rect.width()), ) }) }), 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, &plans); paragraph_intersects( paragraphs.iter().enumerate().filter_map(|(index, group)| { group.first().map(|paragraph| { ( paragraph, plans[index].x_offset(&self.paragraphs[index], width), ) }) }), x_pos, y_pos, ) } } } impl Default for TextContent { fn default() -> Self { Self { paragraphs: vec![], bounds: Rect::default(), grow_type: GrowType::Fixed, line_adjustment: LineAdjustment::default(), size: TextContentSize::default(), layout: TextContentLayout::new(), content_version: 0, layout_version: 0, layout_width: None, layout_height: None, layout_paint_origin: None, layout_paint_size: 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, writing_mode: WritingMode, text_orientation: TextOrientation, language: String, korean_wrap: super::text_policy::KoreanWrap, /// Paragraph in the content tree last exchanged with the editor host. source_position: u32, 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, writing_mode: WritingMode::default(), text_orientation: TextOrientation::default(), language: String::new(), korean_wrap: super::text_policy::KoreanWrap::Syllable, source_position: u32::MAX, 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, writing_mode: WritingMode::default(), text_orientation: TextOrientation::default(), language: String::new(), korean_wrap: super::text_policy::KoreanWrap::Syllable, source_position: u32::MAX, line_height, letter_spacing, children, } } pub fn writing_mode(&self) -> WritingMode { self.writing_mode } pub fn language(&self) -> &str { &self.language } pub fn set_language(&mut self, language: String) { for span in &mut self.children { span.language.clone_from(&language); } self.language = language; } pub fn korean_wrap(&self) -> super::text_policy::KoreanWrap { self.korean_wrap } pub fn set_korean_wrap(&mut self, value: super::text_policy::KoreanWrap) { self.korean_wrap = value; } pub fn layout_policy(&self) -> super::text_policy::TextLayoutPolicy { super::text_policy::TextLayoutPolicy::for_language_with_wrap( &self.language, self.korean_wrap, ) } pub fn source_position(&self) -> u32 { self.source_position } pub fn set_source_position(&mut self, position: u32) { self.source_position = position; } pub fn set_writing_mode(&mut self, writing_mode: WritingMode) { self.writing_mode = writing_mode; } pub fn text_orientation(&self) -> TextOrientation { self.text_orientation } pub fn set_text_orientation(&mut self, text_orientation: TextOrientation) { self.text_orientation = text_orientation; } pub fn children(&self) -> &[TextSpan] { &self.children } pub fn children_mut(&mut self) -> &mut Vec { &mut self.children } /// Move spans into this paragraph, inheriting its shaping language. pub fn extend_children(&mut self, spans: impl IntoIterator) { self.children.extend(spans.into_iter().map(|mut span| { span.language.clone_from(&self.language); span })); } fn set_span_positions(&mut self, index: u32) { self.source_position = index; 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 } /// Inverse of `char_offset_to_utf16`. 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 } fn text(&self) -> String { self.children .iter() .map(|span| span.text.as_str()) .collect() } /// Char offset of the grapheme boundary before `char_offset`, so emojis /// made of several chars are one step. pub fn prev_grapheme_offset(&self, char_offset: usize) -> usize { let mut boundary = 0; for grapheme in self.text().graphemes(true) { let next = boundary + grapheme.chars().count(); if next >= char_offset { break; } boundary = next; } boundary } /// Char offset of the grapheme boundary after `char_offset`. pub fn next_grapheme_offset(&self, char_offset: usize) -> usize { let mut boundary = 0; for grapheme in self.text().graphemes(true) { boundary += grapheme.chars().count(); if boundary > char_offset { break; } } boundary } 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 } /// Span texts as fed to the paragraph builders (text-transform applied, /// Japanese spacing normalized, kinsoku break suppressions inserted), /// plus the map from original to builder-text UTF-16 offsets. Consumers /// of laid-out offsets must translate through the map. Only paragraphs /// with an explicit typography profile are transformed. #[cfg(test)] pub fn layout_span_texts(&self) -> (Vec, kinsoku::OffsetMap) { layout_span_texts(self) } 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)); } } /// 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); } } /// Text after browser filtering and CSS text-transform, plus the source /// UTF-16 range behind each output scalar. One source scalar can yield /// several (`ß` uppercases to `SS`); the ranges let vertical layout wrap and /// export those glyphs as one source unit. #[derive(Debug, Clone, PartialEq)] pub struct AppliedTextTransform { pub text: String, pub(crate) source_ranges: Vec<(std::ops::Range, std::ops::Range)>, } impl AppliedTextTransform { pub fn source_utf16_range( &self, transformed: std::ops::Range, ) -> std::ops::Range { // Output ranges are contiguous and ascending: skip to the first one // that ends past `transformed.start`. let first = self .source_ranges .partition_point(|(output, _)| output.end <= transformed.start); let mut ranges = self.source_ranges[first..] .iter() .take_while(|(output, _)| output.start < transformed.end) .filter(|(output, _)| output.end > transformed.start) .map(|(_, source)| source.clone()); let Some(first) = ranges.next() else { return 0..0; }; ranges.fold(first, |range, source| { range.start.min(source.start)..range.end.max(source.end) }) } } fn apply_text_transform_with_source_ranges( text: &str, browser: u8, transform: Option, ) -> AppliedTextTransform { let mut output = String::with_capacity(text.len()); let mut source_ranges = Vec::new(); let mut source_utf16 = 0usize; let mut output_utf16 = 0usize; let mut capitalize_next = true; for source_char in text.chars() { let source_start = source_utf16; source_utf16 += source_char.len_utf16(); // Control characters below U+0020 are filtered (Firefox drops them, // other browsers show a space); tabs and line breaks are kept. let processed = if matches!(source_char, '\t' | '\n' | '\r' | '\u{2028}' | '\u{2029}') || source_char >= '\u{0020}' { Some(source_char) } else if browser == Browser::Firefox as u8 { None } else { Some(' ') }; let Some(processed) = processed else { continue; }; let transformed: String = match transform { Some(TextTransform::Uppercase) => processed.to_uppercase().collect(), Some(TextTransform::Lowercase) => processed.to_lowercase().collect(), Some(TextTransform::Capitalize) if processed.is_alphabetic() && capitalize_next => { capitalize_next = false; processed.to_uppercase().collect() } Some(TextTransform::Capitalize) => { capitalize_next = !processed.is_alphabetic(); processed.to_string() } None => processed.to_string(), }; for transformed_char in transformed.chars() { let transformed_start = output_utf16; output_utf16 += transformed_char.len_utf16(); source_ranges.push((transformed_start..output_utf16, source_start..source_utf16)); output.push(transformed_char); } } AppliedTextTransform { text: output, source_ranges, } } #[derive(Debug, PartialEq, Clone)] pub struct TextSpan { pub text: String, /// Effective paragraph language, copied when the paragraph is decoded. pub language: 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 text_orientation: TextOrientation, pub text_combine_upright: TextCombineUpright, /// Emphasis mark (圏点 / bouten) applied to each base character. pub text_emphasis: TextEmphasis, pub text_emphasis_position: TextEmphasisPosition, pub text_underline_position: TextUnderlinePosition, /// Ruby (furigana) annotation for this span; empty means no ruby. pub ruby: String, pub ruby_zhuyin: bool, pub ruby_size: RubySize, pub ruby_align: RubyAlign, pub ruby_overhang: RubyOverhang, pub ruby_side: RubySide, /// Warichu (割注): two half-size lines stacked in one column position. pub warichu: bool, pub font_features: FontFeatures, pub annotation_clearance: AnnotationClearance, pub fills: Vec, pub paragraph_position: u32, pub span_position: u32, } impl Default for TextSpan { fn default() -> Self { Self { text: String::default(), language: String::new(), font_family: FontFamily::new(Uuid::nil(), 400, FontStyle::Normal), font_size: 14.0, line_height: 1.2, letter_spacing: 0.0, font_weight: 400, font_variant_id: Uuid::nil(), text_decoration: None, text_transform: None, text_direction: TextDirection::LTR, text_orientation: TextOrientation::default(), text_combine_upright: TextCombineUpright::default(), text_emphasis: TextEmphasis::default(), text_emphasis_position: TextEmphasisPosition::default(), text_underline_position: TextUnderlinePosition::default(), ruby: String::default(), ruby_zhuyin: false, ruby_size: RubySize::default(), ruby_align: RubyAlign::default(), ruby_overhang: RubyOverhang::default(), ruby_side: RubySide::default(), warichu: false, font_features: FontFeatures::default(), annotation_clearance: AnnotationClearance::default(), fills: vec![], paragraph_position: u32::MAX, span_position: u32::MAX, } } } 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, ..Self::default() } } 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; } /// Ruby annotation text, without surrounding whitespace. pub fn ruby_text(&self) -> &str { self.ruby.trim() } /// True when the span shows a reading. A warichu span shows none: the /// note replaces the base text, so a reading has nothing to annotate. pub fn has_ruby(&self) -> bool { !self.warichu && !self.ruby_text().is_empty() } pub fn ruby_font_size(&self) -> f32 { self.font_size * if self.is_zhuyin() { super::text_zhuyin::FONT_SCALE } else { self.ruby_size.scale() } } pub fn is_zhuyin(&self) -> bool { super::text_zhuyin::syllable(self).is_some() } pub fn effective_ruby_side(&self) -> RubySide { if self.is_zhuyin() { RubySide::Over } else { self.ruby_side } } pub fn ruby_cross_room_em(&self) -> f32 { if self.is_zhuyin() { super::text_zhuyin::STRIP_WIDTH } else { self.ruby_size.scale() } } /// Warichu needs at least two characters to fill its two sub-lines. pub fn is_warichu(&self) -> bool { self.warichu && self.text.chars().count() >= 2 } /// Room, in em of the span, that automatic annotation clearance adds to /// the line height: the ruby at its size plus the emphasis marks. `None` /// keeps the set line height, so annotations sit in the line gap. pub fn annotation_room_em(&self) -> f32 { self.annotation_side_room_em(RubySide::Over, false) + self.annotation_side_room_em(RubySide::Under, false) } pub fn emphasis_side(&self, vertical: bool) -> RubySide { self.text_emphasis_position.side( vertical, super::text_policy::TextLayoutPolicy::for_language(&self.language, false).is_chinese(), ) } pub fn underline_below_ink(&self) -> bool { self.text_underline_position.below_ink( super::text_policy::TextLayoutPolicy::for_language(&self.language, false).is_chinese(), ) } /// Only readings on the same side stack with emphasis marks. pub fn stacks_emphasis_outside_ruby(&self, vertical: bool) -> bool { self.annotation_clearance.is_auto() && self.has_ruby() && (!self.is_zhuyin() || vertical) && self.effective_ruby_side() == self.emphasis_side(vertical) } pub fn decoration_room_em(&self, side: RubySide, vertical: bool) -> f32 { let underline = self .text_decoration .is_some_and(|kind| kind.contains(TextDecoration::UNDERLINE)); let underline_side = if vertical { self.text_underline_position.side() } else { RubySide::Under }; let positioned = self.text_underline_position != TextUnderlinePosition::Auto || self.text_emphasis_position != TextEmphasisPosition::Auto || super::text_policy::TextLayoutPolicy::for_language(&self.language, false) .is_chinese(); if self.annotation_clearance.is_auto() && underline && positioned && side == underline_side { 0.18 } else { 0.0 } } pub fn ruby_decoration_offset(&self, vertical: bool) -> f32 { self.font_size * self.decoration_room_em(self.effective_ruby_side(), vertical) } pub fn emphasis_outside_offset(&self, vertical: bool) -> f32 { let ruby = if self.stacks_emphasis_outside_ruby(vertical) { self.font_size * self.ruby_cross_room_em() } else { 0.0 }; ruby + self.font_size * self.decoration_room_em(self.emphasis_side(vertical), vertical) } pub fn annotation_side_room_em(&self, side: RubySide, vertical: bool) -> f32 { if !self.annotation_clearance.is_auto() { return 0.0; } let ruby = if self.has_ruby() && (!self.is_zhuyin() || vertical) && self.effective_ruby_side() == side { self.ruby_cross_room_em() } else { 0.0 }; let emphasis = if !self.text_emphasis.is_none() && self.emphasis_side(vertical) == side { super::text_japanese::EMPHASIS_FONT_SCALE } else { 0.0 }; ruby + emphasis + self.decoration_room_em(side, vertical) } pub fn to_style( &self, content_bounds: &Rect, fallback_fonts: &FallbackFonts, 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: &FallbackFonts, remove_alpha: bool, paragraph_line_height: f32, fill_layer_from_bottom: Option, ) -> skia::textlayout::TextStyle { let mut style = skia::textlayout::TextStyle::default(); if !self.language.is_empty() { style.set_locale(&self.language); } 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) + self.annotation_room_em(); 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.for_text(&self.language, &self.text)); style.set_font_families(&font_families); style.set_font_size(self.font_size); style.set_letter_spacing(self.letter_spacing); match self.font_features { FontFeatures::None => {} FontFeatures::Palt => style.add_font_feature("palt", 1), FontFeatures::Vpal => style.add_font_feature("vpal", 1), } style.set_half_leading(true); style } pub fn to_stroke_style( &self, stroke_paint: &Paint, fallback_fonts: &FallbackFonts, 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 { apply_text_transform_with_source_ranges( text, crate::globals::current_browser(), self.text_transform, ) .text } pub fn apply_text_transform_with_source_ranges(&self) -> AppliedTextTransform { apply_text_transform_with_source_ranges( &self.text, crate::globals::current_browser(), self.text_transform, ) } 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 source_paragraph: usize, pub x: f32, pub y: f32, pub decorations: Vec, } #[derive(Debug)] pub struct TextLayoutData { pub position_data: Vec, pub paragraphs: Vec, } pub(crate) 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 = shape.selrect.x(); 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(); let plans = text_content.horizontal_plans(); // 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 (index, paragraph_builder_group) in paragraph_builder_groups.iter_mut().enumerate() { 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(plans[index].layout_width(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 x = x + plans[i].x_offset(&text_paragraphs[i], text_width); let decorations = text_paragraphs .get(i) .zip(plans.get(i)) .map(|(text_paragraph, plan)| { decoration_segments(&skia_paragraph, text_paragraph, &plan.offsets, x, y_accum) }) .unwrap_or_default(); paragraph_layouts.push(ParagraphLayout { paragraph: skia_paragraph, source_paragraph: i, x, y: y_accum, decorations, }); } y_accum += paragraph_heights[i]; } // Calculate position data from paragraph_layouts if !skip_position_data { for para_layout in ¶graph_layouts { let paragraph_index = para_layout.source_paragraph; if let (Some(text_para), Some(plan)) = ( text_paragraphs.get(paragraph_index), plans.get(paragraph_index), ) { let entries = HorizontalPositionEntries { paragraph_index, paragraph: text_para, offsets: &plan.offsets, layout: para_layout, }; position_data.extend(entries.collect()); } } } TextLayoutData { position_data, paragraphs: paragraph_layouts, } } /// Position data of one laid-out horizontal paragraph: strips of the spans /// outside warichu, one strip per warichu sub-line, and one box per /// emphasis mark. struct HorizontalPositionEntries<'a> { paragraph_index: usize, paragraph: &'a Paragraph, /// Ranges in builder-text (kinsoku-shifted) space; the map translates /// exported positions back to span offsets. offsets: &'a HorizontalOffsets, layout: &'a ParagraphLayout, } impl HorizontalPositionEntries<'_> { fn collect(&self) -> Vec { let mut entries = Vec::new(); // Tabs are placeholders too; this keys warichu boxes by span. let warichu_rects = super::text_horizontal::horizontal_warichu_placeholders( self.paragraph, &self.layout.paragraph, ); for range in &self.offsets.ranges { if range.warichu { let rect = warichu_rects .iter() .find(|(span, _)| *span == range.span) .map(|(_, rect)| *rect); if let Some(rect) = rect { entries.extend(self.warichu_entries(range, rect)); } } else { entries.extend(self.span_entries(range)); } } for mut entry in horizontal_zhuyin_entries(self.paragraph, self.offsets, &self.layout.paragraph) { entry.paragraph = self.paragraph_index as u32; entry.x += self.layout.x; entry.y += self.layout.y; entries.push(entry); } for placement in horizontal_emphasis_placements(self.paragraph, self.offsets, &self.layout.paragraph) { if let Some(span) = self.paragraph.children().get(placement.span) { entries.push(self.entry( placement.span, placement.range.clone(), horizontal_emphasis_mark_box(span, &placement), super::text_vertical::DIRECTION_EMPHASIS_MARK, )); } } entries } /// An entry for `range` of `span`, with `rect` in the laid-out paragraph. fn entry( &self, span: usize, range: std::ops::Range, mut rect: Rect, direction: u32, ) -> PositionData { rect.offset((self.layout.x, self.layout.y)); PositionData { paragraph: self.paragraph_index as u32, span: span as u32, start_pos: range.start as u32, end_pos: range.end as u32, x: rect.x(), y: rect.y(), width: rect.width(), height: rect.height(), direction, } } /// One strip per warichu sub-line: the top half of the placeholder holds /// the first line, the bottom half the second. fn warichu_entries(&self, range: &HorizontalSpanRange, rect: Rect) -> Vec { let Some(span) = self.paragraph.children().get(range.span) else { return Vec::new(); }; let text = span.apply_text_transform(); let split = warichu_text_lines(&text).0.encode_utf16().count(); let end = range.source_end - range.source_start; let half = rect.height() / 2.0; let ltr = direction_to_int(TextDirection::LTR); let top = Rect::from_xywh(rect.x(), rect.y(), rect.width(), half); let bottom = Rect::from_xywh(rect.x(), rect.y() + half, rect.width(), half); vec![ self.entry(range.span, 0..split, top, ltr), self.entry(range.span, split..end, bottom, ltr), ] } /// One strip per laid-out rect of a span, with its source offsets read /// from the glyphs at the rect's edges. fn span_entries(&self, range: &HorizontalSpanRange) -> Vec { let laid_out = &self.layout.paragraph; let to_span_offset = |builder_position: usize| { let within = builder_position .saturating_sub(range.builder_start) .min(range.builder_end - range.builder_start); self.offsets .offset_map .to_original(range.shifted_start + within) - range.source_start }; laid_out .get_rects_for_range( range.builder_start..range.builder_end, RectHeightStyle::Tight, RectWidthStyle::Tight, ) .into_iter() .map(|textbox| { let rect = textbox.rect; let cy = rect.top + rect.height() / 2.0; let position_at = |x: f32| laid_out.get_glyph_position_at_coordinate((x, cy)).position as usize; let start_pos = to_span_offset(position_at(rect.left + 0.1)); let end_pos = to_span_offset(position_at(rect.right - 0.1)); self.entry( range.span, start_pos..end_pos, rect, direction_to_int(textbox.direct), ) }) .collect() } } pub fn calculate_position_data( shape: &Shape, text_content: &TextContent, skip_position_data: bool, ) -> Vec { // Vertical writing generates position data from the vertical cells and // needs no horizontal layout. if text_content.is_vertical() { if skip_position_data { return Vec::new(); } let layout = text_content.vertical_layout(&shape.selrect); return super::text_vertical::position_data( &layout, &shape.selrect, shape.vertical_align(), ); } 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::*; use crate::globals::TestRenderResourcesGuard; use crate::render::RenderResources; fn vertical_content(text: &str, grow_type: GrowType) -> TextContent { let mut content = TextContent::new(Rect::from_xywh(10.0, 20.0, 100.0, 200.0), grow_type); let mut paragraph = test_paragraph(&[text]); paragraph.set_writing_mode(WritingMode::VerticalRl); paragraph.children_mut()[0].font_size = 20.0; content.add_paragraph(paragraph); content } #[test] fn vertical_auto_height_reflows_when_only_height_changes() { let mut resources = crate::render::RenderResources::try_new_headless().expect("headless resources"); let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources); let mut content = vertical_content("あいうえおかきくけこ", GrowType::AutoHeight); let tall = content.update_layout(Rect::from_xywh(10.0, 20.0, 100.0, 240.0)); let short = content.update_layout(Rect::from_xywh(10.0, 20.0, 100.0, 60.0)); assert!(short.width > tall.width); assert_eq!(short.height, 60.0); let restored = content.update_layout(Rect::from_xywh(10.0, 20.0, 100.0, 240.0)); assert_eq!(restored.width, tall.width); } #[test] fn vertical_height_modifier_requires_layout() { let mut resources = crate::render::RenderResources::try_new_headless().expect("headless resources"); let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources); let content = vertical_content("あいうえお", GrowType::AutoHeight); let shape = text_shape_with_cached_layout(content); assert!(modifier_changes_text_layout( &shape, &Matrix::scale((1.0, 0.5)) )); } #[test] fn empty_auto_height_preserves_the_wrap_budget() { let mut resources = crate::render::RenderResources::try_new_headless().expect("headless resources"); let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources); let mut vertical = vertical_content("", GrowType::AutoHeight); let size = vertical.update_layout(Rect::from_xywh(10.0, 20.0, 100.0, 200.0)); assert_eq!(size.height, 200.0); assert!(size.width >= 20.0); let mut horizontal = vertical_content("", GrowType::AutoHeight); horizontal.paragraphs_mut()[0].set_writing_mode(WritingMode::HorizontalTb); let size = horizontal.update_layout(Rect::from_xywh(10.0, 20.0, 100.0, 200.0)); assert_eq!(size.width, 100.0); } #[test] fn empty_fixed_text_preserves_its_container() { let mut resources = crate::render::RenderResources::try_new_headless().expect("headless resources"); let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources); let mut content = vertical_content("", GrowType::Fixed); let size = content.update_layout(Rect::from_xywh(10.0, 20.0, 100.0, 200.0)); assert_eq!((size.width, size.height), (100.0, 200.0)); } #[test] fn a_warichu_span_shows_no_ruby_and_reserves_no_room_for_it() { let span = TextSpan { text: "割注".to_string(), ruby: "よみ".to_string(), warichu: true, annotation_clearance: AnnotationClearance::Auto, ..TextSpan::default() }; assert!(!span.has_ruby()); assert_eq!(span.annotation_room_em(), 0.0); } #[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 ); } fn capitalize(text: &str) -> String { apply_text_transform_with_source_ranges( text, Browser::Chrome as u8, Some(TextTransform::Capitalize), ) .text } fn filter_ignored_chars(text: &str, browser: u8) -> String { apply_text_transform_with_source_ranges(text, browser, None).text } #[test] fn capitalize_basic_words() { assert_eq!(capitalize("hello world"), "Hello World"); } #[test] fn capitalize_preserves_leading_whitespace() { assert_eq!(capitalize(" hello"), " Hello"); } #[test] fn capitalize_preserves_trailing_whitespace() { assert_eq!(capitalize("hello "), "Hello "); } #[test] fn capitalize_preserves_multiple_spaces() { assert_eq!(capitalize("hello world"), "Hello World"); } #[test] fn capitalize_whitespace_only() { assert_eq!(capitalize(" "), " "); assert_eq!(capitalize(" "), " "); } #[test] fn capitalize_empty_string() { assert_eq!(capitalize(""), ""); } #[test] fn capitalize_single_char() { assert_eq!(capitalize("a"), "A"); } #[test] fn capitalize_already_uppercase() { assert_eq!(capitalize("HELLO WORLD"), "HELLO WORLD"); } #[test] fn capitalize_preserves_tabs_and_newlines() { assert_eq!(capitalize("hello\tworld"), "Hello\tWorld"); assert_eq!(capitalize("hello\nworld"), "Hello\nWorld"); } #[test] fn capitalize_after_punctuation() { assert_eq!(capitalize("(readonly)"), "(Readonly)"); assert_eq!(capitalize("hello-world"), "Hello-World"); assert_eq!(capitalize("one/two/three"), "One/Two/Three"); } #[test] fn capitalize_after_digits() { assert_eq!(capitalize("item1name"), "Item1Name"); } #[test] fn ignored_chars_preserves_spaces() { assert_eq!(filter_ignored_chars("hello world", 0), "hello world"); } #[test] fn ignored_chars_preserves_line_breaks() { assert_eq!(filter_ignored_chars("hello\nworld", 0), "hello\nworld"); assert_eq!(filter_ignored_chars("hello\rworld", 0), "hello\rworld"); } #[test] fn ignored_chars_preserves_tabs() { assert_eq!(filter_ignored_chars("hello\tworld", 0), "hello\tworld"); assert_eq!( filter_ignored_chars("hello\tworld", Browser::Firefox as u8), "hello\tworld" ); } #[test] fn ignored_chars_replaces_control_chars_chrome() { // U+0001 (SOH) should become space in non-Firefox assert_eq!(filter_ignored_chars("a\x01b", Browser::Chrome as u8), "a b"); } #[test] fn ignored_chars_removes_control_chars_firefox() { assert_eq!(filter_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!( HorizontalOffsets::new(¶).source_to_builder(offset), offset ); assert_eq!(horizontal_builder_to_source(¶, 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!(horizontal_builder_to_source(¶, 0), 0); assert_eq!(horizontal_builder_to_source(¶, 1), 1); assert_eq!(horizontal_builder_to_source(¶, 3), 2); assert_eq!(horizontal_builder_to_source(¶, 4), 3); } #[test] fn utf16_offsets_map_back_to_char_offsets() { let para = test_paragraph(&["a", "😀b"]); 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(2), 2); assert_eq!(para.utf16_offset_to_char(3), 2); assert_eq!(para.utf16_offset_to_char(4), 3); let mut para = test_paragraph(&["Straße"]); para.children_mut()[0].text_transform = Some(TextTransform::Uppercase); assert_eq!(para.utf16_offset_to_char(7), 6); } #[test] fn builder_offset_inside_a_surrogate_pair_rounds_to_a_char_boundary() { let para = test_paragraph(&["a😀b"]); assert_eq!(horizontal_builder_to_source(¶, 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_offset_to_utf16(5), 6); assert_eq!(HorizontalOffsets::new(¶).source_to_builder(4), 4); assert_eq!(HorizontalOffsets::new(¶).source_to_builder(5), 6); assert_eq!(HorizontalOffsets::new(¶).source_to_builder(6), 7); assert_eq!(horizontal_builder_to_source(¶, 7), 6); } #[test] fn grapheme_offsets_cross_whole_emojis() { let para = test_paragraph(&["A👍🏽👨\u{200d}👩\u{200d}👧e\u{301}"]); let boundaries = [0, 1, 3, 8, 10]; for pair in boundaries.windows(2) { assert_eq!(para.next_grapheme_offset(pair[0]), pair[1]); assert_eq!(para.prev_grapheme_offset(pair[1]), pair[0]); } assert_eq!(para.next_grapheme_offset(2), 3); assert_eq!(para.prev_grapheme_offset(2), 1); assert_eq!(para.next_grapheme_offset(10), 10); assert_eq!(para.prev_grapheme_offset(0), 0); } #[test] fn grapheme_offsets_cross_an_emoji_split_across_spans() { let para = test_paragraph(&["A👍", "🏽B"]); assert_eq!(para.next_grapheme_offset(1), 3); assert_eq!(para.prev_grapheme_offset(3), 1); } #[test] fn builder_range_covers_the_whole_glyph() { let para = test_paragraph(&["a😀b"]); let len_at = |offset| { HorizontalOffsets::new(¶).source_to_builder(offset + 1) - HorizontalOffsets::new(¶).source_to_builder(offset) }; assert_eq!(len_at(0), 1); assert_eq!(len_at(1), 2); assert_eq!(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 resources = crate::render::RenderResources::try_new_headless().expect("headless resources"); let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources); 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) ); } fn gradient_fill() -> shapes::Fill { shapes::Fill::LinearGradient(shapes::Gradient::new( (0.5, 0.0), (0.5, 1.0), 255, 1.0, &[(skia::Color::RED, 0.0), (skia::Color::BLUE, 1.0)], )) } fn solid_fill() -> shapes::Fill { shapes::Fill::Solid(shapes::SolidColor(skia::Color::RED)) } /// A laid-out text shape at 200x100 whose only span has `fills`. fn laid_out_text_shape(grow_type: GrowType, fills: Vec) -> Shape { let mut paragraph = test_paragraph(&["hello"]); paragraph.children_mut()[0].fills = fills; let mut content = TextContent::new(Rect::from_xywh(0.0, 0.0, 200.0, 100.0), grow_type); content.add_paragraph(paragraph); 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 } /// Whether resizing `shape` to `width`x`height`, with no content change, /// rebuilt its cached Skia paragraphs. fn resize_rebuilds_paragraphs(shape: &mut Shape, width: f32, height: f32) -> bool { let before = Rc::clone(&shape.get_text_content().layout.paragraphs); assert!(!before.is_empty(), "the shape should be laid out"); shape.set_selrect(0.0, 0.0, width, height); !Rc::ptr_eq(&before, &shape.get_text_content().layout.paragraphs) } // Gradient and image shaders are built into the cached paragraphs against // the box size, so a new box size must rebuild them. #[test] fn resizing_a_fixed_text_height_rebuilds_box_relative_fills() { let mut resources = RenderResources::try_new_headless().expect("headless resources"); let _guard = TestRenderResourcesGuard::install(&mut resources); let mut shape = laid_out_text_shape(GrowType::Fixed, vec![gradient_fill()]); assert!(resize_rebuilds_paragraphs(&mut shape, 200.0, 300.0)); } #[test] fn resizing_an_auto_width_text_rebuilds_box_relative_fills() { let mut resources = RenderResources::try_new_headless().expect("headless resources"); let _guard = TestRenderResourcesGuard::install(&mut resources); let mut shape = laid_out_text_shape(GrowType::AutoWidth, vec![gradient_fill()]); assert!(resize_rebuilds_paragraphs(&mut shape, 400.0, 300.0)); } #[test] fn resizing_a_fixed_text_height_keeps_the_cache_for_solid_fills() { let mut resources = RenderResources::try_new_headless().expect("headless resources"); let _guard = TestRenderResourcesGuard::install(&mut resources); let mut shape = laid_out_text_shape(GrowType::Fixed, vec![solid_fill()]); assert!(!resize_rebuilds_paragraphs(&mut shape, 200.0, 300.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()); } #[test] fn transformed_text_maps_expanded_scalars_to_their_source_range() { let transformed = apply_text_transform_with_source_ranges( "AßB", Browser::Chrome as u8, Some(TextTransform::Uppercase), ); assert_eq!(transformed.text, "ASSB"); assert_eq!(transformed.source_utf16_range(0..1), 0..1); assert_eq!(transformed.source_utf16_range(1..2), 1..2); assert_eq!(transformed.source_utf16_range(2..3), 1..2); assert_eq!(transformed.source_utf16_range(1..3), 1..2); assert_eq!(transformed.source_utf16_range(3..4), 2..3); } }