diff --git a/render-wasm/src/render/svg/fixtures.rs b/render-wasm/src/render/svg/fixtures.rs index a112752b6b..b8dfa4b785 100644 --- a/render-wasm/src/render/svg/fixtures.rs +++ b/render-wasm/src/render/svg/fixtures.rs @@ -7,9 +7,9 @@ use skia_safe as skia; use crate::globals::TestRenderResourcesGuard; use crate::render::{FontStore, RenderResources}; use crate::shapes::{ - make_corners, Fill, FontFamily, FontStyle, Frame, Group, GrowType, ImageFill, Paragraph, Path, - Rect, Segment, SolidColor, Stroke, StrokeKind, StrokeStyle, TextAlign, TextContent, - TextDirection, TextSpan, Type, + make_corners, Fill, FontFamily, FontStyle, Frame, Group, GrowType, ImageFill, ListStyleType, + Paragraph, Path, Rect, Segment, SolidColor, Stroke, StrokeKind, StrokeStyle, TextAlign, + TextContent, TextDirection, TextSpan, Type, }; use crate::state::ShapesPool; use crate::utils::uuid_from_u32_quartet; @@ -384,6 +384,52 @@ pub(super) fn add_text_with_spans( shape.set_shape_type(Type::Text(content)); } +/// Fixed text with one solid-filled paragraph per `(text, list style)` entry. +pub(super) fn add_text_with_list_paragraphs( + pool: &mut ShapesPool, + id: Uuid, + (l, t, r, b): (f32, f32, f32, f32), + font_size: f32, + fill: skia::Color, + paragraphs: Vec<(&str, ListStyleType)>, +) { + let bounds = skia::Rect::from_ltrb(l, t, r, b); + let mut content = TextContent::new(bounds, GrowType::Fixed); + let line_height = 1.2; + for (text, list_style_type) in paragraphs { + let span = TextSpan::new( + text.to_string(), + FontFamily::new(Uuid::nil(), 400, FontStyle::Normal), + font_size, + line_height, + 0.0, + None, + None, + TextDirection::LTR, + 400, + Uuid::nil(), + vec![Fill::Solid(SolidColor(fill))], + ); + content.add_paragraph( + Paragraph::new( + TextAlign::Left, + TextDirection::LTR, + None, + None, + line_height, + 0.0, + vec![span], + ) + .with_list_style_type(list_style_type), + ); + } + + let shape = pool.add_shape(id); + shape.set_parent(Uuid::nil()); + shape.set_selrect(l, t, r, b); + shape.set_shape_type(Type::Text(content)); +} + /// Adds a rectangle with a single stroke and no fill. pub(super) fn add_stroked_rect( pool: &mut ShapesPool, diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_list_bullets_before_list_paragraphs_only.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_list_bullets_before_list_paragraphs_only.snap new file mode 100644 index 0000000000..d76f3044a5 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_list_bullets_before_list_paragraphs_only.snap @@ -0,0 +1,24 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + Bullet + + + + Plain + + + + Bullet + + + + + + Plain + + diff --git a/render-wasm/src/render/svg/tests.rs b/render-wasm/src/render/svg/tests.rs index 490d5ef27a..f034bd6ebb 100644 --- a/render-wasm/src/render/svg/tests.rs +++ b/render-wasm/src/render/svg/tests.rs @@ -2,8 +2,8 @@ use super::fixtures::*; use crate::shapes::{ radius_to_sigma, AlignContent, AlignItems, BlendMode, Blur, BlurType, Fill, FlexDirection, - ImageFill, ImageFillTransform, JustifyContent, JustifyItems, Shadow, ShadowStyle, SolidColor, - StrokeCap, StrokeKind, WrapType, + ImageFill, ImageFillTransform, JustifyContent, JustifyItems, ListStyleType, Shadow, + ShadowStyle, SolidColor, StrokeCap, StrokeKind, WrapType, }; use crate::state::ShapesPool; use crate::uuid::Uuid; @@ -2026,6 +2026,33 @@ fn exports_text_with_multiple_solid_fills() { insta::assert_snapshot!(svg); } +#[test] +fn exports_list_bullets_before_list_paragraphs_only() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_text_with_list_paragraphs( + &mut pool, + id, + (40.0, 0.0, 300.0, 120.0), + 30.0, + skia::Color::RED, + vec![ + ("Bullet", ListStyleType::Disc), + ("Plain", ListStyleType::None), + ], + ); + pool.get_mut(&id) + .unwrap() + .add_stroke(solid_stroke(StrokeKind::Center, 2.0, skia::Color::BLUE)); + + let svg = render(&pool, id); + // One bullet, before "Bullet" only: a red fill and a blue stroke. + assert_eq!(svg.matches("( + skia_paragraphs: impl IntoIterator>, + text_paragraphs: &[TextParagraph], + x: f32, + top: f32, + layout_width: f32, +) -> Vec { + let mut markers: Vec = Vec::new(); + let mut y = top; + for (skia_paragraph, text_paragraph) in skia_paragraphs.into_iter().zip(text_paragraphs) { + let Some(skia_paragraph) = skia_paragraph else { + continue; + }; + if let Some(marker) = list_marker( + skia_paragraph, + text_paragraph, + x, + y, + layout_width, + markers.last(), + ) { + markers.push(marker); + } + y += skia_paragraph.height(); + } + markers +} + +/// The bullet of a list paragraph, in shape coordinates. It hangs before the +/// text box's start edge, level with the first line, a gap away that grows +/// with the font size. +/// +/// An empty paragraph has no style run, so it takes the paint of `previous` +/// (the last list item painted in the same pass) and its own span's size. +pub fn list_marker( + skia_paragraph: &skia::textlayout::Paragraph, + text_paragraph: &TextParagraph, + x: f32, + y_accum: f32, + layout_width: f32, + previous: Option<&ListMarker>, +) -> Option { + if text_paragraph.list_style_type() == ListStyleType::None { + return None; + } + + let line_metrics = skia_paragraph.get_line_metrics(); + let line = line_metrics.first()?; + let style_metrics = line.get_style_metrics(line.start_index..line.end_index); + let (paint, font_size) = match style_metrics.first() { + Some((_, metrics)) => { + let mut paint = metrics.text_style.foreground(); + paint.set_anti_alias(true); + (paint, metrics.text_style.font_size()) + } + None => { + let font_size = text_paragraph.children().first()?.font_size; + (previous?.paint.clone(), font_size) + } + }; + + let radius = (font_size * LIST_MARKER_RADIUS_FACTOR).max(LIST_MARKER_MIN_RADIUS); + let offset = radius + font_size * LIST_MARKER_GAP_FACTOR; + let center_x = if text_paragraph.text_direction() == TextDirection::RTL { + x + layout_width + offset + } else { + x - offset + }; + let baseline = y_accum + line.baseline as f32; + + Some(ListMarker { + paint, + center: Point::new(center_x, baseline - font_size * LIST_MARKER_CENTER_FACTOR), + radius, + }) +} + fn draw_decoration_segment(canvas: &Canvas, deco: &TextDecorationSegment) { let mut decoration_paint = deco.text_style.foreground(); decoration_paint.set_anti_alias(true); @@ -1235,7 +1337,12 @@ fn calculate_decoration_metrics( #[cfg(test)] mod tests { use super::*; - use crate::shapes::{FontFamily, FontStyle, TextAlign, TextDirection, TextSpan, TextTransform}; + use crate::globals::TestRenderResourcesGuard; + use crate::render::RenderResources; + use crate::shapes::{ + Fill, FontFamily, FontStyle, GrowType, ListStyleType, SolidColor, TextAlign, TextDirection, + TextSpan, TextTransform, Type, + }; use crate::uuid::Uuid; fn span( @@ -1319,4 +1426,320 @@ mod tests { assert!(decorated_span_ranges(¶).is_empty()); } + + fn sized_span(text: &str, font_size: f32, fills: Vec) -> TextSpan { + TextSpan::new( + text.to_string(), + FontFamily::new(Uuid::nil(), 400, FontStyle::Normal), + font_size, + 1.2, + 0.0, + None, + None, + TextDirection::LTR, + 400, + Uuid::nil(), + fills, + ) + } + + fn list_paragraph( + text: &str, + font_size: f32, + direction: TextDirection, + list_style_type: ListStyleType, + ) -> TextParagraph { + TextParagraph::new( + TextAlign::Left, + direction, + None, + None, + 1.2, + 0.0, + vec![sized_span(text, font_size, vec![])], + ) + .with_list_style_type(list_style_type) + } + + fn filled_list_item(text: &str, font_size: f32, color: skia::Color) -> TextParagraph { + TextParagraph::new( + TextAlign::Left, + TextDirection::LTR, + None, + None, + 1.2, + 0.0, + vec![sized_span( + text, + font_size, + vec![Fill::Solid(SolidColor(color))], + )], + ) + .with_list_style_type(ListStyleType::Disc) + } + + /// A fixed text at (10, 20), `width` wide, laid out into paragraph layouts. + fn layout_paragraphs(paragraphs: Vec, width: f32) -> Vec { + let selrect = crate::math::Rect::from_xywh(10.0, 20.0, width, 200.0); + let mut content = TextContent::new(selrect, GrowType::Fixed); + for paragraph in paragraphs { + content.add_paragraph(paragraph); + } + let mut shape = Shape::new(Uuid::nil()); + shape.set_shape_type(Type::Text(content.clone())); + shape.set_selrect(10.0, 20.0, 10.0 + width, 220.0); + + let mut builders = content.paragraph_builder_group_from_text(None); + calculate_text_layout_data(&shape, &content, &mut builders, true).paragraphs + } + + fn first_baseline(layout: &ParagraphLayout) -> f32 { + layout.y + layout.paragraph.get_line_metrics()[0].baseline as f32 + } + + #[test] + fn list_marker_hangs_before_the_first_line() { + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + let _guard = TestRenderResourcesGuard::install(&mut resources); + let layouts = layout_paragraphs( + vec![list_paragraph( + "item", + 20.0, + TextDirection::LTR, + ListStyleType::Disc, + )], + 300.0, + ); + + let marker = layouts[0] + .marker + .as_ref() + .expect("disc paragraph has a marker"); + // Font size 20: radius 3.2, gap 6.6, center 6.4 above the baseline. + assert!((marker.center.x - (10.0 - 9.8)).abs() < 0.01, "{marker:?}"); + assert!( + (marker.center.y - (first_baseline(&layouts[0]) - 6.4)).abs() < 0.01, + "{marker:?}" + ); + assert!((marker.radius - 3.2).abs() < 0.01, "{marker:?}"); + } + + #[test] + fn rtl_list_marker_hangs_after_the_text_box() { + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + let _guard = TestRenderResourcesGuard::install(&mut resources); + let layouts = layout_paragraphs( + vec![list_paragraph( + "item", + 20.0, + TextDirection::RTL, + ListStyleType::Disc, + )], + 300.0, + ); + + let marker = layouts[0] + .marker + .as_ref() + .expect("disc paragraph has a marker"); + assert!( + (marker.center.x - (10.0 + 300.0 + 9.8)).abs() < 0.01, + "{marker:?}" + ); + } + + #[test] + fn wrapped_list_paragraph_has_one_marker_on_its_first_line() { + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + let _guard = TestRenderResourcesGuard::install(&mut resources); + let layouts = layout_paragraphs( + vec![list_paragraph( + "one two three four five six", + 20.0, + TextDirection::LTR, + ListStyleType::Disc, + )], + 60.0, + ); + + assert!(layouts[0].paragraph.get_line_metrics().len() > 1); + let marker = layouts[0] + .marker + .as_ref() + .expect("disc paragraph has a marker"); + assert!( + (marker.center.y - (first_baseline(&layouts[0]) - 6.4)).abs() < 0.01, + "{marker:?}" + ); + } + + #[test] + fn list_marker_keeps_a_gap_before_the_text_at_any_size() { + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + let _guard = TestRenderResourcesGuard::install(&mut resources); + for font_size in [4.0, 14.0, 72.0, 300.0] { + let layouts = layout_paragraphs( + vec![list_paragraph( + "item", + font_size, + TextDirection::LTR, + ListStyleType::Disc, + )], + 2000.0, + ); + + let marker = layouts[0] + .marker + .as_ref() + .expect("disc paragraph has a marker"); + // The text starts at x = 10; the gap grows with the font size. + assert!( + marker.rect().right() <= 10.0 - font_size * 0.32, + "font size {font_size}: {marker:?}" + ); + } + } + + /// The color painted at `point` when `paragraph` is painted through the + /// cached layout of a 200x100 text at (40, 40). + fn cached_paint_color_at( + paragraph: TextParagraph, + point: impl Fn(&ParagraphLayout) -> Point, + ) -> skia::Color { + let selrect = crate::math::Rect::from_xywh(40.0, 40.0, 200.0, 100.0); + let mut content = TextContent::new(selrect, GrowType::Fixed); + content.add_paragraph(paragraph); + let mut shape = Shape::new(Uuid::nil()); + shape.set_shape_type(Type::Text(content)); + shape.set_selrect(40.0, 40.0, 240.0, 140.0); + + let content = shape.get_text_content().clone(); + let mut builders = content.paragraph_builder_group_from_text(None); + let layouts = calculate_text_layout_data(&shape, &content, &mut builders, true); + let at = point(&layouts.paragraphs[0]); + + let mut surface = skia::surfaces::raster_n32_premul((300, 200)).expect("raster surface"); + surface.canvas().clear(skia::Color::TRANSPARENT); + let painted = + try_paint_from_layout_cache(None, Some(surface.canvas()), &shape, None, false) + .expect("cached paint"); + assert!(painted, "the layout cache should be usable"); + + let info = skia::ImageInfo::new( + (1, 1), + skia::ColorType::RGBA8888, + skia::AlphaType::Unpremul, + None, + ); + let mut px = [0u8; 4]; + assert!(surface.image_snapshot().read_pixels( + &info, + &mut px, + 4, + (at.x as i32, at.y as i32), + skia::image::CachingHint::Disallow + )); + skia::Color::from_argb(px[3], px[0], px[1], px[2]) + } + + #[test] + fn cached_paint_draws_the_list_marker() { + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + let _guard = TestRenderResourcesGuard::install(&mut resources); + let color = + cached_paint_color_at(filled_list_item("item", 30.0, skia::Color::RED), |layout| { + layout + .marker + .as_ref() + .expect("disc paragraph has a marker") + .center + }); + assert_eq!(color, skia::Color::RED); + } + + #[test] + fn cached_paint_draws_no_marker_for_plain_text() { + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + let _guard = TestRenderResourcesGuard::install(&mut resources); + // Where a 30px bullet would be: radius 4.8 plus a 9.9 gap before x = 40. + let paragraph = filled_list_item("item", 30.0, skia::Color::RED) + .with_list_style_type(ListStyleType::None); + let color = cached_paint_color_at(paragraph, |layout| { + Point::new(40.0 - 14.7, first_baseline(layout) - 9.6) + }); + assert_eq!(color.a(), 0, "{color:?}"); + } + + #[test] + fn plain_paragraph_has_no_marker() { + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + let _guard = TestRenderResourcesGuard::install(&mut resources); + let layouts = layout_paragraphs( + vec![list_paragraph( + "item", + 20.0, + TextDirection::LTR, + ListStyleType::None, + )], + 300.0, + ); + + assert!(layouts[0].marker.is_none()); + } + + #[test] + fn small_text_marker_keeps_a_minimum_radius() { + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + let _guard = TestRenderResourcesGuard::install(&mut resources); + let layouts = layout_paragraphs( + vec![list_paragraph( + "item", + 4.0, + TextDirection::LTR, + ListStyleType::Disc, + )], + 300.0, + ); + + let marker = layouts[0] + .marker + .as_ref() + .expect("disc paragraph has a marker"); + assert!((marker.radius - 1.5).abs() < 0.01, "{marker:?}"); + } + + #[test] + fn empty_list_paragraph_borrows_the_previous_item_paint() { + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + let _guard = TestRenderResourcesGuard::install(&mut resources); + let layouts = layout_paragraphs( + vec![ + filled_list_item("item", 20.0, skia::Color::RED), + filled_list_item("", 10.0, skia::Color::BLUE), + ], + 300.0, + ); + + let empty = layouts[1].marker.as_ref().expect("empty item has a marker"); + // Its own size, the previous item's paint. + assert!((empty.radius - 1.6).abs() < 0.01, "{empty:?}"); + assert_eq!(empty.paint.color(), skia::Color::RED); + } + + #[test] + fn empty_first_list_paragraph_has_no_marker() { + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + let _guard = TestRenderResourcesGuard::install(&mut resources); + let layouts = layout_paragraphs( + vec![list_paragraph( + "", + 20.0, + TextDirection::LTR, + ListStyleType::Disc, + )], + 300.0, + ); + + assert!(layouts[0].marker.is_none()); + } } diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index f139770095..d3a15eb34c 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -1,4 +1,4 @@ -use crate::render::text::decoration_segments; +use crate::render::text::{decoration_segments, list_marker, list_markers}; use crate::{ math::{Bounds, Matrix, Rect}, render::{default_font, DEFAULT_EMOJI_FONT}, @@ -309,6 +309,34 @@ impl TextContentLayout { } } +// List bullet geometry, in font sizes, as Chrome draws a CSS disc marker +// (measured with Source Sans Pro). +pub const LIST_MARKER_RADIUS_FACTOR: f32 = 0.16; +pub const LIST_MARKER_MIN_RADIUS: f32 = 1.5; +/// Space between the bullet and the text box's start edge. +pub const LIST_MARKER_GAP_FACTOR: f32 = 0.33; +/// How far above the baseline the bullet's center sits. +pub const LIST_MARKER_CENTER_FACTOR: f32 = 0.32; + +/// A list bullet, painted with the style of the first run on its line. +#[derive(Debug, Clone)] +pub struct ListMarker { + pub paint: skia::Paint, + pub center: Point, + pub radius: f32, +} + +impl ListMarker { + pub fn rect(&self) -> Rect { + Rect::new( + self.center.x - self.radius, + self.center.y - self.radius, + self.center.x + self.radius, + self.center.y + self.radius, + ) + } +} + #[derive(Debug, Clone)] pub struct TextDecorationSegment { #[allow(dead_code)] @@ -626,11 +654,40 @@ impl TextContent { } } - if has_lines { - Some(Rect::from_ltrb(min_x, min_y, max_x, max_y)) - } else { - None + if !has_lines { + return None; } + + let mut rect = Rect::from_ltrb(min_x, min_y, max_x, max_y); + if let Some(markers) = self.list_markers_rect(selrect, valign) { + rect.join(markers); + } + Some(rect) + } + + /// Union of the list bullets, which hang outside the text box. + fn list_markers_rect(&self, selrect: &Rect, valign: VerticalAlign) -> Option { + let paragraphs = &self.layout.paragraphs; + let total_height: f32 = paragraphs + .iter() + .filter_map(|group| group.first()) + .map(|p| p.height()) + .sum(); + let top = selrect.y() + vertical_align_offset(selrect.height(), total_height, valign); + + list_markers( + paragraphs.iter().map(|group| group.first()), + self.paragraphs(), + selrect.x(), + top, + self.get_width(selrect.width()), + ) + .iter() + .map(ListMarker::rect) + .reduce(|mut rect, marker_rect| { + rect.join(marker_rect); + rect + }) } fn compute_and_cache_extrect( @@ -643,7 +700,11 @@ impl TextContent { // (line.left) reflect alignment within that huge width and are // unusable for tight bounds. Fall back to content_rect. if self.grow_type() == GrowType::AutoWidth { - return self.content_rect(selrect, valign); + let mut rect = self.content_rect(selrect, valign); + if let Some(markers) = self.list_markers_rect(selrect, valign) { + rect.join(markers); + } + return rect; } let layout_matches_container = self @@ -1876,6 +1937,7 @@ pub struct ParagraphLayout { pub x: f32, pub y: f32, pub decorations: Vec, + pub marker: Option, } #[derive(Debug)] @@ -1939,20 +2001,39 @@ pub fn calculate_text_layout_data( vertical_align_offset(selrect_height, total_text_height, shape.vertical_align()); let mut paragraph_layouts: Vec = Vec::new(); let mut y_accum = base_y + vertical_offset; + // Last list marker of each builder, so an empty item borrows its pass' paint. + let mut previous_markers: Vec> = Vec::new(); 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() { + for (builder_index, skia_paragraph) in group_paragraphs.into_iter().enumerate() { + if previous_markers.len() <= builder_index { + previous_markers.resize(builder_index + 1, None); + } let decorations = text_paragraphs .get(i) .map(|text_paragraph| { decoration_segments(&skia_paragraph, text_paragraph, x, y_accum) }) .unwrap_or_default(); + let marker = text_paragraphs.get(i).and_then(|text_paragraph| { + list_marker( + &skia_paragraph, + text_paragraph, + x, + y_accum, + text_width, + previous_markers[builder_index].as_ref(), + ) + }); + if marker.is_some() { + previous_markers[builder_index] = marker.clone(); + } paragraph_layouts.push(ParagraphLayout { paragraph: skia_paragraph, x, y: y_accum, decorations, + marker, }); } y_accum += paragraph_heights[i]; @@ -2423,6 +2504,119 @@ mod tests { shape } + /// A laid-out 200x100 text at the origin with one "hello" paragraph. + fn list_text_shape( + grow_type: GrowType, + direction: TextDirection, + list_style_type: ListStyleType, + ) -> Shape { + let mut paragraph = test_paragraph(&["hello"]).with_list_style_type(list_style_type); + paragraph.text_direction = direction; + let mut content = TextContent::new(Rect::from_xywh(0.0, 0.0, 200.0, 100.0), grow_type); + content.add_paragraph(paragraph); + + let mut shape = Shape::new(Uuid::nil()); + shape.set_shape_type(shapes::Type::Text(content)); + shape.set_selrect(0.0, 0.0, 200.0, 100.0); + shape + } + + #[test] + fn extrect_holds_the_painted_marker_when_vertically_aligned() { + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + let _guard = TestRenderResourcesGuard::install(&mut resources); + for valign in [VerticalAlign::Center, VerticalAlign::Bottom] { + let mut shape = + list_text_shape(GrowType::Fixed, TextDirection::LTR, ListStyleType::Disc); + shape.set_vertical_align(valign); + + let content = shape.get_text_content().clone(); + let mut builders = content.paragraph_builder_group_from_text(None); + let layout = calculate_text_layout_data(&shape, &content, &mut builders, true); + let marker = layout.paragraphs[0] + .marker + .as_ref() + .expect("disc paragraph has a marker") + .rect(); + + let extrect = text_extrect(&shape); + assert!( + extrect.left() <= marker.left() + && extrect.top() <= marker.top() + && extrect.right() >= marker.right() + && extrect.bottom() >= marker.bottom(), + "{valign:?}: {marker:?} outside {extrect:?}" + ); + // With top alignment the marker would sit near y = 0. + assert!(marker.top() > 10.0, "{valign:?}: {marker:?}"); + } + } + + fn text_extrect(shape: &Shape) -> Rect { + shape + .get_text_content() + .calculate_bounds(shape, false) + .to_rect() + } + + // Font size 14: a gap of 4.62 and a radius of 2.24. + const MARKER_REACH: f32 = 4.62 + 2.0 * 2.24; + + #[test] + fn extrect_includes_a_list_marker_before_the_text() { + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + let _guard = TestRenderResourcesGuard::install(&mut resources); + for grow_type in [GrowType::Fixed, GrowType::AutoHeight, GrowType::AutoWidth] { + let shape = list_text_shape(grow_type, TextDirection::LTR, ListStyleType::Disc); + let extrect = text_extrect(&shape); + assert!( + extrect.left() <= -MARKER_REACH + 0.01, + "{grow_type:?}: {extrect:?}" + ); + assert_eq!(shape.selrect().left(), 0.0); + } + } + + #[test] + fn extrect_includes_an_rtl_list_marker_after_the_text() { + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + let _guard = TestRenderResourcesGuard::install(&mut resources); + let shape = list_text_shape(GrowType::Fixed, TextDirection::RTL, ListStyleType::Disc); + let extrect = text_extrect(&shape); + assert!( + extrect.right() >= 200.0 + MARKER_REACH - 0.01, + "{extrect:?}" + ); + } + + #[test] + fn extrect_of_plain_text_stays_inside_the_text_box() { + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + let _guard = TestRenderResourcesGuard::install(&mut resources); + let shape = list_text_shape(GrowType::Fixed, TextDirection::LTR, ListStyleType::None); + let extrect = text_extrect(&shape); + assert!(extrect.left() >= 0.0, "{extrect:?}"); + } + + #[test] + fn extrect_grows_when_bullets_are_turned_on() { + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + let _guard = TestRenderResourcesGuard::install(&mut resources); + let mut shape = list_text_shape(GrowType::Fixed, TextDirection::LTR, ListStyleType::None); + assert!(text_extrect(&shape).left() >= 0.0); + + let selrect = shape.selrect; + if let shapes::Type::Text(content) = &mut shape.shape_type { + let paragraph = content.paragraphs_mut()[0] + .clone() + .with_list_style_type(ListStyleType::Disc); + content.paragraphs_mut()[0] = paragraph; + content.update_layout(selrect); + } + + assert!(text_extrect(&shape).left() <= -MARKER_REACH + 0.01); + } + /// Whether resizing `shape` to `width`x`height`, with no content change, /// rebuilt its cached Skia paragraphs. fn resize_rebuilds_paragraphs(shape: &mut Shape, width: f32, height: f32) -> bool { diff --git a/render-wasm/src/shapes/text_paths.rs b/render-wasm/src/shapes/text_paths.rs index 44cc21a115..344906971a 100644 --- a/render-wasm/src/shapes/text_paths.rs +++ b/render-wasm/src/shapes/text_paths.rs @@ -1,4 +1,4 @@ -use crate::render::text::decoration_segments; +use crate::render::text::{decoration_segments, list_markers}; use crate::shapes::text::{vertical_align_offset, Paragraph, TextContent}; use crate::shapes::VerticalAlign; use skia_safe::{ @@ -29,10 +29,25 @@ impl TextPaths { .collect(); let total_height: f32 = paragraphs.iter().map(|p| p.height()).sum(); - let mut offset_y = self.bounds.y() + let top = self.bounds.y() + vertical_align_offset(self.bounds.height(), total_height, vertical_align); - let mut paths = Vec::new(); + let mut paths: Vec = list_markers( + paragraphs.iter().map(Some), + self.0.paragraphs(), + self.bounds.x(), + top, + layout_width, + ) + .into_iter() + .map(|marker| { + let mut builder = skia::PathBuilder::new(); + builder.add_circle(marker.center, marker.radius, None); + builder.detach() + }) + .collect(); + + let mut offset_y = top; for (paragraph, text_paragraph) in paragraphs.iter_mut().zip(self.0.paragraphs()) { let origin = Point::new(self.bounds.x(), offset_y); Self::collect_paragraph_paths(paragraph, text_paragraph, origin, &mut paths); @@ -86,3 +101,107 @@ impl Deref for TextPaths { &self.0 } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::globals::TestRenderResourcesGuard; + use crate::math::Rect; + use crate::render::RenderResources; + use crate::shapes::{ + FontFamily, FontStyle, GrowType, ListStyleType, TextAlign, TextDirection, TextSpan, + }; + use crate::uuid::Uuid; + + /// Paths of a 200x100 text at the origin, one 14px paragraph per entry. + fn paths_of_paragraphs( + paragraphs: Vec<(&str, TextDirection, ListStyleType)>, + ) -> Vec { + let mut content = + TextContent::new(Rect::from_xywh(0.0, 0.0, 200.0, 100.0), GrowType::Fixed); + for (text, direction, list_style_type) in paragraphs { + let span = TextSpan::new( + text.to_string(), + FontFamily::new(Uuid::nil(), 400, FontStyle::Normal), + 14.0, + 1.2, + 0.0, + None, + None, + direction, + 400, + Uuid::nil(), + vec![], + ); + content.add_paragraph( + Paragraph::new(TextAlign::Left, direction, None, None, 1.2, 0.0, vec![span]) + .with_list_style_type(list_style_type), + ); + } + TextPaths::new(content).get_paths(VerticalAlign::Top) + } + + fn paths_of(list_style_type: ListStyleType) -> Vec { + paths_of_paragraphs(vec![("hello", TextDirection::LTR, list_style_type)]) + } + + /// Bounds of the paths that lie wholly outside the 200px-wide text box. + fn bullet_bounds(paths: Vec) -> Vec { + paths + .into_iter() + .map(|path| *path.bounds()) + .filter(|bounds| bounds.right() < 0.0 || bounds.left() > 200.0) + .collect() + } + + #[test] + fn list_paragraph_paths_include_the_bullet() { + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + let _guard = TestRenderResourcesGuard::install(&mut resources); + // Font size 14: the bullet is centered at x = -6.86 with radius 2.24. + let bullet = paths_of(ListStyleType::Disc) + .into_iter() + .map(|path| *path.bounds()) + .find(|bounds| bounds.right() < 0.0) + .expect("a path before the text box"); + assert!((bullet.center_x() + 6.86).abs() < 0.01, "{bullet:?}"); + assert!((bullet.width() - 4.48).abs() < 0.01, "{bullet:?}"); + } + + #[test] + fn empty_list_item_paths_include_its_bullet() { + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + let _guard = TestRenderResourcesGuard::install(&mut resources); + let bullets = bullet_bounds(paths_of_paragraphs(vec![ + ("hello", TextDirection::LTR, ListStyleType::Disc), + ("", TextDirection::LTR, ListStyleType::Disc), + ])); + assert_eq!(bullets.len(), 2, "{bullets:?}"); + } + + #[test] + fn rtl_list_paragraph_paths_put_the_bullet_after_the_text_box() { + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + let _guard = TestRenderResourcesGuard::install(&mut resources); + let bullets = bullet_bounds(paths_of_paragraphs(vec![( + "hello", + TextDirection::RTL, + ListStyleType::Disc, + )])); + assert_eq!(bullets.len(), 1, "{bullets:?}"); + // Font size 14: radius 2.24 plus a 4.62 gap past the right edge. + assert!( + (bullets[0].center_x() - (200.0 + 6.86)).abs() < 0.01, + "{bullets:?}" + ); + } + + #[test] + fn plain_paragraph_paths_have_no_bullet() { + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + let _guard = TestRenderResourcesGuard::install(&mut resources); + assert!(paths_of(ListStyleType::None) + .iter() + .all(|path| path.bounds().left() >= 0.0)); + } +}