diff --git a/render-wasm/src/render/text.rs b/render-wasm/src/render/text.rs index 9aa4dd84bb..c60e8ae6f8 100644 --- a/render-wasm/src/render/text.rs +++ b/render-wasm/src/render/text.rs @@ -1,5 +1,5 @@ use super::{filters, RenderState, Shape, SurfaceId, DEFAULT_EMOJI_FONT}; -use crate::shapes::text_paragraph::LayoutParagraph; +use crate::shapes::text_paragraph::{HorizontalBuilder, LayoutParagraph}; use crate::{ error::Result, math::Rect, @@ -14,7 +14,7 @@ use crate::{ use skia_safe::{ self as skia, canvas::SaveLayerRec, - textlayout::{ParagraphBuilder, RectHeightStyle, RectWidthStyle, StyleMetrics, TextDecoration}, + textlayout::{RectHeightStyle, RectWidthStyle, StyleMetrics, TextDecoration}, Canvas, ImageFilter, Paint, }; use std::borrow::Cow; @@ -181,7 +181,7 @@ pub fn stroke_paragraph_builder_group_from_text( let adjustment = adjustments .as_ref() .and_then(|adjustments| adjustments[index].as_ref()); - let mut stroke_paragraphs_map: std::collections::HashMap = + let mut stroke_paragraphs_map: std::collections::HashMap = std::collections::HashMap::new(); for (span_index, (((span, text), sheds), extra)) in paragraph @@ -226,7 +226,7 @@ pub fn stroke_paragraph_builder_group_from_text( paragraph_style.set_text_align(skia::textlayout::TextAlign::Left); } } - ParagraphBuilder::new(¶graph_style, fonts) + HorizontalBuilder::new(¶graph_style, fonts) }); let stroke_paint = stroke_paint.clone(); let remove_alpha = use_shadow.unwrap_or(false) && !span.is_transparent(); @@ -241,7 +241,7 @@ pub fn stroke_paragraph_builder_group_from_text( } } - let stroke_paragraphs: Vec = (0..stroke_paragraphs_map.len()) + let stroke_paragraphs: ParagraphBuilderGroup = (0..stroke_paragraphs_map.len()) .filter_map(|i| stroke_paragraphs_map.remove(&i)) .collect(); @@ -320,7 +320,7 @@ pub fn render_with_bounds_outset( render_state: Option<&mut RenderState>, canvas: Option<&Canvas>, shape: &Shape, - paragraph_builders: &mut [Vec], + paragraph_builders: &mut [ParagraphBuilderGroup], surface_id: Option, shadow: Option<&Paint>, blur: Option<&ImageFilter>, @@ -348,7 +348,7 @@ pub fn render_with_bounds_outset( pub fn render_with_bounds_outset_overlay_emoji( canvas: &Canvas, shape: &Shape, - paragraph_builders: &mut [Vec], + paragraph_builders: &mut [ParagraphBuilderGroup], shadow: Option<&Paint>, blur: Option<&ImageFilter>, stroke_bounds_outset: f32, @@ -440,7 +440,7 @@ fn render_with_bounds_outset_inner( render_state: Option<&mut RenderState>, canvas: Option<&Canvas>, shape: &Shape, - paragraph_builders: &mut [Vec], + paragraph_builders: &mut [ParagraphBuilderGroup], surface_id: Option, shadow: Option<&Paint>, blur: Option<&ImageFilter>, @@ -491,7 +491,7 @@ pub fn render( render_state: Option<&mut RenderState>, canvas: Option<&Canvas>, shape: &Shape, - paragraph_builders: &mut [Vec], + paragraph_builders: &mut [ParagraphBuilderGroup], surface_id: Option, shadow: Option<&Paint>, blur: Option<&ImageFilter>, @@ -517,7 +517,7 @@ pub fn render( pub fn render_overlay_emoji( canvas: &Canvas, shape: &Shape, - paragraph_builders: &mut [Vec], + paragraph_builders: &mut [ParagraphBuilderGroup], shadow: Option<&Paint>, blur: Option<&ImageFilter>, fill_inset: Option, @@ -659,7 +659,7 @@ fn paint_from_cached_layout(canvas: &Canvas, shape: &Shape, text_content: &TextC fn render_text_on_canvas( canvas: &Canvas, shape: &Shape, - paragraph_builders: &mut [Vec], + paragraph_builders: &mut [ParagraphBuilderGroup], shadow: Option<&Paint>, blur: Option<&ImageFilter>, fill_inset: Option, @@ -736,7 +736,7 @@ fn render_text_on_canvas( pub fn paint_text_paragraphs( canvas: &Canvas, shape: &Shape, - paragraph_builder_groups: &mut [Vec], + paragraph_builder_groups: &mut [ParagraphBuilderGroup], ) { paint_text_with_emoji_overlay(canvas, shape, paragraph_builder_groups, false); } @@ -745,7 +745,7 @@ pub fn paint_text_paragraphs( fn paint_text( canvas: &Canvas, shape: &Shape, - paragraph_builder_groups: &mut [Vec], + paragraph_builder_groups: &mut [ParagraphBuilderGroup], ) { paint_text_with_emoji_overlay(canvas, shape, paragraph_builder_groups, false); } @@ -771,7 +771,7 @@ pub fn paint_text_mask(canvas: &Canvas, shape: &Shape) { fn paint_text_with_emoji_overlay( canvas: &Canvas, shape: &Shape, - paragraph_builder_groups: &mut [Vec], + paragraph_builder_groups: &mut [ParagraphBuilderGroup], overlay_emoji: bool, ) { let text_content = shape.get_text_content(); @@ -1064,8 +1064,8 @@ fn paint_emoji_opaque( pub fn render_emoji_overlay( render_state: &mut RenderState, shape: &Shape, - emoji_builders: &mut [Vec], - deco_builders: &mut [Vec], + emoji_builders: &mut [ParagraphBuilderGroup], + deco_builders: &mut [ParagraphBuilderGroup], surface_id: SurfaceId, blur: Option<&ImageFilter>, ) { @@ -1122,7 +1122,7 @@ pub fn render_emoji_overlay( fn draw_text( canvas: &Canvas, shape: &Shape, - paragraph_builder_groups: &mut [Vec], + paragraph_builder_groups: &mut [ParagraphBuilderGroup], layer_opacity: Option, overlay_emoji: bool, ) { @@ -1156,9 +1156,9 @@ fn draw_text( fn render_masked_stroke_on_canvas( canvas: &Canvas, shape: &Shape, - mask_builders: &mut [Vec], - stroke_builders: &mut [Vec], - fill_builders: Option<&mut [Vec]>, + mask_builders: &mut [ParagraphBuilderGroup], + stroke_builders: &mut [ParagraphBuilderGroup], + fill_builders: Option<&mut [ParagraphBuilderGroup]>, stroke_mask_blend: skia::BlendMode, blur: Option<&ImageFilter>, layer_opacity: Option, @@ -1236,9 +1236,9 @@ fn render_masked_stroke( render_state: Option<&mut RenderState>, canvas: Option<&Canvas>, shape: &Shape, - mask_builders: &mut [Vec], - stroke_builders: &mut [Vec], - mut fill_builders: Option<&mut [Vec]>, + mask_builders: &mut [ParagraphBuilderGroup], + stroke_builders: &mut [ParagraphBuilderGroup], + mut fill_builders: Option<&mut [ParagraphBuilderGroup]>, stroke_mask_blend: skia::BlendMode, surface_id: Option, blur: Option<&ImageFilter>, @@ -1318,8 +1318,8 @@ pub fn render_inner_stroke( render_state: Option<&mut RenderState>, canvas: Option<&Canvas>, shape: &Shape, - stroke_builders: &mut [Vec], - fill_builders: &mut [Vec], + stroke_builders: &mut [ParagraphBuilderGroup], + fill_builders: &mut [ParagraphBuilderGroup], surface_id: Option, blur: Option<&ImageFilter>, stroke_bounds_outset: f32, @@ -1346,7 +1346,7 @@ pub fn render_outer_stroke( render_state: Option<&mut RenderState>, canvas: Option<&Canvas>, shape: &Shape, - stroke_builders: &mut [Vec], + stroke_builders: &mut [ParagraphBuilderGroup], surface_id: Option, blur: Option<&ImageFilter>, stroke_bounds_outset: f32, diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 6639c2011b..7ae31a418f 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -1,5 +1,5 @@ use crate::render::text::decoration_segments; -use crate::shapes::text_paragraph::LayoutParagraph; +use crate::shapes::text_paragraph::{HorizontalBuilder, LayoutParagraph}; use crate::{ math::{Bounds, Matrix, Rect}, render::{default_font, DEFAULT_EMOJI_FONT}, @@ -39,7 +39,7 @@ use crate::utils::{get_fallback_fonts, get_font_collection}; use crate::Uuid; // TODO: maybe move this to the wasm module? -pub type ParagraphBuilderGroup = Vec; +pub type ParagraphBuilderGroup = Vec; /// True when the modifier changes the text layout container (resize), as opposed /// to rotation/move where glyph layout can be reused. @@ -784,13 +784,13 @@ impl TextContent { paragraph: &Paragraph, plan: &HorizontalParagraphPlan, extra_sheds: &[Vec<(usize, f32)>], - ) -> ParagraphBuilder { + ) -> HorizontalBuilder { let fonts = get_font_collection(); let fallback_fonts = get_fallback_fonts(); let mut paragraph_style = paragraph.paragraph_to_style(); paragraph_style.set_apply_rounding_hack(false); paragraph_style.set_text_align(skia::textlayout::TextAlign::Left); - let mut builder = ParagraphBuilder::new(¶graph_style, fonts); + let mut builder = HorizontalBuilder::new(¶graph_style, fonts); for (span_index, ((span, text), sheds)) in paragraph .children() .iter() @@ -1400,7 +1400,7 @@ impl TextContent { // The justify spacing already fills the lines. paragraph_style.set_text_align(skia::textlayout::TextAlign::Left); } - let mut builder = ParagraphBuilder::new(¶graph_style, fonts); + let mut builder = HorizontalBuilder::new(¶graph_style, fonts); let mut has_text = false; for (span_index, (((span, text), plan_sheds), ruby_extra)) in paragraph .children() diff --git a/render-wasm/src/shapes/text_horizontal/mod.rs b/render-wasm/src/shapes/text_horizontal/mod.rs index 2e993b10d0..44096e9a32 100644 --- a/render-wasm/src/shapes/text_horizontal/mod.rs +++ b/render-wasm/src/shapes/text_horizontal/mod.rs @@ -1,4 +1,4 @@ -use crate::shapes::text_paragraph::LayoutParagraph; +use crate::shapes::text_paragraph::{LayoutParagraph, SpacedParagraphBuilder}; // Horizontal Japanese layout on top of SkParagraph: the kinsoku layout // texts, punctuation aki, offset maps between source, layout and builder // text, and the ruby, emphasis and warichu painters. @@ -164,7 +164,7 @@ fn horizontal_policy_spacing(paragraph: &Paragraph, texts: &[String]) -> Vec TextContent { + let mut paragraph = Paragraph::new( + align, + TextDirection::LTR, + None, + None, + 1.0, + tracking, + vec![TextSpan { + fills: vec![crate::shapes::Fill::Solid(crate::shapes::SolidColor( + skia::Color::BLACK, + ))], + ..make_span(text, tracking) + }], + ); + paragraph.set_language("zh-Hans".to_string()); + let bounds = crate::math::Rect::from_xywh(0.0, 0.0, width, 200.0); + let mut content = TextContent::new(bounds, crate::shapes::GrowType::Fixed); + content.add_paragraph(paragraph); + content.update_layout(bounds); + content +} + +#[test] +fn spread_lines_start_and_end_at_the_box_edges() { + use skia::textlayout::{RectHeightStyle, RectWidthStyle}; + init_state(); + let mut resources = + crate::render::RenderResources::try_new_headless().expect("headless resources"); + let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources); + for (align, text, width) in [ + (TextAlign::Distribute, "国家", 200.0), + (TextAlign::Distribute, "手机号", 200.0), + (TextAlign::Justify, "国家国家国家国家国家国家国家国家", 50.0), + ] { + let content = aligned_content(text, 0.0, align, width); + let layout = &content.layout.paragraphs[0][0]; + let lines = layout.get_line_metrics(); + let spread = if align == TextAlign::Distribute { + lines.len() + } else { + lines.len() - 1 + }; + assert!(spread > 0, "{text}: no spread line"); + let rect = |at: usize| { + layout.get_rects_for_range(at..at + 1, RectHeightStyle::Tight, RectWidthStyle::Tight)[0] + .rect + }; + for line in lines.iter().take(spread) { + let first = rect(line.start_index); + let last = rect(line.end_excluding_whitespaces - 1); + assert!( + first.left.abs() < 0.1, + "{text}: line starts at {}", + first.left + ); + assert!(line.left.abs() < 0.1, "{text}: line left {}", line.left); + assert!( + (last.right - width).abs() < 0.1, + "{text}: line ends at {}, box {width}", + last.right + ); + let hit = layout.get_glyph_position_at_coordinate((first.left + 1.0, first.center_y())); + assert_eq!(hit.position as usize, line.start_index, "{text}: hit test"); + } + // The painted ink starts at the box edge too, not half a gap in. + let mut surface = skia::surfaces::raster_n32_premul((400, 200)).unwrap(); + layout.paint(surface.canvas(), (100.0, 0.0)); + let pixels = surface.peek_pixels().unwrap(); + let ink_left = (0..400) + .find(|x| (0..200).any(|y| pixels.get_color((*x, y)).a() > 0)) + .expect("visible ink"); + assert!( + (100..104).contains(&ink_left), + "{text}: ink starts at {}", + ink_left - 100 + ); + } + // Span tracking keeps SkParagraph's half spacing before the line. + let content = aligned_content("国家", 10.0, TextAlign::Left, 200.0); + let layout = &content.layout.paragraphs[0][0]; + let first = layout.get_rects_for_range(0..1, RectHeightStyle::Tight, RectWidthStyle::Tight)[0]; + assert!( + (first.rect.left - 5.0).abs() < 0.01, + "tracked line starts at {}", + first.rect.left + ); +} diff --git a/render-wasm/src/shapes/text_paragraph.rs b/render-wasm/src/shapes/text_paragraph.rs index 256f8c7d5b..4134a5f951 100644 --- a/render-wasm/src/shapes/text_paragraph.rs +++ b/render-wasm/src/shapes/text_paragraph.rs @@ -1,32 +1,115 @@ //! SkParagraph rounds every line height and baseline, even with its rounding //! hack disabled. Keep its shaping and horizontal layout, but use the original //! ascent/descent for vertical geometry and painting. +//! +//! SkParagraph also moves each line by half the letter-spacing of its first +//! character. That centers span tracking, but letter-spacing added to single +//! characters (justification, distribution, squeezing, ruby room) must only +//! open the gap after them, so each line takes back half of that extra. use skia_safe::{ textlayout::{ - LineMetrics, Paragraph, ParagraphBuilder, PositionWithAffinity, RectHeightStyle, - RectWidthStyle, TextBox, + FontCollection, LineMetrics, Paragraph, ParagraphBuilder, ParagraphStyle, + PositionWithAffinity, RectHeightStyle, RectWidthStyle, TextBox, }, Canvas, Paint, Point, TextBlob, TextBlobBuilder, }; use std::ops::{Deref, DerefMut, Range}; +/// A builder `LayoutParagraph` can lay out, with the letter-spacing its +/// characters take beyond their span tracking. +pub trait SpacedParagraphBuilder { + fn paragraph_builder(&mut self) -> &mut ParagraphBuilder; + + /// Records `amount` of extra letter-spacing on the character at the + /// builder's UTF-16 offset `at`. + fn add_extra_spacing(&mut self, _at: usize, _amount: f32) {} + + /// Extra letter-spacing as (UTF-16 offset, amount), sorted by offset. + fn extra_spacing(&self) -> &[(usize, f32)] { + &[] + } +} + +impl SpacedParagraphBuilder for ParagraphBuilder { + fn paragraph_builder(&mut self) -> &mut ParagraphBuilder { + self + } +} + +/// Paragraph builder of horizontal text, which records the extra +/// letter-spacing of its characters. +pub struct HorizontalBuilder { + builder: ParagraphBuilder, + extra_spacing: Vec<(usize, f32)>, +} + +impl HorizontalBuilder { + pub fn new(style: &ParagraphStyle, fonts: impl Into) -> Self { + Self { + builder: ParagraphBuilder::new(style, fonts), + extra_spacing: Vec::new(), + } + } +} + +impl SpacedParagraphBuilder for HorizontalBuilder { + fn paragraph_builder(&mut self) -> &mut ParagraphBuilder { + &mut self.builder + } + + fn add_extra_spacing(&mut self, at: usize, amount: f32) { + self.extra_spacing.push((at, amount)); + } + + fn extra_spacing(&self) -> &[(usize, f32)] { + &self.extra_spacing + } +} + +impl std::fmt::Debug for HorizontalBuilder { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("HorizontalBuilder") + .field("extra_spacing", &self.extra_spacing) + .finish_non_exhaustive() + } +} + +impl Deref for HorizontalBuilder { + type Target = ParagraphBuilder; + fn deref(&self) -> &ParagraphBuilder { + &self.builder + } +} + +impl DerefMut for HorizontalBuilder { + fn deref_mut(&mut self) -> &mut ParagraphBuilder { + &mut self.builder + } +} + #[derive(Debug)] pub struct LayoutParagraph { inner: Paragraph, text: String, + extra_spacing: Vec<(usize, f32)>, shifts: Vec, + x_shifts: Vec, height: f32, runs: Vec<(TextBlob, Paint, Point)>, } impl LayoutParagraph { - pub fn build(builder: &mut ParagraphBuilder) -> Self { + pub fn build(builder: &mut impl SpacedParagraphBuilder) -> Self { + let extra_spacing = builder.extra_spacing().to_vec(); + let builder = builder.paragraph_builder(); let text = builder.get_text().to_owned(); Self { inner: builder.build(), text, + extra_spacing, shifts: Vec::new(), + x_shifts: Vec::new(), height: 0.0, runs: Vec::new(), } @@ -36,6 +119,7 @@ impl LayoutParagraph { self.inner.layout(width); let lines = self.inner.get_line_metrics(); self.shifts.clear(); + self.x_shifts.clear(); self.height = 0.0; // Keep the first baseline anchored as before. Only remove drift // between lines; this also preserves existing one-line layouts. @@ -46,12 +130,22 @@ impl LayoutParagraph { self.shifts .push(self.height + line.ascent as f32 + anchor - line.baseline as f32); self.height += (line.ascent + line.descent) as f32; + let first = self + .extra_spacing + .binary_search_by_key(&line.start_index, |(at, _)| *at) + .map_or(0.0, |found| self.extra_spacing[found].1); + self.x_shifts.push(-first / 2.0); } if lines.is_empty() { self.height = self.inner.height(); } self.runs.clear(); - if self.shifts.iter().all(|shift| shift.abs() < 0.00001) { + if self + .shifts + .iter() + .chain(&self.x_shifts) + .all(|shift| shift.abs() < 0.00001) + { return; } // Copy the paints before visiting: Skia exposes style metrics as @@ -77,6 +171,7 @@ impl LayoutParagraph { boundaries.dedup(); let text = &self.text; let shifts = &self.shifts; + let x_shifts = &self.x_shifts; let runs = &mut self.runs; self.inner.visit(|line, info| { let Some(info) = info.filter(|info| info.count() > 0) else { @@ -115,6 +210,7 @@ impl LayoutParagraph { } if let Some(blob) = builder.make() { let mut origin = info.origin(); + origin.x += x_shifts.get(line).copied().unwrap_or(0.0); origin.y += shifts.get(line).copied().unwrap_or(0.0); runs.push((blob, paint, origin)); } @@ -125,6 +221,10 @@ impl LayoutParagraph { self.shifts.get(line).copied().unwrap_or(0.0) } + pub fn line_x_shift(&self, line: usize) -> f32 { + self.x_shifts.get(line).copied().unwrap_or(0.0) + } + pub fn height(&self) -> f32 { self.height } @@ -136,6 +236,7 @@ impl LayoutParagraph { pub fn get_line_metrics(&self) -> Vec> { let mut lines = self.inner.get_line_metrics(); for (index, line) in lines.iter_mut().enumerate() { + line.left += self.line_x_shift(index) as f64; line.baseline += self.line_shift(index) as f64; line.height = line.ascent + line.descent; } @@ -157,7 +258,9 @@ impl LayoutParagraph { } let mut boxes = self.inner.get_rects_for_range(start..end, height, width); for textbox in &mut boxes { - textbox.rect.offset((0.0, self.line_shift(index))); + textbox + .rect + .offset((self.line_x_shift(index), self.line_shift(index))); } result.extend(boxes); } @@ -173,7 +276,9 @@ impl LayoutParagraph { .iter() .position(|line| center < (line.baseline + line.descent) as f32) .unwrap_or(lines.len().saturating_sub(1)); - textbox.rect.offset((0.0, self.line_shift(index))); + textbox + .rect + .offset((self.line_x_shift(index), self.line_shift(index))); } boxes } @@ -188,6 +293,7 @@ impl LayoutParagraph { .iter() .position(|line| point.y < (line.baseline + line.descent) as f32) .unwrap_or(lines.len().saturating_sub(1)); + point.x -= self.line_x_shift(index); point.y -= self.line_shift(index); self.inner.get_glyph_position_at_coordinate(point) } diff --git a/render-wasm/src/shapes/text_paths.rs b/render-wasm/src/shapes/text_paths.rs index 8613c9c651..201237db77 100644 --- a/render-wasm/src/shapes/text_paths.rs +++ b/render-wasm/src/shapes/text_paths.rs @@ -78,7 +78,7 @@ impl TextPaths { } let shifts: Vec<_> = (0..paragraph.get_line_metrics().len()) - .map(|line| paragraph.line_shift(line)) + .map(|line| Point::new(paragraph.line_x_shift(line), paragraph.line_shift(line))) .collect(); paragraph.visit(|line: usize, info: Option<&VisitorInfo>| { let Some(info) = info else { @@ -86,7 +86,7 @@ impl TextPaths { }; let font = info.font(); - let run_origin = origin + info.origin() + Point::new(0.0, shifts[line]); + let run_origin = origin + info.origin() + shifts[line]; let mut builder = skia::PathBuilder::new(); let mut has_glyphs = false;