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
+---
+
+
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