mirror of
https://github.com/penpot/penpot.git
synced 2026-08-27 07:09:14 +00:00
2369 lines
82 KiB
Rust
2369 lines
82 KiB
Rust
use crate::render::text::calculate_decoration_metrics;
|
||
use crate::{
|
||
math::{Bounds, Matrix, Rect},
|
||
render::{default_font, DEFAULT_EMOJI_FONT},
|
||
utils::Browser,
|
||
};
|
||
|
||
use core::f32;
|
||
use macros::ToJs;
|
||
use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
|
||
use skia_safe::{
|
||
self as skia,
|
||
paint::{self, Paint},
|
||
textlayout::Affinity,
|
||
textlayout::ParagraphBuilder,
|
||
textlayout::ParagraphStyle,
|
||
textlayout::PositionWithAffinity,
|
||
Contains,
|
||
};
|
||
|
||
use std::cell::Cell;
|
||
use std::collections::HashSet;
|
||
|
||
use super::text_japanese::*;
|
||
use super::FontFamily;
|
||
use crate::math::Point;
|
||
use crate::shapes::{self, kinsoku, merge_fills, Shape, 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<ParagraphBuilder>;
|
||
|
||
#[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;
|
||
|
||
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_without_affinity(paragraph: usize, offset: usize) -> Self {
|
||
Self {
|
||
position_with_affinity: PositionWithAffinity {
|
||
position: offset as i32,
|
||
affinity: Affinity::Downstream,
|
||
},
|
||
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<ParagraphBuilderGroup>,
|
||
Vec<Vec<skia::textlayout::Paragraph>>,
|
||
TextContentSize,
|
||
);
|
||
|
||
/// Cached extrect stored as offsets from the selrect origin,
|
||
/// keyed by the selrect dimensions (width, height) and vertical alignment
|
||
/// used to compute it.
|
||
#[derive(Debug, Clone, Copy)]
|
||
struct CachedExtrect {
|
||
selrect_width: f32,
|
||
selrect_height: f32,
|
||
valign: u8,
|
||
left: f32,
|
||
top: f32,
|
||
right: f32,
|
||
bottom: f32,
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
pub struct TextContentLayout {
|
||
pub paragraph_builders: Vec<ParagraphBuilderGroup>,
|
||
pub paragraphs: Vec<Vec<skia::textlayout::Paragraph>>,
|
||
cached_extrect: Cell<Option<CachedExtrect>>,
|
||
}
|
||
|
||
impl Default for TextContentLayout {
|
||
fn default() -> Self {
|
||
Self::new()
|
||
}
|
||
}
|
||
|
||
impl Clone for TextContentLayout {
|
||
fn clone(&self) -> Self {
|
||
Self {
|
||
paragraph_builders: vec![],
|
||
paragraphs: vec![],
|
||
cached_extrect: Cell::new(None),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl PartialEq for TextContentLayout {
|
||
fn eq(&self, _other: &Self) -> bool {
|
||
true
|
||
}
|
||
}
|
||
|
||
impl TextContentLayout {
|
||
pub fn new() -> Self {
|
||
Self {
|
||
paragraph_builders: vec![],
|
||
paragraphs: vec![],
|
||
cached_extrect: Cell::new(None),
|
||
}
|
||
}
|
||
|
||
pub fn set(
|
||
&mut self,
|
||
paragraph_builders: Vec<ParagraphBuilderGroup>,
|
||
paragraphs: Vec<Vec<skia::textlayout::Paragraph>>,
|
||
) {
|
||
self.paragraph_builders = paragraph_builders;
|
||
self.paragraphs = paragraphs;
|
||
self.cached_extrect.set(None);
|
||
}
|
||
|
||
pub fn needs_update(&self) -> bool {
|
||
self.paragraph_builders.is_empty() || 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,
|
||
}
|
||
|
||
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<Item = &'a skia::textlayout::Paragraph>,
|
||
x_pos: f32,
|
||
y_pos: f32,
|
||
) -> bool {
|
||
paragraphs
|
||
.scan(0.0_f32, |height, p| {
|
||
let prev_height = *height;
|
||
*height += p.height();
|
||
Some((prev_height, p))
|
||
})
|
||
.any(|(height, p)| intersects(p, x_pos, y_pos - height))
|
||
}
|
||
|
||
// Performs a text auto layout without width limits.
|
||
// This should be the same as text_auto_layout.
|
||
pub fn build_paragraphs_from_paragraph_builders(
|
||
paragraph_builders: &mut [ParagraphBuilderGroup],
|
||
width: f32,
|
||
) -> Vec<Vec<skia::textlayout::Paragraph>> {
|
||
let paragraphs = paragraph_builders
|
||
.iter_mut()
|
||
.map(|builders| {
|
||
builders
|
||
.iter_mut()
|
||
.map(|builder| {
|
||
let mut paragraph = builder.build();
|
||
// For auto-width, always layout with infinite width first to get intrinsic width
|
||
paragraph.layout(width);
|
||
paragraph
|
||
})
|
||
.collect()
|
||
})
|
||
.collect();
|
||
paragraphs
|
||
}
|
||
|
||
/// Calculate the normalized line height from paragraph builders
|
||
pub fn calculate_normalized_line_height(
|
||
paragraph_builders: &mut [ParagraphBuilderGroup],
|
||
width: f32,
|
||
) -> f32 {
|
||
let mut normalized_line_height = 0.0;
|
||
for paragraph_builder_group in paragraph_builders.iter_mut() {
|
||
for paragraph_builder in paragraph_builder_group.iter_mut() {
|
||
let mut paragraph = paragraph_builder.build();
|
||
paragraph.layout(width);
|
||
let baseline = paragraph.ideographic_baseline();
|
||
if baseline > normalized_line_height {
|
||
normalized_line_height = baseline;
|
||
}
|
||
}
|
||
}
|
||
normalized_line_height
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
pub struct TextContent {
|
||
pub paragraphs: Vec<Paragraph>,
|
||
pub bounds: Rect,
|
||
pub grow_type: GrowType,
|
||
pub size: TextContentSize,
|
||
pub layout: TextContentLayout,
|
||
content_version: u64,
|
||
layout_version: u64,
|
||
layout_width: Option<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,
|
||
size: TextContentSize::default(),
|
||
layout: TextContentLayout::new(),
|
||
content_version: 0,
|
||
layout_version: 0,
|
||
layout_width: None,
|
||
}
|
||
}
|
||
|
||
pub fn new_bounds(&self, bounds: Rect) -> Self {
|
||
let paragraphs = self.paragraphs.clone();
|
||
let grow_type = self.grow_type;
|
||
Self {
|
||
paragraphs,
|
||
bounds,
|
||
grow_type,
|
||
size: TextContentSize::new_with_size(bounds.width(), bounds.height()),
|
||
layout: TextContentLayout::new(),
|
||
content_version: 0,
|
||
layout_version: 0,
|
||
layout_width: None,
|
||
}
|
||
}
|
||
|
||
pub fn bounds(&self) -> Rect {
|
||
self.bounds
|
||
}
|
||
|
||
pub fn set_xywh(&mut self, x: f32, y: f32, w: f32, h: f32) {
|
||
self.bounds = Rect::from_xywh(x, y, w, h);
|
||
}
|
||
|
||
pub fn add_paragraph(&mut self, paragraph: Paragraph) {
|
||
self.paragraphs.push(paragraph);
|
||
self.content_version = self.content_version.wrapping_add(1);
|
||
}
|
||
|
||
pub fn paragraphs(&self) -> &[Paragraph] {
|
||
&self.paragraphs
|
||
}
|
||
|
||
pub fn has_non_ascii(&self) -> bool {
|
||
self.paragraphs
|
||
.iter()
|
||
.flat_map(|p| p.children())
|
||
.any(|span| !span.text.is_ascii())
|
||
}
|
||
|
||
pub fn paragraphs_mut(&mut self) -> &mut Vec<Paragraph> {
|
||
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
|
||
}
|
||
|
||
/// Writing mode is a whole-shape property: the first paragraph
|
||
/// decides the flow for all of them.
|
||
pub fn is_vertical(&self) -> bool {
|
||
self.paragraphs
|
||
.first()
|
||
.is_some_and(|p| p.writing_mode() == WritingMode::VerticalRl)
|
||
}
|
||
|
||
pub fn grow_type(&self) -> GrowType {
|
||
self.grow_type
|
||
}
|
||
|
||
pub fn set_grow_type(&mut self, grow_type: GrowType) {
|
||
if self.grow_type != grow_type {
|
||
self.grow_type = grow_type;
|
||
self.content_version = self.content_version.wrapping_add(1);
|
||
}
|
||
}
|
||
|
||
/// 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<Rect> {
|
||
let paragraphs = &self.layout.paragraphs;
|
||
let x = selrect.x();
|
||
let base_y = selrect.y();
|
||
|
||
let total_height: f32 = paragraphs
|
||
.iter()
|
||
.filter_map(|group| group.first())
|
||
.map(|p| p.height())
|
||
.sum();
|
||
|
||
let vertical_offset = vertical_align_offset(selrect.height(), total_height, valign);
|
||
|
||
let mut min_x = f32::MAX;
|
||
let mut min_y = f32::MAX;
|
||
let mut max_x = f32::MIN;
|
||
let mut max_y = f32::MIN;
|
||
let mut has_lines = false;
|
||
let mut y_accum = base_y + vertical_offset;
|
||
|
||
for group in paragraphs {
|
||
if let Some(paragraph) = group.first() {
|
||
let line_metrics = paragraph.get_line_metrics();
|
||
for line in &line_metrics {
|
||
let line_baseline = y_accum + line.baseline as f32;
|
||
|
||
// Use per-glyph fm.top for tighter vertical bounds when
|
||
// available; fall back to line-level ascent for empty lines
|
||
// (where get_style_metrics returns nothing).
|
||
let style_metrics = line.get_style_metrics(line.start_index..line.end_index);
|
||
if style_metrics.is_empty() {
|
||
min_y = min_y.min(line_baseline - line.ascent as f32);
|
||
} else {
|
||
for (_start, style_metric) in &style_metrics {
|
||
let fm = &style_metric.font_metrics;
|
||
min_y = min_y.min(line_baseline + fm.top);
|
||
}
|
||
}
|
||
|
||
// Bottom uses line-level descent (includes descender space
|
||
// for the whole line, not just present glyphs).
|
||
max_y = max_y.max(line_baseline + line.descent as f32);
|
||
min_x = min_x.min(x + line.left as f32);
|
||
max_x = max_x.max(x + line.left as f32 + line.width as f32);
|
||
has_lines = true;
|
||
}
|
||
y_accum += paragraph.height();
|
||
}
|
||
}
|
||
|
||
if has_lines {
|
||
Some(Rect::from_ltrb(min_x, min_y, max_x, max_y))
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
fn compute_and_cache_extrect(
|
||
&self,
|
||
shape: &Shape,
|
||
selrect: &Rect,
|
||
valign: VerticalAlign,
|
||
) -> Rect {
|
||
// AutoWidth paragraphs are laid out with f32::MAX, so line metrics
|
||
// (line.left) reflect alignment within that huge width and are
|
||
// unusable for tight bounds. Fall back to content_rect.
|
||
// Vertical writing bounds come from the vertical pass through
|
||
// content_rect; the skparagraph line metrics below describe the
|
||
// unused horizontal layout.
|
||
if self.grow_type() == GrowType::AutoWidth || self.is_vertical() {
|
||
return self.content_rect(selrect, valign);
|
||
}
|
||
|
||
let tight = if !self.layout.paragraphs.is_empty() {
|
||
self.rect_from_paragraphs(selrect, valign)
|
||
} else {
|
||
let mut text_content = self.clone();
|
||
text_content.update_layout(shape.selrect);
|
||
text_content.rect_from_paragraphs(selrect, valign)
|
||
}
|
||
.unwrap_or_else(|| self.content_rect(selrect, valign));
|
||
|
||
// Cache as offsets from selrect origin so it's position-independent.
|
||
let sx = selrect.x();
|
||
let sy = selrect.y();
|
||
self.layout.cached_extrect.set(Some(CachedExtrect {
|
||
selrect_width: selrect.width(),
|
||
selrect_height: selrect.height(),
|
||
valign: valign as u8,
|
||
left: tight.left() - sx,
|
||
top: tight.top() - sy,
|
||
right: tight.right() - sx,
|
||
bottom: tight.bottom() - sy,
|
||
}));
|
||
|
||
tight
|
||
}
|
||
|
||
pub fn calculate_bounds(&self, shape: &Shape, apply_transform: bool) -> Bounds {
|
||
let transform = &shape.transform;
|
||
let center = &shape.center();
|
||
let selrect = shape.selrect();
|
||
let valign = shape.vertical_align();
|
||
let sw = selrect.width();
|
||
let sh = selrect.height();
|
||
let sx = selrect.x();
|
||
let sy = selrect.y();
|
||
|
||
// Try the cache first: if dimensions and valign match, just apply position offset.
|
||
let text_rect = if let Some(cached) = self.layout.cached_extrect.get() {
|
||
if (cached.selrect_width - sw).abs() < 0.1
|
||
&& (cached.selrect_height - sh).abs() < 0.1
|
||
&& cached.valign == valign as u8
|
||
{
|
||
Rect::from_ltrb(
|
||
sx + cached.left,
|
||
sy + cached.top,
|
||
sx + cached.right,
|
||
sy + cached.bottom,
|
||
)
|
||
} else {
|
||
self.compute_and_cache_extrect(shape, &selrect, valign)
|
||
}
|
||
} else {
|
||
self.compute_and_cache_extrect(shape, &selrect, valign)
|
||
};
|
||
|
||
let mut bounds = Bounds::new(
|
||
Point::new(text_rect.x(), text_rect.y()),
|
||
Point::new(text_rect.x() + text_rect.width(), text_rect.y()),
|
||
Point::new(
|
||
text_rect.x() + text_rect.width(),
|
||
text_rect.y() + text_rect.height(),
|
||
),
|
||
Point::new(text_rect.x(), text_rect.y() + text_rect.height()),
|
||
);
|
||
|
||
if apply_transform && !transform.is_identity() {
|
||
let mut matrix = *transform;
|
||
matrix.post_translate(*center);
|
||
matrix.pre_translate(-*center);
|
||
bounds.transform_mut(&matrix);
|
||
}
|
||
|
||
bounds
|
||
}
|
||
|
||
pub fn content_rect(&self, selrect: &Rect, valign: VerticalAlign) -> Rect {
|
||
// Vertical content anchors to the shape's right edge and always
|
||
// aligns to the top (vertical-align along columns is deferred).
|
||
if self.is_vertical() {
|
||
let (width, height) = if self.grow_type() == GrowType::AutoWidth {
|
||
(self.size.width, self.size.height)
|
||
} else {
|
||
(selrect.width(), selrect.height())
|
||
};
|
||
return Rect::from_xywh(selrect.right() - width, 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);
|
||
}
|
||
|
||
pub fn get_caret_position_from_shape_coords(
|
||
&self,
|
||
point: &Point,
|
||
vertical_align: VerticalAlign,
|
||
) -> Option<TextPositionWithAffinity> {
|
||
// Vertical writing: resolve through the vertical pass. The point
|
||
// arrives selrect-local; the content block is right-anchored.
|
||
if self.is_vertical() {
|
||
let bounds = self.bounds();
|
||
let max_height = super::text_vertical::wrap_height(self, bounds.height());
|
||
let layout = super::text_vertical::layout_from_content(self, max_height);
|
||
let cx = point.x
|
||
- super::text_vertical::block_axis_offset(
|
||
bounds.width(),
|
||
layout.width,
|
||
vertical_align,
|
||
);
|
||
let (paragraph, offset) = super::text_vertical::caret_from_point(&layout, cx, point.y)?;
|
||
return Some(TextPositionWithAffinity::new_without_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 para_pt = Point::new(point.x, 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_without_affinity(
|
||
paragraph_index,
|
||
original_position,
|
||
));
|
||
}
|
||
}
|
||
let mut position_with_affinity =
|
||
layout_paragraph.get_glyph_position_at_coordinate((para_pt.x, para_pt.y));
|
||
if let Some(paragraph) = self.paragraphs().get(paragraph_index) {
|
||
// The laid-out paragraph reports offsets in the
|
||
// builder-text (kinsoku-shifted) space; translate
|
||
// back to original text offsets.
|
||
let original_position = horizontal_builder_to_source(
|
||
paragraph,
|
||
position_with_affinity.position as usize,
|
||
);
|
||
position_with_affinity.position = original_position as i32;
|
||
|
||
// Computed position keeps the current position in terms
|
||
// of number of characters of text. This is used to know
|
||
// in which span we are.
|
||
let mut computed_position: usize = 0;
|
||
|
||
// If paragraph has no spans, default to span 0, offset 0
|
||
if !paragraph.children().is_empty() {
|
||
for span in paragraph.children() {
|
||
let length = span.text.chars().count();
|
||
let start_position = computed_position;
|
||
let end_position = computed_position + length;
|
||
let current_position = original_position;
|
||
|
||
// Handle empty spans: if the span is empty and current position
|
||
// matches the start, this is the right span
|
||
if length == 0 && current_position == start_position {
|
||
break;
|
||
}
|
||
|
||
if start_position <= current_position
|
||
&& end_position >= current_position
|
||
{
|
||
break;
|
||
}
|
||
computed_position += length;
|
||
}
|
||
}
|
||
|
||
return Some(TextPositionWithAffinity::new(
|
||
position_with_affinity,
|
||
paragraph_index,
|
||
original_position,
|
||
));
|
||
}
|
||
}
|
||
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,
|
||
vertical_align: VerticalAlign,
|
||
) -> Option<TextPositionWithAffinity> {
|
||
let shape_rel_point = Shape::get_relative_point(point, view_matrix, shape_matrix)?;
|
||
self.get_caret_position_from_shape_coords(&shape_rel_point, vertical_align)
|
||
}
|
||
|
||
/// Builds the ParagraphBuilders necessary to render
|
||
/// this text.
|
||
pub fn paragraph_builder_group_from_text(
|
||
&self,
|
||
use_shadow: Option<bool>,
|
||
) -> Vec<ParagraphBuilderGroup> {
|
||
let fonts = get_font_collection();
|
||
let fallback_fonts = get_fallback_fonts();
|
||
let mut paragraph_group = Vec::new();
|
||
|
||
for paragraph in self.paragraphs() {
|
||
let paragraph_style = paragraph.paragraph_to_style();
|
||
let mut builder = ParagraphBuilder::new(¶graph_style, fonts);
|
||
let mut has_text = false;
|
||
let (span_texts, _) = paragraph.layout_span_texts();
|
||
for (span, text) in paragraph.children().iter().zip(span_texts.iter()) {
|
||
let remove_alpha = use_shadow.unwrap_or(false) && !span.is_transparent();
|
||
let text_style = span.to_style(
|
||
&self.bounds(),
|
||
fallback_fonts,
|
||
remove_alpha,
|
||
paragraph.line_height(),
|
||
);
|
||
if !text.is_empty() {
|
||
has_text = true;
|
||
}
|
||
builder.push_style(&text_style);
|
||
add_horizontal_span(&mut builder, span, text, &text_style, fonts);
|
||
}
|
||
if !has_text {
|
||
builder.add_text(" ");
|
||
}
|
||
paragraph_group.push(vec![builder]);
|
||
}
|
||
|
||
paragraph_group
|
||
}
|
||
|
||
/// 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<ParagraphBuilderGroup> {
|
||
let fonts = get_font_collection();
|
||
let fallback_fonts = get_fallback_fonts();
|
||
let mut paragraph_group = Vec::new();
|
||
|
||
for paragraph in self.paragraphs() {
|
||
let paragraph_style = paragraph.paragraph_to_style();
|
||
let mut builder = ParagraphBuilder::new(¶graph_style, fonts);
|
||
let mut has_text = false;
|
||
let (span_texts, _) = paragraph.layout_span_texts();
|
||
for (span, text) in paragraph.children().iter().zip(span_texts.iter()) {
|
||
let text_style = span.to_style(
|
||
&self.bounds(),
|
||
fallback_fonts,
|
||
true, // always opaque
|
||
paragraph.line_height(),
|
||
);
|
||
if !text.is_empty() {
|
||
has_text = true;
|
||
}
|
||
builder.push_style(&text_style);
|
||
add_horizontal_span(&mut builder, span, text, &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 {
|
||
let mut paragraph_builders = self.paragraph_builder_group_from_text(None);
|
||
|
||
let normalized_line_height =
|
||
calculate_normalized_line_height(&mut paragraph_builders, f32::MAX);
|
||
|
||
let paragraphs =
|
||
build_paragraphs_from_paragraph_builders(&mut paragraph_builders, f32::MAX);
|
||
|
||
let (width, height) =
|
||
paragraphs
|
||
.iter()
|
||
.flatten()
|
||
.fold((0.0, 0.0), |(auto_width, auto_height), paragraph| {
|
||
(
|
||
f32::max(paragraph.longest_line(), auto_width),
|
||
auto_height + paragraph.height(),
|
||
)
|
||
});
|
||
|
||
let size = TextContentSize::new_with_normalized_line_height(
|
||
width.ceil(),
|
||
height.ceil(),
|
||
width.ceil(),
|
||
normalized_line_height,
|
||
);
|
||
TextContentLayoutResult(paragraph_builders, paragraphs, size)
|
||
}
|
||
|
||
/// Private function that performs
|
||
/// Performs an Auto Height text layout.
|
||
fn text_layout_auto_height(&self) -> TextContentLayoutResult {
|
||
let width = self.width();
|
||
let mut paragraph_builders = self.paragraph_builder_group_from_text(None);
|
||
|
||
let normalized_line_height =
|
||
calculate_normalized_line_height(&mut paragraph_builders, width);
|
||
|
||
let paragraphs = build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width);
|
||
let height = paragraphs
|
||
.iter()
|
||
.flatten()
|
||
.fold(0.0, |auto_height, paragraph| {
|
||
auto_height + paragraph.height()
|
||
});
|
||
let size = TextContentSize::new_with_normalized_line_height(
|
||
width,
|
||
height.ceil(),
|
||
DEFAULT_TEXT_CONTENT_SIZE,
|
||
normalized_line_height,
|
||
);
|
||
TextContentLayoutResult(paragraph_builders, paragraphs, size)
|
||
}
|
||
|
||
/// Performs a Fixed text layout.
|
||
fn text_layout_fixed(&self) -> TextContentLayoutResult {
|
||
let width = self.width();
|
||
let mut paragraph_builders = self.paragraph_builder_group_from_text(None);
|
||
|
||
let normalized_line_height =
|
||
calculate_normalized_line_height(&mut paragraph_builders, width);
|
||
|
||
let paragraphs = build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width);
|
||
let paragraph_height = paragraphs
|
||
.iter()
|
||
.flatten()
|
||
.fold(0.0, |auto_height, paragraph| {
|
||
auto_height + paragraph.height()
|
||
});
|
||
|
||
let size = TextContentSize::new_with_normalized_line_height(
|
||
width.ceil(),
|
||
paragraph_height.ceil(),
|
||
DEFAULT_TEXT_CONTENT_SIZE,
|
||
normalized_line_height,
|
||
);
|
||
TextContentLayoutResult(paragraph_builders, paragraphs, size)
|
||
}
|
||
|
||
pub fn get_width(&self, width: f32) -> f32 {
|
||
if self.grow_type() == GrowType::AutoWidth {
|
||
self.size.width
|
||
} else {
|
||
width
|
||
}
|
||
}
|
||
|
||
pub fn get_height(&self, width: f32) -> f32 {
|
||
let mut paragraph_builders = self.paragraph_builder_group_from_text(None);
|
||
let paragraphs = build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width);
|
||
let paragraph_height = paragraphs
|
||
.iter()
|
||
.flatten()
|
||
.fold(0.0, |auto_height, paragraph| {
|
||
auto_height + paragraph.height()
|
||
});
|
||
paragraph_height
|
||
}
|
||
|
||
pub fn needs_update_layout(&self) -> bool {
|
||
self.layout.needs_update()
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
pub fn force_next_layout_update(&mut self) {
|
||
self.layout_width = None;
|
||
self.layout.cached_extrect.set(None);
|
||
}
|
||
|
||
pub fn update_layout(&mut self, selrect: Rect) -> TextContentSize {
|
||
if !self.layout.needs_update()
|
||
&& self.layout_version == self.content_version
|
||
&& self
|
||
.layout_width
|
||
.is_some_and(|w| (w - selrect.width()).abs() < f32::EPSILON)
|
||
{
|
||
return self.size;
|
||
}
|
||
|
||
self.size.set_size(selrect.width(), selrect.height());
|
||
|
||
match self.grow_type() {
|
||
GrowType::AutoHeight => {
|
||
let result = self.text_layout_auto_height();
|
||
self.layout_width = Some(result.2.width);
|
||
self.set_layout_from_result(result, selrect.width(), selrect.height());
|
||
}
|
||
GrowType::AutoWidth => {
|
||
let result = self.text_layout_auto_width();
|
||
self.layout_width = Some(result.2.width);
|
||
self.set_layout_from_result(result, selrect.width(), selrect.height());
|
||
}
|
||
GrowType::Fixed => {
|
||
let result = self.text_layout_fixed();
|
||
self.layout_width = Some(result.2.width);
|
||
self.set_layout_from_result(result, selrect.width(), selrect.height());
|
||
}
|
||
}
|
||
|
||
// Vertical writing sizes come from the vertical pass. Auto-width
|
||
// fits both axes without wrapping. Auto-height keeps the shape height
|
||
// as its wrap budget and grows width as columns advance right-to-left.
|
||
// Fixed keeps both shape dimensions.
|
||
if self.is_vertical() {
|
||
match self.grow_type() {
|
||
GrowType::AutoWidth => {
|
||
let max_height = super::text_vertical::wrap_height(self, selrect.height());
|
||
let (width, height) = super::text_vertical::measure_content(self, max_height);
|
||
self.size.width = width.ceil().max(DEFAULT_TEXT_CONTENT_SIZE);
|
||
self.size.height = height.ceil().max(DEFAULT_TEXT_CONTENT_SIZE);
|
||
self.size.max_width = self.size.width;
|
||
}
|
||
GrowType::AutoHeight => {
|
||
let max_height = super::text_vertical::wrap_height(self, selrect.height());
|
||
let (width, _) = super::text_vertical::measure_content(self, max_height);
|
||
self.size.width = width.ceil().max(DEFAULT_TEXT_CONTENT_SIZE);
|
||
self.size.height = selrect.height();
|
||
self.size.max_width = self.size.width;
|
||
}
|
||
GrowType::Fixed => {
|
||
self.size.set_size(selrect.width(), selrect.height());
|
||
}
|
||
}
|
||
}
|
||
|
||
if self.is_empty() {
|
||
let (placeholder_width, placeholder_height) = self.placeholder_dimensions(selrect);
|
||
self.size.width = placeholder_width;
|
||
self.size.height = placeholder_height;
|
||
self.size.max_width = placeholder_width;
|
||
}
|
||
|
||
self.layout_version = self.content_version;
|
||
self.size
|
||
}
|
||
|
||
/// Return true when the content represents a freshly created empty text.
|
||
/// We consider it empty only if there is exactly one paragraph with a single
|
||
/// span whose text buffer is empty. Any additional paragraphs or characters
|
||
/// mean the user has already entered content.
|
||
fn is_empty(&self) -> bool {
|
||
if self.paragraphs.len() != 1 {
|
||
return false;
|
||
}
|
||
|
||
let paragraph = match self.paragraphs.first() {
|
||
Some(paragraph) => paragraph,
|
||
None => return true,
|
||
};
|
||
if paragraph.children().len() != 1 {
|
||
return false;
|
||
}
|
||
|
||
let span = match paragraph.children().first() {
|
||
Some(span) => span,
|
||
None => return true,
|
||
};
|
||
|
||
span.text.is_empty()
|
||
}
|
||
|
||
/// Compute the placeholder size used while the text is still empty. We ask
|
||
/// Skia to measure a single glyph using the span's typography so the editor
|
||
/// shows a caret-sized box that reflects the selected font, size and spacing.
|
||
/// If that fails we fall back to the previous WASM size or the incoming
|
||
/// selrect dimensions.
|
||
fn placeholder_dimensions(&self, selrect: Rect) -> (f32, f32) {
|
||
if let Some(paragraph) = self.paragraphs.first() {
|
||
if let Some(span) = paragraph.children().first() {
|
||
let fonts = get_font_collection();
|
||
let fallback_fonts = get_fallback_fonts();
|
||
let paragraph_style = paragraph.paragraph_to_style();
|
||
let mut builder = ParagraphBuilder::new(¶graph_style, fonts);
|
||
|
||
let text_style = span.to_style(
|
||
&self.bounds(),
|
||
fallback_fonts,
|
||
false,
|
||
paragraph.line_height(),
|
||
);
|
||
|
||
builder.push_style(&text_style);
|
||
builder.add_text("0");
|
||
|
||
let mut paragraph_layout = builder.build();
|
||
paragraph_layout.layout(f32::MAX);
|
||
|
||
let width = paragraph_layout.max_intrinsic_width();
|
||
let height = paragraph_layout.height();
|
||
|
||
return (width, height);
|
||
}
|
||
}
|
||
|
||
let fallback_width = selrect.width().max(self.size.width);
|
||
let fallback_height = selrect.height().max(self.size.height);
|
||
|
||
(fallback_width, fallback_height)
|
||
}
|
||
|
||
#[allow(dead_code)]
|
||
pub fn intersect_position_in_shape(&self, shape: &Shape, x_pos: f32, y_pos: f32) -> bool {
|
||
let rect = shape.selrect;
|
||
let mut matrix = Matrix::new_identity();
|
||
let center = shape.center();
|
||
let Some(inv_transform) = &shape.transform.invert() else {
|
||
return false;
|
||
};
|
||
matrix.pre_translate(center);
|
||
matrix.pre_concat(inv_transform);
|
||
matrix.pre_translate(-center);
|
||
|
||
let result = matrix.map_point((x_pos, y_pos));
|
||
|
||
let x_pos = result.x;
|
||
let y_pos = result.y;
|
||
|
||
x_pos >= rect.x() && x_pos <= rect.right() && y_pos >= rect.y() && y_pos <= rect.bottom()
|
||
}
|
||
|
||
pub fn intersect_position_in_text(&self, shape: &Shape, x_pos: f32, y_pos: f32) -> bool {
|
||
let rect = self.content_rect(&shape.selrect, shape.vertical_align);
|
||
let mut matrix = Matrix::new_identity();
|
||
let center = shape.center();
|
||
let Some(inv_transform) = &shape.transform.invert() else {
|
||
return false;
|
||
};
|
||
matrix.pre_translate(center);
|
||
matrix.pre_concat(inv_transform);
|
||
matrix.pre_translate(-center);
|
||
|
||
let result = matrix.map_point((x_pos, y_pos));
|
||
|
||
// Vertical writing: hit-test against the laid-out cells directly
|
||
// (absolute coordinates, right-anchored to the selrect).
|
||
if self.is_vertical() {
|
||
let max_height = super::text_vertical::wrap_height(self, shape.selrect.height());
|
||
let layout = super::text_vertical::layout_from_content(self, max_height);
|
||
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();
|
||
|
||
if !self.layout.paragraphs.is_empty() {
|
||
// Reuse stored laid-out paragraphs
|
||
paragraph_intersects(
|
||
self.layout
|
||
.paragraphs
|
||
.iter()
|
||
.flat_map(|group| group.first()),
|
||
x_pos,
|
||
y_pos,
|
||
)
|
||
} else {
|
||
let width = self.width();
|
||
let mut paragraph_builders = self.paragraph_builder_group_from_text(None);
|
||
let paragraphs =
|
||
build_paragraphs_from_paragraph_builders(&mut paragraph_builders, width);
|
||
|
||
paragraph_intersects(paragraphs.iter().flatten(), x_pos, y_pos)
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Default for TextContent {
|
||
fn default() -> Self {
|
||
Self {
|
||
paragraphs: vec![],
|
||
bounds: Rect::default(),
|
||
grow_type: GrowType::Fixed,
|
||
size: TextContentSize::default(),
|
||
layout: TextContentLayout::new(),
|
||
content_version: 0,
|
||
layout_version: 0,
|
||
layout_width: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
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<TextDecoration>,
|
||
text_transform: Option<TextTransform>,
|
||
writing_mode: WritingMode,
|
||
text_orientation: TextOrientation,
|
||
line_height: f32,
|
||
letter_spacing: f32,
|
||
children: Vec<TextSpan>,
|
||
}
|
||
|
||
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(),
|
||
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<TextDecoration>,
|
||
text_transform: Option<TextTransform>,
|
||
line_height: f32,
|
||
letter_spacing: f32,
|
||
children: Vec<TextSpan>,
|
||
) -> Self {
|
||
Self {
|
||
text_align,
|
||
text_direction,
|
||
text_decoration,
|
||
text_transform,
|
||
writing_mode: WritingMode::default(),
|
||
text_orientation: TextOrientation::default(),
|
||
line_height,
|
||
letter_spacing,
|
||
children,
|
||
}
|
||
}
|
||
|
||
pub fn writing_mode(&self) -> WritingMode {
|
||
self.writing_mode
|
||
}
|
||
|
||
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<TextSpan> {
|
||
&mut self.children
|
||
}
|
||
|
||
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<TextDecoration> {
|
||
self.text_decoration
|
||
}
|
||
|
||
pub fn text_transform(&self) -> Option<TextTransform> {
|
||
self.text_transform
|
||
}
|
||
|
||
/// Span texts as fed to the paragraph builders: text-transform applied,
|
||
/// Japanese spacing normalized, and kinsoku break suppressions inserted,
|
||
/// plus the map between original and builder-text UTF-16 offsets. Every
|
||
/// consumer of laid-out offsets must translate through the map. The layout
|
||
/// transform is skipped under letter-spacing, where skparagraph would add
|
||
/// letter spacing to synthetic layout characters.
|
||
pub fn layout_span_texts(&self) -> (Vec<String>, 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(true);
|
||
style.set_apply_rounding_hack(true);
|
||
style.set_text_height_behavior(skia::textlayout::TextHeightBehavior::All);
|
||
style
|
||
}
|
||
|
||
pub fn scale_content(&mut self, value: f32) {
|
||
self.letter_spacing *= value;
|
||
self.children
|
||
.iter_mut()
|
||
.for_each(|l| l.scale_content(value));
|
||
}
|
||
}
|
||
|
||
/// Capitalize the first letter of each word, preserving all original whitespace.
|
||
/// Matches CSS `text-transform: capitalize` behavior: a "word" starts after
|
||
/// any non-letter character (whitespace, punctuation, digits, symbols).
|
||
#[cfg(test)]
|
||
fn capitalize_words(text: &str) -> String {
|
||
let mut result = String::with_capacity(text.len());
|
||
let mut capitalize_next = true;
|
||
for c in text.chars() {
|
||
if c.is_alphabetic() {
|
||
if capitalize_next {
|
||
result.extend(c.to_uppercase());
|
||
} else {
|
||
result.push(c);
|
||
}
|
||
capitalize_next = false;
|
||
} else {
|
||
result.push(c);
|
||
capitalize_next = true;
|
||
}
|
||
}
|
||
result
|
||
}
|
||
|
||
/// Filter control characters below U+0020, preserving line breaks.
|
||
/// Browser-dependent: Firefox drops them, others replace with space.
|
||
#[cfg(test)]
|
||
fn process_ignored_chars(text: &str, browser: u8) -> String {
|
||
text.chars()
|
||
.filter_map(|c| {
|
||
if c == '\n' || c == '\r' || c == '\u{2028}' || c == '\u{2029}' {
|
||
return Some(c);
|
||
}
|
||
if c < '\u{0020}' {
|
||
if browser == Browser::Firefox as u8 {
|
||
None
|
||
} else {
|
||
Some(' ')
|
||
}
|
||
} else {
|
||
Some(c)
|
||
}
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Text after browser filtering and CSS text transformation, plus the source
|
||
/// UTF-16 range that produced each transformed Unicode scalar. A single source
|
||
/// scalar can produce several output scalars (for example `ß` uppercases to
|
||
/// `SS`); keeping that ownership lets vertical layout wrap and export the
|
||
/// transformed glyphs as one source-text unit.
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct AppliedTextTransform {
|
||
pub text: String,
|
||
source_ranges: Vec<(std::ops::Range<usize>, std::ops::Range<usize>)>,
|
||
}
|
||
|
||
impl AppliedTextTransform {
|
||
pub fn source_utf16_range(
|
||
&self,
|
||
transformed: std::ops::Range<usize>,
|
||
) -> std::ops::Range<usize> {
|
||
let mut ranges = self.source_ranges.iter().filter_map(|(output, source)| {
|
||
(output.start < transformed.end && output.end > transformed.start)
|
||
.then_some(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<TextTransform>,
|
||
) -> 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();
|
||
|
||
let processed = if source_char == '\n'
|
||
|| source_char == '\r'
|
||
|| source_char == '\u{2028}'
|
||
|| source_char == '\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,
|
||
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<TextDecoration>,
|
||
pub text_transform: Option<TextTransform>,
|
||
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,
|
||
/// Ruby (furigana) annotation for this span; empty means no ruby.
|
||
pub ruby: String,
|
||
pub ruby_size: RubySize,
|
||
pub ruby_align: RubyAlign,
|
||
pub ruby_overhang: RubyOverhang,
|
||
pub ruby_side: RubySide,
|
||
/// Warichu (割注): render the span as two half-size lines stacked inline
|
||
/// within one column position of the vertical flow.
|
||
pub warichu: bool,
|
||
pub font_features: FontFeatures,
|
||
pub annotation_clearance: AnnotationClearance,
|
||
pub fills: Vec<shapes::Fill>,
|
||
}
|
||
|
||
impl TextSpan {
|
||
#[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<TextDecoration>,
|
||
text_transform: Option<TextTransform>,
|
||
text_direction: TextDirection,
|
||
font_weight: i32,
|
||
font_variant_id: Uuid,
|
||
fills: Vec<shapes::Fill>,
|
||
) -> Self {
|
||
Self {
|
||
text,
|
||
font_family,
|
||
font_size,
|
||
line_height,
|
||
letter_spacing,
|
||
text_decoration,
|
||
text_transform,
|
||
text_direction,
|
||
text_orientation: TextOrientation::default(),
|
||
text_combine_upright: TextCombineUpright::default(),
|
||
text_emphasis: TextEmphasis::default(),
|
||
ruby: String::default(),
|
||
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(),
|
||
font_weight,
|
||
font_variant_id,
|
||
fills,
|
||
}
|
||
}
|
||
|
||
pub fn set_text(&mut self, text: String) {
|
||
self.text = text;
|
||
}
|
||
|
||
pub fn set_ruby(&mut self, ruby: String) {
|
||
self.ruby = ruby;
|
||
}
|
||
|
||
pub fn set_ruby_size(&mut self, value: RubySize) {
|
||
self.ruby_size = value;
|
||
}
|
||
|
||
pub fn set_ruby_align(&mut self, value: RubyAlign) {
|
||
self.ruby_align = value;
|
||
}
|
||
|
||
pub fn set_ruby_overhang(&mut self, value: RubyOverhang) {
|
||
self.ruby_overhang = value;
|
||
}
|
||
|
||
pub fn set_ruby_side(&mut self, value: RubySide) {
|
||
self.ruby_side = value;
|
||
}
|
||
|
||
pub fn set_text_orientation(&mut self, text_orientation: TextOrientation) {
|
||
self.text_orientation = text_orientation;
|
||
}
|
||
|
||
pub fn set_text_combine_upright(&mut self, text_combine_upright: TextCombineUpright) {
|
||
self.text_combine_upright = text_combine_upright;
|
||
}
|
||
|
||
pub fn set_text_emphasis(&mut self, text_emphasis: TextEmphasis) {
|
||
self.text_emphasis = text_emphasis;
|
||
}
|
||
|
||
pub fn set_warichu(&mut self, warichu: bool) {
|
||
self.warichu = warichu;
|
||
}
|
||
|
||
pub fn set_font_features(&mut self, font_features: FontFeatures) {
|
||
self.font_features = font_features;
|
||
}
|
||
|
||
pub fn set_annotation_clearance(&mut self, clearance: AnnotationClearance) {
|
||
self.annotation_clearance = clearance;
|
||
}
|
||
|
||
pub fn to_style(
|
||
&self,
|
||
content_bounds: &Rect,
|
||
fallback_fonts: &HashSet<String>,
|
||
remove_alpha: bool,
|
||
paragraph_line_height: f32,
|
||
) -> skia::textlayout::TextStyle {
|
||
let mut style = skia::textlayout::TextStyle::default();
|
||
let mut paint = paint::Paint::default();
|
||
|
||
if remove_alpha {
|
||
paint.set_color(skia::Color::BLACK);
|
||
paint.set_alpha(255);
|
||
} else {
|
||
paint = merge_fills(&self.fills, *content_bounds);
|
||
}
|
||
|
||
let annotation_layers = if self.annotation_clearance.is_auto() {
|
||
usize::from(!self.ruby.trim().is_empty()) + usize::from(!self.text_emphasis.is_none())
|
||
} else {
|
||
0
|
||
};
|
||
let max_line_height =
|
||
f32::max(paragraph_line_height, self.line_height) + annotation_layers as f32 * 0.5;
|
||
style.set_height(max_line_height);
|
||
style.set_height_override(true);
|
||
style.set_foreground_paint(&paint);
|
||
style.set_decoration_type(match self.text_decoration {
|
||
Some(text_decoration) => text_decoration,
|
||
None => skia::textlayout::TextDecoration::NO_DECORATION,
|
||
});
|
||
|
||
// Trick to avoid showing the text decoration
|
||
style.set_decoration_thickness_multiplier(0.0);
|
||
|
||
let mut font_families = vec![
|
||
self.serialized_font_family(),
|
||
default_font(),
|
||
DEFAULT_EMOJI_FONT.to_string(),
|
||
];
|
||
|
||
font_families.extend(fallback_fonts.iter().cloned());
|
||
style.set_font_families(&font_families);
|
||
style.set_font_size(self.font_size);
|
||
style.set_letter_spacing(self.letter_spacing);
|
||
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: &HashSet<String>,
|
||
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 apply_text_transform(&self) -> String {
|
||
self.apply_text_transform_with_source_ranges().text
|
||
}
|
||
|
||
pub fn apply_text_transform_with_source_ranges(&self) -> AppliedTextTransform {
|
||
let browser = crate::with_state!(state, { state.current_browser });
|
||
apply_text_transform_with_source_ranges(&self.text, browser, self.text_transform)
|
||
}
|
||
|
||
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<TextDecorationSegment>,
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
pub struct TextLayoutData {
|
||
pub position_data: Vec<PositionData>,
|
||
pub paragraphs: Vec<ParagraphLayout>,
|
||
}
|
||
|
||
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<PositionData> = Vec::new();
|
||
let mut previous_line_height = text_content.normalized_line_height();
|
||
let text_paragraphs = text_content.paragraphs();
|
||
|
||
// 1. Build + layout each paragraph once, recording heights as we go.
|
||
let mut paragraph_heights: Vec<f32> = Vec::new();
|
||
let mut built_groups: Vec<Vec<skia::textlayout::Paragraph>> =
|
||
Vec::with_capacity(paragraph_builder_groups.len());
|
||
for paragraph_builder_group in paragraph_builder_groups.iter_mut() {
|
||
let group_len = paragraph_builder_group.len();
|
||
let mut paragraph_offset_y = previous_line_height;
|
||
let mut group_paragraphs: Vec<skia::textlayout::Paragraph> = Vec::with_capacity(group_len);
|
||
for (builder_index, paragraph_builder) in paragraph_builder_group.iter_mut().enumerate() {
|
||
let mut skia_paragraph = paragraph_builder.build();
|
||
skia_paragraph.layout(text_width);
|
||
if builder_index == group_len - 1 {
|
||
if skia_paragraph.get_line_metrics().is_empty() {
|
||
paragraph_offset_y = skia_paragraph.ideographic_baseline();
|
||
} else {
|
||
paragraph_offset_y = skia_paragraph.height();
|
||
}
|
||
}
|
||
if builder_index == 0 {
|
||
paragraph_heights.push(skia_paragraph.height());
|
||
}
|
||
group_paragraphs.push(skia_paragraph);
|
||
}
|
||
previous_line_height = paragraph_offset_y;
|
||
built_groups.push(group_paragraphs);
|
||
}
|
||
|
||
// 2. Position each built paragraph using the heights from step 1.
|
||
let total_text_height: f32 = paragraph_heights.iter().sum();
|
||
let vertical_offset = match shape.vertical_align() {
|
||
VerticalAlign::Center => (selrect_height - total_text_height) / 2.0,
|
||
VerticalAlign::Bottom => selrect_height - total_text_height,
|
||
_ => 0.0,
|
||
};
|
||
let mut paragraph_layouts: Vec<ParagraphLayout> = 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() {
|
||
// Calculate text decorations for this paragraph
|
||
let mut decorations = Vec::new();
|
||
let line_metrics = skia_paragraph.get_line_metrics();
|
||
for line in &line_metrics {
|
||
let style_metrics: Vec<_> = line
|
||
.get_style_metrics(line.start_index..line.end_index)
|
||
.into_iter()
|
||
.collect();
|
||
let line_baseline = y_accum + line.baseline as f32;
|
||
let (max_underline_thickness, underline_y, max_strike_thickness, strike_y) =
|
||
calculate_decoration_metrics(&style_metrics, line_baseline);
|
||
for (i, (style_start, style_metric)) in style_metrics.iter().enumerate() {
|
||
let text_style = &style_metric.text_style;
|
||
let style_end = style_metrics
|
||
.get(i + 1)
|
||
.map(|(next_i, _)| *next_i)
|
||
.unwrap_or(line.end_index);
|
||
let seg_start = (*style_start).max(line.start_index);
|
||
let seg_end = style_end.min(line.end_index);
|
||
if seg_start >= seg_end {
|
||
continue;
|
||
}
|
||
let rects = skia_paragraph.get_rects_for_range(
|
||
seg_start..seg_end,
|
||
skia::textlayout::RectHeightStyle::Tight,
|
||
skia::textlayout::RectWidthStyle::Tight,
|
||
);
|
||
let (segment_width, actual_x_offset) = if !rects.is_empty() {
|
||
let total_width: f32 = rects.iter().map(|r| r.rect.width()).sum();
|
||
let skia_x_offset = rects
|
||
.first()
|
||
.map(|r| r.rect.left - line.left as f32)
|
||
.unwrap_or(0.0);
|
||
(total_width, skia_x_offset)
|
||
} else {
|
||
(0.0, 0.0)
|
||
};
|
||
let text_left = x + line.left as f32 + actual_x_offset;
|
||
let text_width = segment_width;
|
||
use skia::textlayout::TextDecoration;
|
||
if text_style.decoration().ty == TextDecoration::UNDERLINE {
|
||
decorations.push(TextDecorationSegment {
|
||
kind: TextDecoration::UNDERLINE,
|
||
text_style: (*text_style).clone(),
|
||
y: underline_y.unwrap_or(line_baseline),
|
||
thickness: max_underline_thickness,
|
||
left: text_left,
|
||
width: text_width,
|
||
});
|
||
}
|
||
if text_style.decoration().ty == TextDecoration::LINE_THROUGH {
|
||
decorations.push(TextDecorationSegment {
|
||
kind: TextDecoration::LINE_THROUGH,
|
||
text_style: (*text_style).clone(),
|
||
y: strike_y.unwrap_or(line_baseline),
|
||
thickness: max_strike_thickness,
|
||
left: text_left,
|
||
width: text_width,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
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;
|
||
let current_y = para_layout.y;
|
||
let text_paragraph = text_paragraphs.get(paragraph_index);
|
||
if let Some(text_para) = text_paragraph {
|
||
// Ranges are in the builder-text (kinsoku-shifted)
|
||
// space; exported positions are translated back to
|
||
// original span-relative offsets through the map.
|
||
let (_, offset_map) = text_para.layout_span_texts();
|
||
let span_ranges = horizontal_span_ranges(text_para);
|
||
let placeholder_rects = para_layout.paragraph.get_rects_for_placeholders();
|
||
let mut placeholder_index = 0usize;
|
||
for range in span_ranges {
|
||
if range.warichu {
|
||
if let Some(textbox) = placeholder_rects.get(placeholder_index) {
|
||
let mut rect = textbox.rect;
|
||
rect.offset((x, current_y));
|
||
position_data.push(PositionData {
|
||
paragraph: paragraph_index as u32,
|
||
span: range.span as u32,
|
||
start_pos: 0,
|
||
end_pos: (range.source_end - range.source_start) as u32,
|
||
x: rect.x(),
|
||
y: rect.y(),
|
||
width: rect.width(),
|
||
height: rect.height(),
|
||
direction: direction_to_int(TextDirection::LTR),
|
||
});
|
||
}
|
||
placeholder_index += 1;
|
||
continue;
|
||
}
|
||
let orig_span_start = range.source_start;
|
||
let rects = para_layout.paragraph.get_rects_for_range(
|
||
range.builder_start..range.builder_end,
|
||
RectHeightStyle::Tight,
|
||
RectWidthStyle::Tight,
|
||
);
|
||
|
||
for textbox in rects {
|
||
let direction = textbox.direct;
|
||
let mut rect = textbox.rect;
|
||
let cy = rect.top + rect.height() / 2.0;
|
||
|
||
// Get byte positions from Skia's transformed text layout
|
||
let to_source = |builder_position: usize| {
|
||
let within = builder_position
|
||
.saturating_sub(range.builder_start)
|
||
.min(range.builder_end - range.builder_start);
|
||
offset_map.to_original(range.shifted_start + within)
|
||
};
|
||
let start_pos = to_source(
|
||
para_layout
|
||
.paragraph
|
||
.get_glyph_position_at_coordinate((rect.left + 0.1, cy))
|
||
.position as usize,
|
||
) - orig_span_start;
|
||
|
||
let end_pos = to_source(
|
||
para_layout
|
||
.paragraph
|
||
.get_glyph_position_at_coordinate((rect.right - 0.1, cy))
|
||
.position as usize,
|
||
) - orig_span_start;
|
||
|
||
rect.offset((x, current_y));
|
||
position_data.push(PositionData {
|
||
paragraph: paragraph_index as u32,
|
||
span: range.span as u32,
|
||
start_pos: start_pos as u32,
|
||
end_pos: end_pos as u32,
|
||
x: rect.x(),
|
||
y: rect.y(),
|
||
width: rect.width(),
|
||
height: rect.height(),
|
||
direction: direction_to_int(direction),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
TextLayoutData {
|
||
position_data,
|
||
paragraphs: paragraph_layouts,
|
||
}
|
||
}
|
||
|
||
pub fn calculate_position_data(
|
||
shape: &Shape,
|
||
text_content: &TextContent,
|
||
skip_position_data: bool,
|
||
) -> Vec<PositionData> {
|
||
let mut text_content = text_content.clone();
|
||
text_content.update_layout(shape.selrect);
|
||
|
||
// Vertical writing generates position data from the vertical cells.
|
||
if text_content.is_vertical() {
|
||
if skip_position_data {
|
||
return Vec::new();
|
||
}
|
||
let max_height = super::text_vertical::wrap_height(&text_content, shape.selrect.height());
|
||
let layout = super::text_vertical::layout_from_content(&text_content, max_height);
|
||
return super::text_vertical::position_data(
|
||
&layout,
|
||
&shape.selrect,
|
||
shape.vertical_align(),
|
||
);
|
||
}
|
||
|
||
let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None);
|
||
let layout_info = calculate_text_layout_data(
|
||
shape,
|
||
&text_content,
|
||
&mut paragraph_builders,
|
||
skip_position_data,
|
||
);
|
||
|
||
layout_info.position_data
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn capitalize_basic_words() {
|
||
assert_eq!(capitalize_words("hello world"), "Hello World");
|
||
}
|
||
|
||
#[test]
|
||
fn capitalize_preserves_leading_whitespace() {
|
||
assert_eq!(capitalize_words(" hello"), " Hello");
|
||
}
|
||
|
||
#[test]
|
||
fn capitalize_preserves_trailing_whitespace() {
|
||
assert_eq!(capitalize_words("hello "), "Hello ");
|
||
}
|
||
|
||
#[test]
|
||
fn capitalize_preserves_multiple_spaces() {
|
||
assert_eq!(capitalize_words("hello world"), "Hello World");
|
||
}
|
||
|
||
#[test]
|
||
fn capitalize_whitespace_only() {
|
||
assert_eq!(capitalize_words(" "), " ");
|
||
assert_eq!(capitalize_words(" "), " ");
|
||
}
|
||
|
||
#[test]
|
||
fn capitalize_empty_string() {
|
||
assert_eq!(capitalize_words(""), "");
|
||
}
|
||
|
||
#[test]
|
||
fn capitalize_single_char() {
|
||
assert_eq!(capitalize_words("a"), "A");
|
||
}
|
||
|
||
#[test]
|
||
fn capitalize_already_uppercase() {
|
||
assert_eq!(capitalize_words("HELLO WORLD"), "HELLO WORLD");
|
||
}
|
||
|
||
#[test]
|
||
fn capitalize_preserves_tabs_and_newlines() {
|
||
assert_eq!(capitalize_words("hello\tworld"), "Hello\tWorld");
|
||
assert_eq!(capitalize_words("hello\nworld"), "Hello\nWorld");
|
||
}
|
||
|
||
#[test]
|
||
fn capitalize_after_punctuation() {
|
||
assert_eq!(capitalize_words("(readonly)"), "(Readonly)");
|
||
assert_eq!(capitalize_words("hello-world"), "Hello-World");
|
||
assert_eq!(capitalize_words("one/two/three"), "One/Two/Three");
|
||
}
|
||
|
||
#[test]
|
||
fn capitalize_after_digits() {
|
||
assert_eq!(capitalize_words("item1name"), "Item1Name");
|
||
}
|
||
|
||
#[test]
|
||
fn process_ignored_chars_preserves_spaces() {
|
||
assert_eq!(process_ignored_chars("hello world", 0), "hello world");
|
||
}
|
||
|
||
#[test]
|
||
fn process_ignored_chars_preserves_line_breaks() {
|
||
assert_eq!(process_ignored_chars("hello\nworld", 0), "hello\nworld");
|
||
assert_eq!(process_ignored_chars("hello\rworld", 0), "hello\rworld");
|
||
}
|
||
|
||
#[test]
|
||
fn process_ignored_chars_replaces_control_chars_chrome() {
|
||
// U+0001 (SOH) should become space in non-Firefox
|
||
assert_eq!(
|
||
process_ignored_chars("a\x01b", Browser::Chrome as u8),
|
||
"a b"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn process_ignored_chars_removes_control_chars_firefox() {
|
||
assert_eq!(
|
||
process_ignored_chars("a\x01b", Browser::Firefox as u8),
|
||
"ab"
|
||
);
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
|
||
// apply_text_transform reads the browser from the design state.
|
||
fn init_state() {
|
||
crate::globals::design_init();
|
||
}
|
||
|
||
fn make_span(text: &str, letter_spacing: f32) -> TextSpan {
|
||
TextSpan {
|
||
text: text.to_string(),
|
||
font_family: FontFamily::new(Uuid::nil(), 400, shapes::FontStyle::Normal),
|
||
font_size: 16.0,
|
||
line_height: 1.0,
|
||
letter_spacing,
|
||
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(),
|
||
ruby: String::default(),
|
||
warichu: false,
|
||
font_features: FontFeatures::default(),
|
||
annotation_clearance: AnnotationClearance::default(),
|
||
ruby_size: RubySize::default(),
|
||
ruby_align: RubyAlign::default(),
|
||
ruby_overhang: RubyOverhang::default(),
|
||
ruby_side: RubySide::default(),
|
||
fills: vec![],
|
||
}
|
||
}
|
||
|
||
fn make_paragraph(spans: Vec<TextSpan>, letter_spacing: f32) -> Paragraph {
|
||
Paragraph::new(
|
||
TextAlign::default(),
|
||
TextDirection::LTR,
|
||
None,
|
||
None,
|
||
1.0,
|
||
letter_spacing,
|
||
spans,
|
||
)
|
||
}
|
||
|
||
#[test]
|
||
fn layout_span_texts_applies_kinsoku() {
|
||
init_state();
|
||
let paragraph = make_paragraph(vec![make_span("雪国", 0.0), make_span("。です", 0.0)], 0.0);
|
||
let (texts, map) = paragraph.layout_span_texts();
|
||
assert_eq!(
|
||
texts,
|
||
vec!["雪国".to_string(), "\u{2060}。です".to_string()]
|
||
);
|
||
assert!(!map.is_empty());
|
||
assert_eq!(map.to_original(3), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn layout_span_texts_skips_kinsoku_under_paragraph_letter_spacing() {
|
||
init_state();
|
||
let paragraph = make_paragraph(vec![make_span("雪国。", 0.0)], 2.0);
|
||
let (texts, map) = paragraph.layout_span_texts();
|
||
assert_eq!(texts, vec!["雪国。".to_string()]);
|
||
assert!(map.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn layout_span_texts_skips_kinsoku_under_span_letter_spacing() {
|
||
init_state();
|
||
let paragraph = make_paragraph(vec![make_span("雪国。", 1.5)], 0.0);
|
||
let (texts, map) = paragraph.layout_span_texts();
|
||
assert_eq!(texts, vec!["雪国。".to_string()]);
|
||
assert!(map.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn layout_span_texts_respects_text_transform() {
|
||
init_state();
|
||
let mut span = make_span("hello。", 0.0);
|
||
span.text_transform = Some(TextTransform::Uppercase);
|
||
let paragraph = make_paragraph(vec![span], 0.0);
|
||
let (texts, _) = paragraph.layout_span_texts();
|
||
assert_eq!(texts, vec!["HELLO\u{2060}。".to_string()]);
|
||
}
|
||
|
||
#[test]
|
||
fn layout_span_texts_identity_map_for_plain_text() {
|
||
init_state();
|
||
let paragraph = make_paragraph(vec![make_span("helloworld", 0.0)], 0.0);
|
||
let (texts, map) = paragraph.layout_span_texts();
|
||
assert_eq!(texts, vec!["helloworld".to_string()]);
|
||
assert!(map.is_empty());
|
||
assert_eq!(map.to_original(5), 5);
|
||
assert_eq!(map.to_shifted(5), 5);
|
||
}
|
||
|
||
#[test]
|
||
fn horizontal_ruby_is_atomic() {
|
||
init_state();
|
||
let mut group = make_span("日本", 0.0);
|
||
group.ruby = "にほん".to_string();
|
||
let paragraph = make_paragraph(vec![group], 0.0);
|
||
assert_eq!(
|
||
paragraph.layout_span_texts().0,
|
||
vec!["日\u{2060}本".to_string()]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn horizontal_annotation_uses_the_base_em_not_typographic_leading() {
|
||
let rect = skia::Rect::from_xywh(0.0, 66.0, 56.0, 90.0);
|
||
|
||
assert_eq!(
|
||
crate::shapes::text_japanese::horizontal_annotation_over_top(rect, 56.0),
|
||
88.0
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn horizontal_warichu_collapses_to_one_builder_position() {
|
||
init_state();
|
||
let mut warichu = make_span("割注入り", 0.0);
|
||
warichu.warichu = true;
|
||
let paragraph = make_paragraph(vec![warichu, make_span("後", 0.0)], 0.0);
|
||
|
||
let ranges = horizontal_span_ranges(¶graph);
|
||
assert_eq!(ranges[0].builder_start..ranges[0].builder_end, 0..3);
|
||
assert_eq!(ranges[1].builder_start..ranges[1].builder_end, 3..4);
|
||
assert_eq!(horizontal_source_to_builder(¶graph, 2), 0);
|
||
assert_eq!(horizontal_source_to_builder(¶graph, 4), 3);
|
||
assert_eq!(horizontal_source_to_builder(¶graph, 5), 4);
|
||
assert_eq!(horizontal_builder_to_source(¶graph, 1), 4);
|
||
assert_eq!(horizontal_builder_to_source(¶graph, 2), 4);
|
||
assert_eq!(horizontal_builder_to_source(¶graph, 3), 4);
|
||
assert_eq!(horizontal_builder_to_source(¶graph, 4), 5);
|
||
assert_eq!(
|
||
horizontal_normal_selection_ranges(¶graph, 1, 5),
|
||
vec![3..4]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn horizontal_builder_mapping_preserves_non_bmp_boundaries() {
|
||
init_state();
|
||
let paragraph = make_paragraph(vec![make_span("😀A", 0.0)], 0.0);
|
||
|
||
assert_eq!(horizontal_source_to_builder(¶graph, 1), 2);
|
||
assert_eq!(horizontal_builder_to_source(¶graph, 2), 1);
|
||
assert_eq!(horizontal_source_to_builder(¶graph, 2), 3);
|
||
assert_eq!(horizontal_builder_to_source(¶graph, 3), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn horizontal_warichu_builder_emits_one_styled_placeholder() {
|
||
init_state();
|
||
let mut span = make_span("割注入り", 0.0);
|
||
span.warichu = true;
|
||
let mut style = skia::textlayout::TextStyle::default();
|
||
style.set_font_size(span.font_size);
|
||
let mut fonts = skia::textlayout::FontCollection::new();
|
||
fonts.set_default_font_manager(skia::FontMgr::new(), None);
|
||
let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), &fonts);
|
||
builder.push_style(&style);
|
||
add_horizontal_span(&mut builder, &span, &span.text, &style, &fonts);
|
||
let mut laid_out = builder.build();
|
||
laid_out.layout(200.0);
|
||
|
||
let placeholders = laid_out.get_rects_for_placeholders();
|
||
assert_eq!(placeholders.len(), 1);
|
||
assert!(placeholders[0].rect.width() > 0.0);
|
||
assert!(placeholders[0].rect.height() > 0.0);
|
||
let has_style = laid_out
|
||
.get_line_metrics()
|
||
.iter()
|
||
.any(|line| !line.get_style_metrics(3..5).is_empty());
|
||
assert!(
|
||
has_style,
|
||
"the paint pass must recover the placeholder style"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn horizontal_warichu_allows_wrapping_after_the_atomic_box() {
|
||
init_state();
|
||
let mut span = make_span("割注入り", 0.0);
|
||
span.warichu = true;
|
||
let following = make_span("A", 0.0);
|
||
let mut style = skia::textlayout::TextStyle::default();
|
||
style.set_font_size(span.font_size);
|
||
let mut fonts = skia::textlayout::FontCollection::new();
|
||
fonts.set_default_font_manager(skia::FontMgr::new(), None);
|
||
let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), &fonts);
|
||
builder.push_style(&style);
|
||
add_horizontal_span(&mut builder, &span, &span.text, &style, &fonts);
|
||
builder.push_style(&style);
|
||
builder.add_text(&following.text);
|
||
|
||
let mut laid_out = builder.build();
|
||
laid_out.layout(16.1);
|
||
|
||
assert_eq!(laid_out.get_rects_for_placeholders().len(), 1);
|
||
assert_eq!(laid_out.get_line_metrics().len(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn emphasis_excludes_whitespace_and_japanese_punctuation() {
|
||
for character in " \t\n、。,.「」『』()[]【】〔〕〈〉《》‘’“”".chars()
|
||
{
|
||
assert!(
|
||
!emphasis_char_allowed(character),
|
||
"emphasis must skip {character:?}"
|
||
);
|
||
}
|
||
for character in "漢あA1・!?".chars() {
|
||
assert!(
|
||
emphasis_char_allowed(character),
|
||
"emphasis should mark {character:?}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn horizontal_emphasis_tracks_eligible_unicode_characters() {
|
||
init_state();
|
||
let mut span = make_span("A😀。 B", 0.0);
|
||
span.text_emphasis = TextEmphasis::FilledDot;
|
||
let paragraph = make_paragraph(vec![span], 0.0);
|
||
let mut style = skia::textlayout::TextStyle::default();
|
||
style.set_font_size(16.0);
|
||
let mut fonts = skia::textlayout::FontCollection::new();
|
||
fonts.set_default_font_manager(skia::FontMgr::new(), None);
|
||
let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), &fonts);
|
||
let (texts, _) = paragraph.layout_span_texts();
|
||
for (span, text) in paragraph.children().iter().zip(texts) {
|
||
builder.push_style(&style);
|
||
add_horizontal_span(&mut builder, span, &text, &style, &fonts);
|
||
}
|
||
let mut laid_out = builder.build();
|
||
laid_out.layout(200.0);
|
||
|
||
let placements = horizontal_emphasis_placements(¶graph, &laid_out);
|
||
assert_eq!(placements.len(), 3, "A, emoji and B receive one mark each");
|
||
assert!(placements
|
||
.iter()
|
||
.all(|placement| placement.rect.width() > 0.0));
|
||
assert!(horizontal_span_style(&laid_out, &horizontal_span_ranges(¶graph)[0]).is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn horizontal_emphasis_recovers_each_non_ascii_span_style() {
|
||
init_state();
|
||
let mut first = make_span("漢", 0.0);
|
||
first.text_emphasis = TextEmphasis::FilledDot;
|
||
let mut second = make_span("字", 0.0);
|
||
second.text_emphasis = TextEmphasis::OpenCircle;
|
||
let paragraph = make_paragraph(vec![first, second], 0.0);
|
||
let mut fonts = skia::textlayout::FontCollection::new();
|
||
fonts.set_default_font_manager(skia::FontMgr::new(), None);
|
||
let mut builder = ParagraphBuilder::new(&ParagraphStyle::default(), &fonts);
|
||
let (texts, _) = paragraph.layout_span_texts();
|
||
for (index, (span, text)) in paragraph.children().iter().zip(texts).enumerate() {
|
||
let mut style = skia::textlayout::TextStyle::default();
|
||
style.set_font_size(if index == 0 { 16.0 } else { 24.0 });
|
||
builder.push_style(&style);
|
||
add_horizontal_span(&mut builder, span, &text, &style, &fonts);
|
||
}
|
||
let mut laid_out = builder.build();
|
||
laid_out.layout(200.0);
|
||
|
||
let ranges = horizontal_span_ranges(¶graph);
|
||
assert_eq!(
|
||
horizontal_span_style(&laid_out, &ranges[0])
|
||
.unwrap()
|
||
.font_size(),
|
||
16.0
|
||
);
|
||
assert_eq!(
|
||
horizontal_span_style(&laid_out, &ranges[1])
|
||
.unwrap()
|
||
.font_size(),
|
||
24.0
|
||
);
|
||
}
|
||
}
|