| use std::{collections::HashMap, ops::Range}; |
| use unicode_bidi::BidiInfo; |
|
|
| use anyhow::Result; |
| use harfrust::{Feature, Tag}; |
| use hypher::Lang; |
| use skrifa::{ |
| MetadataProvider, |
| instance::{LocationRef, Size}, |
| }; |
|
|
| use crate::font::{Font, font_key}; |
| use crate::shape::shape_script_runs; |
| use crate::text::script::shaping_direction_for_text; |
| use crate::types::TextAlign; |
|
|
| pub use crate::segment::{LineBreakOpportunity, LineBreaker, LineSegment}; |
| pub use crate::segment::{LineBreakSuffix, hyphenation_lang_from_tag}; |
| pub use crate::shape::{PositionedGlyph, ShapedRun, ShapingOptions, TextShaper}; |
|
|
| const HYPHENATION_MIN_WORD_LEN: usize = 8; |
| const LINE_BREAK_HYPHEN_PENALTY: f32 = 2_000.0; |
| const LINE_BREAK_OVERFLOW_MULTIPLIER: f32 = 10_000.0; |
|
|
| |
| #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] |
| pub enum WritingMode { |
| |
| #[default] |
| Horizontal, |
| |
| VerticalRl, |
| } |
|
|
| impl WritingMode { |
| |
| pub fn is_vertical(&self) -> bool { |
| matches!(self, WritingMode::VerticalRl) |
| } |
| } |
|
|
| |
| #[derive(Debug, Clone, Default)] |
| pub struct LayoutLine<'a> { |
| |
| pub glyphs: Vec<PositionedGlyph<'a>>, |
| |
| pub range: Range<usize>, |
| |
| pub advance: f32, |
| |
| pub baseline: (f32, f32), |
| |
| pub direction: harfrust::Direction, |
| } |
|
|
| |
| #[derive(Debug, Clone)] |
| pub struct LayoutRun<'a> { |
| |
| pub lines: Vec<LayoutLine<'a>>, |
| |
| pub width: f32, |
| |
| pub height: f32, |
| |
| pub font_size: f32, |
| } |
|
|
| #[derive(Clone)] |
| struct LineRun<'a> { |
| shaped: ShapedRun<'a>, |
| level: unicode_bidi::Level, |
| } |
|
|
| #[derive(Clone)] |
| struct ShapedBreakSuffix<'a> { |
| runs: Vec<LineRun<'a>>, |
| advance: f32, |
| } |
|
|
| #[derive(Clone)] |
| struct ShapedSegment<'a> { |
| range: Range<usize>, |
| next_offset: usize, |
| is_mandatory: bool, |
| runs: Vec<LineRun<'a>>, |
| advance: f32, |
| break_suffix: Option<ShapedBreakSuffix<'a>>, |
| } |
|
|
| #[derive(Clone, Copy, Debug)] |
| struct LineBreakMeasure { |
| advance: f32, |
| break_suffix_advance: f32, |
| } |
|
|
| #[derive(Clone)] |
| pub struct TextLayout<'a> { |
| writing_mode: WritingMode, |
| center_vertical_punctuation: bool, |
| hyphenation_lang: Option<Lang>, |
| font: &'a Font, |
| fallback_fonts: &'a [Font], |
| font_size: Option<f32>, |
| max_width: Option<f32>, |
| max_height: Option<f32>, |
| alignment: Option<TextAlign>, |
| } |
|
|
| impl<'a> TextLayout<'a> { |
| pub fn new(font: &'a Font, font_size: Option<f32>) -> Self { |
| Self { |
| writing_mode: WritingMode::Horizontal, |
| center_vertical_punctuation: true, |
| hyphenation_lang: Some(Lang::English), |
| font, |
| fallback_fonts: &[], |
| font_size, |
| max_width: None, |
| max_height: None, |
| alignment: None, |
| } |
| } |
|
|
| pub fn with_font_size(mut self, size: f32) -> Self { |
| self.font_size = Some(size); |
| self |
| } |
|
|
| pub fn with_writing_mode(mut self, mode: WritingMode) -> Self { |
| self.writing_mode = mode; |
| self |
| } |
|
|
| pub fn with_center_vertical_punctuation(mut self, enabled: bool) -> Self { |
| self.center_vertical_punctuation = enabled; |
| self |
| } |
|
|
| pub fn with_hyphenation_language(mut self, lang: Lang) -> Self { |
| self.hyphenation_lang = Some(lang); |
| self |
| } |
|
|
| pub fn with_hyphenation_language_tag(mut self, tag: &str) -> Self { |
| self.hyphenation_lang = hyphenation_lang_from_tag(tag); |
| self |
| } |
|
|
| pub fn without_hyphenation(mut self) -> Self { |
| self.hyphenation_lang = None; |
| self |
| } |
|
|
| pub fn with_fallback_fonts(mut self, fonts: &'a [Font]) -> Self { |
| self.fallback_fonts = fonts; |
| self |
| } |
|
|
| pub fn with_max_width(mut self, width: f32) -> Self { |
| self.max_width = Some(width); |
| self |
| } |
|
|
| pub fn with_max_height(mut self, height: f32) -> Self { |
| self.max_height = Some(height); |
| self |
| } |
|
|
| pub fn with_alignment(mut self, alignment: TextAlign) -> Self { |
| self.alignment = Some(alignment); |
| self |
| } |
|
|
| pub fn run(&self, text: &str) -> Result<LayoutRun<'a>> { |
| if let Some(font_size) = self.font_size { |
| return self.run_with_size(text, font_size); |
| } |
|
|
| self.run_auto(text) |
| } |
|
|
| fn run_auto(&self, text: &str) -> Result<LayoutRun<'a>> { |
| let _s = tracing::info_span!("auto_size").entered(); |
| let max_height = self.max_height.unwrap_or(f32::INFINITY); |
| let max_width = self.max_width.unwrap_or(f32::INFINITY); |
|
|
| let mut low = 6; |
| let mut high = 300; |
| let mut best: Option<LayoutRun<'a>> = None; |
| let mut iterations = 0u32; |
|
|
| while low <= high { |
| iterations += 1; |
| let mid = (low + high) / 2; |
| let size = mid as f32; |
| let layout = self.run_with_size(text, size)?; |
| if layout.width <= max_width && layout.height <= max_height { |
| best = Some(layout); |
| low = mid + 1; |
| } else { |
| high = mid - 1; |
| } |
| } |
|
|
| |
| |
| if best.is_none() { |
| best = Some(self.run_with_size(text, 6.0)?); |
| } |
| tracing::info!( |
| iterations, |
| font_size = best.as_ref().map(|b| b.font_size as u32).unwrap_or(0), |
| "auto_size done" |
| ); |
| Ok(best.unwrap()) |
| } |
|
|
| fn run_with_size(&self, text: &str, font_size: f32) -> Result<LayoutRun<'a>> { |
| let _s = tracing::debug_span!("layout_size", font_size = font_size as u32).entered(); |
| let shaper = TextShaper::new(); |
| let mut line_breaker = LineBreaker::new().with_chinese_word_segmentation(); |
| if !self.writing_mode.is_vertical() |
| && let Some(lang) = self.hyphenation_lang |
| { |
| line_breaker = line_breaker.with_hyphenation(lang, HYPHENATION_MIN_WORD_LEN); |
| } |
| let normalized_punctuation; |
| let text = if self.writing_mode.is_vertical() { |
| normalized_punctuation = normalize_vertical_emphasis_punctuation(text); |
| normalized_punctuation.as_str() |
| } else { |
| text |
| }; |
|
|
| |
| let font_ref = self.font.skrifa()?; |
| let metrics = font_ref.metrics(Size::new(font_size), LocationRef::default()); |
| let ascent = metrics.ascent; |
| let descent = -metrics.descent; |
| let line_height = (ascent + descent + metrics.leading).max(font_size); |
|
|
| let bidi_info = BidiInfo::new(text, None); |
|
|
| let (direction, script) = shaping_direction_for_text(text, self.writing_mode); |
| let opts = ShapingOptions { |
| direction, |
| script, |
| font_size, |
| features: if self.writing_mode.is_vertical() { |
| &[ |
| Feature::new(Tag::new(b"vert"), 1, ..), |
| Feature::new(Tag::new(b"vrt2"), 1, ..), |
| ] |
| } else { |
| &[] |
| }, |
| }; |
|
|
| let max_extent = if self.writing_mode.is_vertical() { |
| self.max_height |
| } else { |
| self.max_width |
| } |
| .unwrap_or(f32::INFINITY); |
| let max_extent_finite = max_extent.is_finite() && max_extent > 0.0; |
|
|
| let mut fonts: Vec<&Font> = Vec::with_capacity(1 + self.fallback_fonts.len()); |
| fonts.push(self.font); |
| fonts.extend(self.fallback_fonts.iter()); |
|
|
| let shape_break_suffix = |suffix: LineBreakSuffix, |
| level: unicode_bidi::Level, |
| cluster: usize| |
| -> Result<ShapedBreakSuffix<'a>> { |
| let mut suffix_opts = opts.clone(); |
| suffix_opts.direction = if level.is_rtl() { |
| harfrust::Direction::RightToLeft |
| } else { |
| harfrust::Direction::LeftToRight |
| }; |
|
|
| let mut runs = Vec::new(); |
| let mut advance = 0.0f32; |
| for mut shaped in shape_script_runs(&shaper, suffix.as_str(), &fonts, &suffix_opts)? { |
| for glyph in &mut shaped.glyphs { |
| glyph.cluster += cluster as u32; |
| } |
| advance += shaped.x_advance.abs(); |
| runs.push(LineRun { shaped, level }); |
| } |
|
|
| Ok(ShapedBreakSuffix { runs, advance }) |
| }; |
|
|
| let mut shaped_segments = Vec::new(); |
| for segment in line_breaker.line_segments(text) { |
| let segment_text = &text[segment.range.clone()]; |
|
|
| let mut segment_runs = Vec::new(); |
| let mut segment_advance = 0.0f32; |
|
|
| if !segment_text.is_empty() { |
| |
| let mut char_indices = segment_text |
| .char_indices() |
| .map(|(id, _)| segment.range.start + id) |
| .peekable(); |
|
|
| while let Some(run_start) = char_indices.next() { |
| let level = bidi_info.levels[run_start]; |
| let mut run_end = segment.range.end; |
|
|
| while let Some(&next_char_start) = char_indices.peek() { |
| if bidi_info.levels[next_char_start] != level { |
| run_end = next_char_start; |
| break; |
| } |
| char_indices.next(); |
| } |
|
|
| let run_text = &text[run_start..run_end]; |
| let mut run_opts = opts.clone(); |
| run_opts.direction = if self.writing_mode.is_vertical() { |
| harfrust::Direction::TopToBottom |
| } else if level.is_rtl() { |
| harfrust::Direction::RightToLeft |
| } else { |
| harfrust::Direction::LeftToRight |
| }; |
|
|
| let script_runs = shape_script_runs(&shaper, run_text, &fonts, &run_opts)?; |
| for mut shaped in script_runs { |
| if self.writing_mode.is_vertical() && self.center_vertical_punctuation { |
| self.center_vertical_fullwidth_punctuation( |
| font_size, |
| run_text, |
| &mut shaped.glyphs, |
| ); |
| } |
|
|
| for glyph in &mut shaped.glyphs { |
| glyph.cluster += run_start as u32; |
| } |
|
|
| segment_advance += if self.writing_mode.is_vertical() { |
| shaped.y_advance.abs() |
| } else { |
| shaped.x_advance.abs() |
| }; |
|
|
| segment_runs.push(LineRun { shaped, level }); |
| } |
| } |
| } |
| let segment_break_suffix = if let (Some(suffix), Some(level)) = |
| (segment.break_suffix, segment_runs.last().map(|r| r.level)) |
| { |
| Some(shape_break_suffix(suffix, level, segment.range.end)?) |
| } else { |
| None |
| }; |
|
|
| shaped_segments.push(ShapedSegment { |
| range: segment.range, |
| next_offset: segment.next_offset, |
| is_mandatory: segment.is_mandatory, |
| runs: segment_runs, |
| advance: segment_advance, |
| break_suffix: segment_break_suffix, |
| }); |
| } |
|
|
| let mut lines: Vec<LayoutLine<'a>> = Vec::new(); |
| let mut line_offset = 0usize; |
| let mut paragraph_start = 0usize; |
| for (index, segment) in shaped_segments.iter().enumerate() { |
| if !segment.is_mandatory { |
| continue; |
| } |
| self.append_balanced_segment_lines( |
| &shaped_segments[paragraph_start..=index], |
| &mut line_offset, |
| segment.next_offset, |
| true, |
| max_extent, |
| &bidi_info, |
| &mut lines, |
| ); |
| paragraph_start = index + 1; |
| } |
| if paragraph_start < shaped_segments.len() { |
| self.append_balanced_segment_lines( |
| &shaped_segments[paragraph_start..], |
| &mut line_offset, |
| text.len(), |
| false, |
| max_extent, |
| &bidi_info, |
| &mut lines, |
| ); |
| } |
|
|
| |
| |
| let line_count = lines.len(); |
| let effective_alignment = self.alignment.unwrap_or(if max_extent_finite { |
| |
| |
| TextAlign::Center |
| } else { |
| TextAlign::Left |
| }); |
|
|
| for (i, line) in lines.iter_mut().enumerate() { |
| line.baseline = if self.writing_mode.is_vertical() { |
| |
| |
| |
| let x = (line_count.saturating_sub(1) as f32 - i as f32) * line_height |
| + line_height * 0.5; |
| (x, ascent) |
| } else { |
| (0.0, ascent + i as f32 * line_height) |
| }; |
| } |
|
|
| |
| |
| |
| let (mut width, mut height) = (0.0, 0.0); |
| if let Some((mut min_x, mut min_y, mut max_x, mut max_y)) = |
| self.ink_bounds(font_size, &lines) |
| { |
| |
| const PAD: f32 = 1.0; |
| min_x -= PAD; |
| min_y -= PAD; |
| max_x += PAD; |
| max_y += PAD; |
|
|
| for line in &mut lines { |
| line.baseline.0 -= min_x; |
| line.baseline.1 -= min_y; |
| } |
| let max_width_finite = self.max_width.is_some_and(|w| w.is_finite() && w > 0.0); |
| if self.writing_mode.is_vertical() { |
| let actual_width = (max_x - min_x).max(0.0); |
| if max_width_finite { |
| |
| width = if effective_alignment == TextAlign::Center { |
| actual_width |
| } else { |
| actual_width.max(self.max_width.unwrap()) |
| }; |
|
|
| if effective_alignment != TextAlign::Left { |
| let anchor = if effective_alignment == TextAlign::Center { |
| actual_width |
| } else { |
| width |
| }; |
| let remaining = (anchor - actual_width).max(0.0); |
| let offset = match effective_alignment { |
| TextAlign::Center => remaining * 0.5, |
| TextAlign::Right => remaining, |
| TextAlign::Left => 0.0, |
| }; |
| if offset > 0.0 { |
| for line in &mut lines { |
| line.baseline.0 += offset; |
| } |
| } |
| } |
| } else { |
| width = actual_width; |
| } |
| } else { |
| let actual_width = (max_x - min_x).max(0.0); |
| width = if effective_alignment == TextAlign::Center && max_extent_finite { |
| actual_width |
| } else { |
| actual_width.max(if max_extent.is_finite() { |
| max_extent |
| } else { |
| 0.0 |
| }) |
| }; |
| } |
| height = (max_y - min_y).max(0.0); |
|
|
| |
| if !self.writing_mode.is_vertical() |
| && effective_alignment == TextAlign::Center |
| && let Some(max_h) = self.max_height.filter(|&h| h.is_finite() && h > 0.0) |
| { |
| let offset = (max_h - height).max(0.0) * 0.5; |
| if offset > 0.0 { |
| for line in &mut lines { |
| line.baseline.1 += offset; |
| } |
| height = height.max(max_h); |
| } |
| } |
|
|
| |
| if !self.writing_mode.is_vertical() |
| && max_extent_finite |
| && effective_alignment != TextAlign::Left |
| { |
| |
| |
| let anchor = width; |
| for line in &mut lines { |
| let remaining = (anchor - line.advance).max(0.0); |
| let offset = match effective_alignment { |
| TextAlign::Left => 0.0, |
| TextAlign::Center => remaining * 0.5, |
| TextAlign::Right => remaining, |
| }; |
| if offset > 0.0 { |
| line.baseline.0 += offset; |
| } |
| } |
| } |
| } |
|
|
| Ok(LayoutRun { |
| lines, |
| width, |
| height, |
| font_size, |
| }) |
| } |
|
|
| #[allow(clippy::too_many_arguments)] |
| fn append_balanced_segment_lines( |
| &self, |
| segments: &[ShapedSegment<'a>], |
| line_offset: &mut usize, |
| final_next_offset: usize, |
| force_final_line: bool, |
| max_extent: f32, |
| bidi_info: &BidiInfo<'_>, |
| lines: &mut Vec<LayoutLine<'a>>, |
| ) { |
| if segments.is_empty() { |
| if force_final_line { |
| *line_offset = self.push_layout_line( |
| Vec::new(), |
| *line_offset, |
| *line_offset, |
| final_next_offset, |
| None, |
| true, |
| bidi_info, |
| lines, |
| ); |
| } |
| return; |
| } |
|
|
| let break_indices = if max_extent.is_finite() && max_extent > 0.0 { |
| let measures = segments |
| .iter() |
| .map(|segment| LineBreakMeasure { |
| advance: segment.advance, |
| break_suffix_advance: segment |
| .break_suffix |
| .as_ref() |
| .map_or(0.0, |suffix| suffix.advance), |
| }) |
| .collect::<Vec<_>>(); |
| optimal_line_breaks(&measures, max_extent) |
| } else { |
| vec![segments.len()] |
| }; |
|
|
| let mut start = 0usize; |
| for end in break_indices { |
| if end <= start || end > segments.len() { |
| continue; |
| } |
| let final_line = end == segments.len(); |
| let visible_end = segments[end - 1].range.end; |
| let next_offset = if final_line { |
| final_next_offset |
| } else { |
| segments[end].range.start |
| }; |
| let break_suffix = if final_line { |
| None |
| } else { |
| segments[end - 1].break_suffix.clone() |
| }; |
| let runs = segments[start..end] |
| .iter() |
| .flat_map(|segment| segment.runs.iter().cloned()) |
| .collect::<Vec<_>>(); |
| *line_offset = self.push_layout_line( |
| runs, |
| *line_offset, |
| visible_end, |
| next_offset, |
| break_suffix, |
| force_final_line && final_line, |
| bidi_info, |
| lines, |
| ); |
| start = end; |
| } |
| } |
|
|
| #[allow(clippy::too_many_arguments)] |
| fn push_layout_line( |
| &self, |
| mut runs: Vec<LineRun<'a>>, |
| offset: usize, |
| visible_end: usize, |
| next_offset: usize, |
| break_suffix: Option<ShapedBreakSuffix<'a>>, |
| force_push: bool, |
| bidi_info: &BidiInfo<'_>, |
| lines: &mut Vec<LayoutLine<'a>>, |
| ) -> usize { |
| if runs.is_empty() && !force_push { |
| return next_offset; |
| } |
|
|
| if let Some(mut suffix) = break_suffix { |
| runs.append(&mut suffix.runs); |
| } |
|
|
| let levels: Vec<unicode_bidi::Level> = runs.iter().map(|r| r.level).collect(); |
| let visual_indices = reorder_visual(&levels); |
|
|
| let mut line = LayoutLine { |
| range: offset..visible_end, |
| direction: if self.writing_mode.is_vertical() { |
| harfrust::Direction::TopToBottom |
| } else { |
| bidi_info |
| .paragraphs |
| .iter() |
| .find(|p| offset >= p.range.start && offset <= p.range.end) |
| .map(|p| { |
| if p.level.is_rtl() { |
| harfrust::Direction::RightToLeft |
| } else { |
| harfrust::Direction::LeftToRight |
| } |
| }) |
| .unwrap_or(harfrust::Direction::LeftToRight) |
| }, |
| ..Default::default() |
| }; |
|
|
| let mut pen_x = 0.0f32; |
| let mut pen_y = 0.0f32; |
|
|
| for idx in visual_indices { |
| let run = &mut runs[idx]; |
| for glyph in std::mem::take(&mut run.shaped.glyphs) { |
| line.glyphs.push(glyph); |
| } |
| if self.writing_mode.is_vertical() { |
| pen_y -= run.shaped.y_advance; |
| } else { |
| pen_x += run.shaped.x_advance; |
| } |
| } |
|
|
| line.advance = if self.writing_mode.is_vertical() { |
| pen_y.abs() |
| } else { |
| pen_x |
| }; |
|
|
| lines.push(line); |
| next_offset |
| } |
|
|
| fn ink_bounds(&self, font_size: f32, lines: &[LayoutLine<'a>]) -> Option<(f32, f32, f32, f32)> { |
| let mut metrics_cache = HashMap::new(); |
|
|
| let mut min_x = f32::INFINITY; |
| let mut min_y = f32::INFINITY; |
| let mut max_x = f32::NEG_INFINITY; |
| let mut max_y = f32::NEG_INFINITY; |
|
|
| for line in lines { |
| let (mut x, mut y) = line.baseline; |
| for g in &line.glyphs { |
| let key = font_key(g.font); |
| let glyph_metrics = match metrics_cache.entry(key) { |
| std::collections::hash_map::Entry::Occupied(entry) => entry.into_mut(), |
| std::collections::hash_map::Entry::Vacant(entry) => { |
| let Ok(font_ref) = g.font.skrifa() else { |
| x += g.x_advance; |
| y -= g.y_advance; |
| continue; |
| }; |
| entry.insert( |
| font_ref.glyph_metrics(Size::new(font_size), LocationRef::default()), |
| ) |
| } |
| }; |
|
|
| let gid = skrifa::GlyphId::new(g.glyph_id); |
| if let Some(b) = glyph_metrics.bounds(gid) { |
| let x0 = x + g.x_offset + b.x_min; |
| let x1 = x + g.x_offset + b.x_max; |
|
|
| |
| |
| let y0 = (y - g.y_offset) - b.y_max; |
| let y1 = (y - g.y_offset) - b.y_min; |
|
|
| min_x = min_x.min(x0).min(x1); |
| max_x = max_x.max(x0).max(x1); |
| min_y = min_y.min(y0).min(y1); |
| max_y = max_y.max(y0).max(y1); |
| } |
|
|
| x += g.x_advance; |
| y -= g.y_advance; |
| } |
| } |
|
|
| if min_x.is_finite() { |
| Some((min_x, min_y, max_x, max_y)) |
| } else { |
| None |
| } |
| } |
|
|
| fn center_vertical_fullwidth_punctuation( |
| &self, |
| font_size: f32, |
| segment: &str, |
| glyphs: &mut [PositionedGlyph<'a>], |
| ) { |
| if segment.is_empty() || glyphs.is_empty() { |
| return; |
| } |
|
|
| let mut metrics_cache = HashMap::new(); |
| for glyph in glyphs { |
| let cluster = glyph.cluster as usize; |
| let Some(ch) = segment.get(cluster..).and_then(|tail| tail.chars().next()) else { |
| continue; |
| }; |
| if !is_fullwidth_punctuation(ch) { |
| continue; |
| } |
|
|
| let key = font_key(glyph.font); |
| let glyph_metrics = match metrics_cache.entry(key) { |
| std::collections::hash_map::Entry::Occupied(entry) => entry.into_mut(), |
| std::collections::hash_map::Entry::Vacant(entry) => { |
| let Ok(font_ref) = glyph.font.skrifa() else { |
| continue; |
| }; |
| entry.insert( |
| font_ref.glyph_metrics(Size::new(font_size), LocationRef::default()), |
| ) |
| } |
| }; |
|
|
| let gid = skrifa::GlyphId::new(glyph.glyph_id); |
| let Some(bounds) = glyph_metrics.bounds(gid) else { |
| continue; |
| }; |
| glyph.x_offset = centered_x_offset(bounds.x_min, bounds.x_max); |
| } |
| } |
| } |
|
|
| fn optimal_line_breaks(segments: &[LineBreakMeasure], max_extent: f32) -> Vec<usize> { |
| let len = segments.len(); |
| if len == 0 { |
| return Vec::new(); |
| } |
| if !max_extent.is_finite() || max_extent <= 0.0 { |
| return vec![len]; |
| } |
|
|
| let mut dp = vec![f32::INFINITY; len + 1]; |
| let mut prev = vec![None; len + 1]; |
| dp[0] = 0.0; |
|
|
| for start in 0..len { |
| if !dp[start].is_finite() { |
| continue; |
| } |
| let mut advance = 0.0f32; |
| for end in start + 1..=len { |
| advance += segments[end - 1].advance; |
| let suffix_advance = if end < len { |
| segments[end - 1].break_suffix_advance |
| } else { |
| 0.0 |
| }; |
| let line_advance = advance + suffix_advance; |
| let is_single_segment = end == start + 1; |
| if line_advance > max_extent && !is_single_segment { |
| break; |
| } |
|
|
| let mut cost = dp[start] + line_break_badness(line_advance, max_extent); |
| if end < len && suffix_advance > 0.0 { |
| cost += LINE_BREAK_HYPHEN_PENALTY; |
| } |
|
|
| if cost < dp[end] { |
| dp[end] = cost; |
| prev[end] = Some(start); |
| } |
| } |
| } |
|
|
| if !dp[len].is_finite() { |
| return vec![len]; |
| } |
|
|
| let mut breaks = Vec::new(); |
| let mut index = len; |
| while index > 0 { |
| breaks.push(index); |
| let Some(previous) = prev[index] else { |
| return vec![len]; |
| }; |
| index = previous; |
| } |
| breaks.reverse(); |
| breaks |
| } |
|
|
| fn line_break_badness(line_advance: f32, max_extent: f32) -> f32 { |
| if line_advance <= max_extent { |
| (max_extent - line_advance).powi(3) |
| } else { |
| (line_advance - max_extent).powi(3) * LINE_BREAK_OVERFLOW_MULTIPLIER |
| } |
| } |
|
|
| fn centered_x_offset(x_min: f32, x_max: f32) -> f32 { |
| -((x_min + x_max) * 0.5) |
| } |
|
|
| #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| enum EmphasisMark { |
| Bang, |
| Question, |
| } |
|
|
| fn emphasis_mark_kind(ch: char) -> Option<EmphasisMark> { |
| match ch { |
| '!' | '!' => Some(EmphasisMark::Bang), |
| '?' | '?' => Some(EmphasisMark::Question), |
| _ => None, |
| } |
| } |
|
|
| fn emphasis_pair_symbol(left: EmphasisMark, right: EmphasisMark) -> char { |
| match (left, right) { |
| (EmphasisMark::Bang, EmphasisMark::Bang) => '‼', |
| (EmphasisMark::Question, EmphasisMark::Question) => '⁇', |
| (EmphasisMark::Bang, EmphasisMark::Question) => '⁉', |
| (EmphasisMark::Question, EmphasisMark::Bang) => '⁈', |
| } |
| } |
|
|
| fn normalize_vertical_emphasis_punctuation(text: &str) -> String { |
| let chars: Vec<char> = text.chars().collect(); |
| let mut out = String::with_capacity(text.len()); |
| let mut i = 0usize; |
|
|
| while i < chars.len() { |
| let Some(kind) = emphasis_mark_kind(chars[i]) else { |
| out.push(chars[i]); |
| i += 1; |
| continue; |
| }; |
|
|
| if i + 1 >= chars.len() { |
| out.push(chars[i]); |
| i += 1; |
| continue; |
| } |
|
|
| let Some(next_kind) = emphasis_mark_kind(chars[i + 1]) else { |
| out.push(chars[i]); |
| i += 1; |
| continue; |
| }; |
|
|
| if kind == next_kind { |
| out.push(emphasis_pair_symbol(kind, next_kind)); |
| i += 2; |
| continue; |
| } |
|
|
| if i + 2 < chars.len() |
| && let Some(lookahead_kind) = emphasis_mark_kind(chars[i + 2]) |
| && next_kind == lookahead_kind |
| { |
| out.push(chars[i]); |
| i += 1; |
| continue; |
| } |
|
|
| out.push(emphasis_pair_symbol(kind, next_kind)); |
| i += 2; |
| } |
|
|
| out |
| } |
|
|
| fn is_fullwidth_punctuation(ch: char) -> bool { |
| matches!( |
| ch, |
| '\u{3001}' |
| | '\u{3002}' |
| | '\u{3008}'..='\u{3011}' |
| | '\u{3014}'..='\u{301F}' |
| | '\u{3030}' |
| | '\u{30FB}' |
| | '\u{FF01}'..='\u{FF0F}' |
| | '\u{FF1A}'..='\u{FF20}' |
| | '\u{FF3B}'..='\u{FF40}' |
| | '\u{FF5B}'..='\u{FF65}' |
| ) |
| } |
|
|
| fn reorder_visual(levels: &[unicode_bidi::Level]) -> Vec<usize> { |
| let mut indices: Vec<usize> = (0..levels.len()).collect(); |
| if levels.is_empty() { |
| return indices; |
| } |
|
|
| let max_level = levels.iter().map(|l| l.number()).max().unwrap(); |
| let min_odd_level = levels |
| .iter() |
| .map(|l| l.number()) |
| .filter(|&n| n % 2 != 0) |
| .min() |
| .unwrap_or(u8::MAX); |
|
|
| if min_odd_level == u8::MAX { |
| return indices; |
| } |
|
|
| for level in (min_odd_level..=max_level).rev() { |
| let mut i = 0; |
| while i < levels.len() { |
| if levels[i].number() >= level { |
| let mut j = i; |
| while j < levels.len() && levels[j].number() >= level { |
| j += 1; |
| } |
| indices[i..j].reverse(); |
| i = j; |
| } else { |
| i += 1; |
| } |
| } |
| } |
| indices |
| } |
|
|
| #[cfg(test)] |
| mod tests { |
| use super::*; |
| use crate::font::{Font, FontBook}; |
| use skrifa::{ |
| MetadataProvider, |
| instance::{LocationRef, Size}, |
| }; |
|
|
| fn any_system_font() -> Font { |
| let mut book = FontBook::new(); |
|
|
| |
| |
| let preferred = [ |
| "Yu Gothic", |
| "MS Gothic", |
| "Noto Sans CJK JP", |
| "Noto Sans", |
| "Arial", |
| "DejaVu Sans", |
| "Liberation Sans", |
| ]; |
|
|
| for name in preferred { |
| if let Some(post_script_name) = book |
| .all_families() |
| .into_iter() |
| .find(|face| { |
| face.post_script_name == name |
| || face |
| .families |
| .iter() |
| .any(|(family, _)| family.as_str() == name) |
| }) |
| .map(|face| face.post_script_name) |
| .filter(|post_script_name| !post_script_name.is_empty()) |
| && let Ok(font) = book.query(&post_script_name) |
| { |
| return font; |
| } |
| } |
|
|
| if let Some(face) = book |
| .all_families() |
| .into_iter() |
| .find(|face| !face.post_script_name.is_empty()) |
| { |
| return book |
| .query(&face.post_script_name) |
| .expect("failed to load first system font"); |
| } |
|
|
| panic!("no system font available for tests"); |
| } |
|
|
| fn assert_approx_eq(actual: f32, expected: f32) { |
| if actual.is_infinite() |
| && expected.is_infinite() |
| && actual.is_sign_positive() == expected.is_sign_positive() |
| { |
| return; |
| } |
| let eps = 1e-4; |
| assert!( |
| (actual - expected).abs() <= eps, |
| "expected {expected}, got {actual}" |
| ); |
| } |
|
|
| #[test] |
| fn optimal_line_breaks_balance_ragged_lines() { |
| let segments = vec![ |
| LineBreakMeasure { |
| advance: 30.0, |
| break_suffix_advance: 0.0, |
| }; |
| 7 |
| ]; |
|
|
| assert_eq!(optimal_line_breaks(&segments, 100.0), vec![2, 4, 7]); |
| } |
|
|
| #[test] |
| fn layout_baselines_horizontal_follow_font_metrics() -> anyhow::Result<()> { |
| let font = any_system_font(); |
| let font_size = 16.0; |
| let layout = TextLayout::new(&font, Some(font_size)) |
| .with_writing_mode(WritingMode::Horizontal) |
| .run("A\nB\nC")?; |
|
|
| assert!(layout.lines.len() >= 2); |
|
|
| let metrics = font |
| .skrifa()? |
| .metrics(Size::new(font_size), LocationRef::default()); |
| let ascent = metrics.ascent; |
| let descent = -metrics.descent; |
| let line_height = (ascent + descent + metrics.leading).max(font_size); |
|
|
| let base_x = layout.lines[0].baseline.0; |
| for line in &layout.lines { |
| assert_approx_eq(line.baseline.0, base_x); |
| } |
| for i in 1..layout.lines.len() { |
| let dy = layout.lines[i].baseline.1 - layout.lines[i - 1].baseline.1; |
| assert_approx_eq(dy, line_height); |
| } |
|
|
| Ok(()) |
| } |
|
|
| #[test] |
| fn mandatory_newlines_are_not_shaped_as_glyphs() -> anyhow::Result<()> { |
| let font = any_system_font(); |
| let text = "A\nB\nC"; |
| let layout = TextLayout::new(&font, Some(16.0)) |
| .with_writing_mode(WritingMode::Horizontal) |
| .run(text)?; |
|
|
| assert_eq!(layout.lines.len(), 3); |
| for (line, expected) in layout.lines.iter().zip(["A", "B", "C"]) { |
| assert_eq!(&text[line.range.clone()], expected); |
| assert_eq!(line.glyphs.len(), 1); |
| } |
|
|
| Ok(()) |
| } |
|
|
| #[test] |
| fn layout_baselines_vertical_follow_font_metrics() -> anyhow::Result<()> { |
| let font = any_system_font(); |
| let font_size = 16.0; |
| let layout = TextLayout::new(&font, Some(font_size)) |
| .with_writing_mode(WritingMode::VerticalRl) |
| .run("A\nB\nC")?; |
|
|
| assert!(layout.lines.len() >= 2); |
|
|
| let metrics = font |
| .skrifa()? |
| .metrics(Size::new(font_size), LocationRef::default()); |
| let ascent = metrics.ascent; |
| let descent = -metrics.descent; |
| let line_height = (ascent + descent + metrics.leading).max(font_size); |
| let base_y = layout.lines[0].baseline.1; |
| for line in &layout.lines { |
| assert_approx_eq(line.baseline.1, base_y); |
| } |
|
|
| for i in 1..layout.lines.len() { |
| let dx = layout.lines[i - 1].baseline.0 - layout.lines[i].baseline.0; |
| assert_approx_eq(dx, line_height); |
| } |
|
|
| Ok(()) |
| } |
|
|
| #[test] |
| fn vertical_layout_horizontal_alignment_works() -> anyhow::Result<()> { |
| let font = any_system_font(); |
| let max_width = 100.0; |
| let layout = TextLayout::new(&font, Some(16.0)) |
| .with_writing_mode(WritingMode::VerticalRl) |
| .with_max_width(max_width) |
| .with_alignment(TextAlign::Center) |
| .run("A")?; |
|
|
| |
| |
| assert!(layout.width < max_width); |
| assert!(layout.width > 10.0); |
|
|
| Ok(()) |
| } |
|
|
| #[test] |
| fn vertical_layout_left_alignment_expands_width() -> anyhow::Result<()> { |
| let font = any_system_font(); |
| let max_width = 100.0; |
| let layout = TextLayout::new(&font, Some(16.0)) |
| .with_writing_mode(WritingMode::VerticalRl) |
| .with_max_width(max_width) |
| .with_alignment(TextAlign::Left) |
| .run("A")?; |
|
|
| assert_eq!(layout.width, max_width); |
| |
| assert!(layout.lines[0].baseline.0 < 20.0); |
|
|
| Ok(()) |
| } |
|
|
| #[test] |
| fn horizontal_center_alignment_centres_short_lines() -> anyhow::Result<()> { |
| |
| |
| |
| |
| let font = any_system_font(); |
| let max_width = 400.0; |
| let layout = TextLayout::new(&font, Some(20.0)) |
| .with_max_width(max_width) |
| .with_alignment(TextAlign::Center) |
| .run("HELLOWORLD\nHI")?; |
|
|
| assert_eq!(layout.lines.len(), 2); |
| let w0 = layout.lines[0].advance; |
| let w1 = layout.lines[1].advance; |
| let c0 = layout.lines[0].baseline.0 + w0 * 0.5; |
| let c1 = layout.lines[1].baseline.0 + w1 * 0.5; |
| |
| assert!( |
| (c0 - c1).abs() < 1.0, |
| "expected line centres to match, got c0={c0} c1={c1}", |
| ); |
| Ok(()) |
| } |
|
|
| #[test] |
| fn horizontal_layout_hyphenates_long_words() -> anyhow::Result<()> { |
| let font = any_system_font(); |
| let text = "antidisestablishmentarianism"; |
| let font_size = 24.0; |
| let unwrapped = TextLayout::new(&font, Some(font_size)).run(text)?; |
| let max_width = (unwrapped.lines[0].advance * 0.45).max(font_size * 4.0); |
|
|
| let layout = TextLayout::new(&font, Some(font_size)) |
| .with_max_width(max_width) |
| .run(text)?; |
|
|
| assert!( |
| layout.lines.len() > 1, |
| "expected hyphenation to wrap long word, got {layout:?}" |
| ); |
| for line in layout.lines.iter().take(layout.lines.len() - 1) { |
| assert!( |
| line.advance <= max_width + 1.0, |
| "hyphenated line should fit max width {max_width}, got {}", |
| line.advance |
| ); |
| } |
| assert!( |
| layout |
| .lines |
| .iter() |
| .take(layout.lines.len() - 1) |
| .any(|line| line |
| .glyphs |
| .iter() |
| .any(|glyph| glyph.cluster as usize == line.range.end)), |
| "expected a synthetic hyphen glyph at a discretionary break" |
| ); |
|
|
| Ok(()) |
| } |
|
|
| #[test] |
| fn horizontal_layout_wraps_chinese_on_jieba_word_boundaries() -> anyhow::Result<()> { |
| let font = any_system_font(); |
| let text = "\u{5357}\u{4eac}\u{5e02}\u{957f}\u{6c5f}\u{5927}\u{6865}"; |
| let font_size = 24.0; |
| let unwrapped = TextLayout::new(&font, Some(font_size)).run(text)?; |
| let layout = TextLayout::new(&font, Some(font_size)) |
| .with_max_width(unwrapped.lines[0].advance * 0.5) |
| .run(text)?; |
|
|
| assert!( |
| layout.lines.len() > 1, |
| "expected Chinese text to wrap, got {layout:?}" |
| ); |
| assert_eq!( |
| &text[layout.lines[0].range.clone()], |
| "\u{5357}\u{4eac}\u{5e02}" |
| ); |
|
|
| Ok(()) |
| } |
|
|
| #[test] |
| fn fullwidth_punctuation_detection_works() { |
| assert!(is_fullwidth_punctuation('。')); |
| assert!(is_fullwidth_punctuation('(')); |
| assert!(is_fullwidth_punctuation('!')); |
| assert!(!is_fullwidth_punctuation('A')); |
| assert!(!is_fullwidth_punctuation('中')); |
| } |
|
|
| #[test] |
| fn vertical_punctuation_centering_enabled_by_default() { |
| let font = any_system_font(); |
| let layout = TextLayout::new(&font, Some(16.0)); |
| assert!(layout.center_vertical_punctuation); |
| } |
|
|
| #[test] |
| fn centered_x_offset_uses_absolute_center() { |
| assert_approx_eq(centered_x_offset(2.0, 6.0), -4.0); |
| assert_approx_eq(centered_x_offset(-3.0, 1.0), 1.0); |
| } |
|
|
| #[test] |
| fn normalize_vertical_emphasis_punctuation_collapses_pairs() { |
| assert_eq!(normalize_vertical_emphasis_punctuation("!!"), "‼"); |
| assert_eq!(normalize_vertical_emphasis_punctuation("!!"), "‼"); |
| assert_eq!(normalize_vertical_emphasis_punctuation("!!?"), "‼?"); |
| assert_eq!(normalize_vertical_emphasis_punctuation("?!!"), "?‼"); |
| assert_eq!(normalize_vertical_emphasis_punctuation("!?!"), "⁉!"); |
| assert_eq!(normalize_vertical_emphasis_punctuation("!?"), "⁉"); |
| assert_eq!(normalize_vertical_emphasis_punctuation("?!"), "⁈"); |
| assert_eq!( |
| normalize_vertical_emphasis_punctuation("Hello!?!"), |
| "Hello⁉!" |
| ); |
| } |
|
|
| #[test] |
| fn horizontal_center_alignment_with_overflow_is_aligned_relative_to_widest() |
| -> anyhow::Result<()> { |
| let font = any_system_font(); |
| |
| let max_width = 20.0; |
| |
| let text = "LONGWORDTHATWILLOVERFLOW,\nHI"; |
| let layout = TextLayout::new(&font, Some(20.0)) |
| .with_max_width(max_width) |
| .with_alignment(TextAlign::Center) |
| .run(text)?; |
|
|
| let w0 = layout.lines[0].advance; |
| let w1 = layout.lines[1].advance; |
|
|
| |
| assert!( |
| w0 > max_width, |
| "Test error: widest line {w0} did not overflow max_width {max_width}" |
| ); |
|
|
| let c0 = layout.lines[0].baseline.0 + w0 * 0.5; |
| let c1 = layout.lines[1].baseline.0 + w1 * 0.5; |
|
|
| |
| |
| assert!( |
| (c0 - c1).abs() < 1.0, |
| "expected line centres to match even with overflow, got c0={c0} c1={c1} (max_width={max_width})", |
| ); |
| Ok(()) |
| } |
| } |
|
|