From db3e6e639deca495fe3b42339d9f1ecbdf43f63a Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Tue, 27 Oct 2026 16:21:02 +0000 Subject: [PATCH] :bug: Keep distributed and justified lines inside the text box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SkParagraph moves each line right by half the letter-spacing of its first character. That centres span tracking, but the spacing added to single characters (distribution and justification gaps, squeezing, compression and ruby room) must only open the gap after them. A distributed label of two characters started half a gap in and overflowed the box by as much: "国家" in a 200px box drew 国 92px from the left edge. Builders of horizontal text record how far each adjusted character's letter-spacing departs from its span tracking, and `LayoutParagraph` takes back half of that on each line, next to its baseline shifts: painting, glyph and placeholder rects, line metrics, hit testing and text paths. Span tracking keeps its half spacing before the line. AI-assisted-by: claude-opus-5-5 --- render-wasm/src/render/text.rs | 52 ++++---- render-wasm/src/shapes/text.rs | 10 +- render-wasm/src/shapes/text_horizontal/mod.rs | 38 ++++-- .../src/shapes/text_horizontal/tests.rs | 90 +++++++++++++ render-wasm/src/shapes/text_paragraph.rs | 118 +++++++++++++++++- render-wasm/src/shapes/text_paths.rs | 4 +- 6 files changed, 264 insertions(+), 48 deletions(-) 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;