mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 22:10:34 +00:00
✨ Draw bullets for list paragraphs
A paragraph with list-style-type "disc" now gets a solid round bullet, sized as Chrome draws a CSS disc marker (measured with Source Sans Pro): a radius of 0.16 font sizes, centered 0.32 font sizes above the first baseline, with a gap of 0.33 font sizes before the text. The bullet hangs outside the text box, before its start edge (after it for RTL), like CSS list-style-position: outside, so the text layout, caret and selection stay as they were. The bullet takes the paint of its line's first style run, so fills, strokes, shadows and masks apply to it as they do to the glyphs. An empty item, which has no run, borrows the previous item's paint. The cached paint, the text extrect and text-to-path share one walker over the paragraphs, so they always agree: the extrect grows to hold the bullets, like shadows and blur, and flattening a text keeps them. Refs #12630 AI-assisted-by: claude-opus-5-5
This commit is contained in:
parent
cc9cf07ac0
commit
0140a31c37
@ -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,
|
||||
|
||||
@ -0,0 +1,24 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="281.5" height="122" viewBox="0 0 281.5 122"><defs><style type="text/css"><![CDATA[@font-face{font-family:"Source Sans Pro";font-style:normal;font-weight:400;src:url("fonts/sourcesanspro-regular.ttf") format("truetype");}]]></style></defs>
|
||||
<text fill="red" transform="translate(-19.5 1)" font-size="30" font-family="Source Sans Pro" x="40, 57.636719, 73.955078, 81.601562, 89.248047, 103.69141" y="27">
|
||||
Bullet
|
||||
</text>
|
||||
<ellipse fill="red" transform="translate(-19.5 1)" cx="25.299999" cy="17.540627" rx="4.7999992" ry="4.7999992"/>
|
||||
<text fill="red" transform="translate(-19.5 1)" font-size="30" font-family="Source Sans Pro" x="40, 57.270508, 64.916992, 80.268555, 87.636719" y="63">
|
||||
Plain
|
||||
</text>
|
||||
<defs/>
|
||||
<text fill="none" stroke="blue" stroke-width="2" stroke-miterlimit="4" transform="translate(-19.5 1)" font-size="30" font-family="Source Sans Pro" x="40, 57.636719, 73.955078, 81.601562, 89.248047, 103.69141" y="27">
|
||||
Bullet
|
||||
</text>
|
||||
<defs/>
|
||||
<ellipse fill="none" stroke="blue" stroke-width="2" stroke-miterlimit="4" transform="translate(-19.5 1)" cx="25.299999" cy="17.540627" rx="4.7999992" ry="4.7999992"/>
|
||||
<defs/>
|
||||
<text fill="none" stroke="blue" stroke-width="2" stroke-miterlimit="4" transform="translate(-19.5 1)" font-size="30" font-family="Source Sans Pro" x="40, 57.270508, 64.916992, 80.268555, 87.636719" y="63">
|
||||
Plain
|
||||
</text>
|
||||
</svg>
|
||||
@ -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("<ellipse").count(), 2, "{svg}");
|
||||
assert!(svg.contains("<ellipse fill=\"red\""), "{svg}");
|
||||
assert!(svg.contains("stroke=\"blue\""), "{svg}");
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_rect_with_solid_inner_stroke() {
|
||||
let mut pool = ShapesPool::new();
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
use super::{filters, RenderState, Shape, SurfaceId, DEFAULT_EMOJI_FONT};
|
||||
use crate::{
|
||||
error::Result,
|
||||
math::Rect,
|
||||
math::{Point, Rect},
|
||||
shapes::{
|
||||
add_text_with_tabs, calculate_text_layout_data, set_paint_fill, vertical_align_offset,
|
||||
Paragraph as TextParagraph, ParagraphBuilderGroup, ParagraphLayout, Stroke, StrokeKind,
|
||||
TextContent, TextDecorationSegment,
|
||||
ListMarker, ListStyleType, Paragraph as TextParagraph, ParagraphBuilderGroup,
|
||||
ParagraphLayout, Stroke, StrokeKind, TextContent, TextDecorationSegment, TextDirection,
|
||||
LIST_MARKER_CENTER_FACTOR, LIST_MARKER_GAP_FACTOR, LIST_MARKER_MIN_RADIUS,
|
||||
LIST_MARKER_RADIUS_FACTOR,
|
||||
},
|
||||
utils::{get_fallback_fonts, get_font_collection},
|
||||
};
|
||||
@ -371,6 +373,7 @@ fn paint_from_cached_layout(canvas: &Canvas, shape: &Shape, text_content: &TextC
|
||||
|
||||
let x = anchor.x;
|
||||
let base_y = anchor.y;
|
||||
let layout_width = text_content.get_width(selrect.width());
|
||||
let paragraphs = &text_content.layout.paragraphs;
|
||||
let draw_decorations = text_content.has_text_decorations();
|
||||
|
||||
@ -382,7 +385,8 @@ fn paint_from_cached_layout(canvas: &Canvas, shape: &Shape, text_content: &TextC
|
||||
let vertical_offset =
|
||||
vertical_align_offset(selrect.height(), total_text_height, shape.vertical_align());
|
||||
|
||||
let mut y_accum = base_y + vertical_offset;
|
||||
let top = base_y + vertical_offset;
|
||||
let mut y_accum = top;
|
||||
for (index, group) in paragraphs.iter().enumerate() {
|
||||
let Some(paragraph) = group.first() else {
|
||||
continue;
|
||||
@ -398,6 +402,16 @@ fn paint_from_cached_layout(canvas: &Canvas, shape: &Shape, text_content: &TextC
|
||||
y_accum += paragraph.height();
|
||||
}
|
||||
|
||||
for marker in list_markers(
|
||||
paragraphs.iter().map(|group| group.first()),
|
||||
text_content.paragraphs(),
|
||||
x,
|
||||
top,
|
||||
layout_width,
|
||||
) {
|
||||
draw_list_marker(canvas, &marker);
|
||||
}
|
||||
|
||||
if needs_translate {
|
||||
canvas.restore();
|
||||
}
|
||||
@ -533,6 +547,10 @@ fn paint_text_with_emoji_overlay(
|
||||
paint_emoji_overlay(canvas, para);
|
||||
}
|
||||
|
||||
if let Some(marker) = ¶.marker {
|
||||
draw_list_marker(canvas, marker);
|
||||
}
|
||||
|
||||
for deco in ¶.decorations {
|
||||
draw_decoration_segment(canvas, deco);
|
||||
}
|
||||
@ -1069,6 +1087,90 @@ pub fn render_outer_stroke(
|
||||
)
|
||||
}
|
||||
|
||||
fn draw_list_marker(canvas: &Canvas, marker: &ListMarker) {
|
||||
canvas.draw_circle(marker.center, marker.radius, &marker.paint);
|
||||
}
|
||||
|
||||
/// Bullets of paragraphs laid out one under another, from `top` down. `None`
|
||||
/// stands for a paragraph with no laid-out Skia paragraph; it keeps the
|
||||
/// paragraphs in step with `text_paragraphs`.
|
||||
pub fn list_markers<'a>(
|
||||
skia_paragraphs: impl IntoIterator<Item = Option<&'a skia::textlayout::Paragraph>>,
|
||||
text_paragraphs: &[TextParagraph],
|
||||
x: f32,
|
||||
top: f32,
|
||||
layout_width: f32,
|
||||
) -> Vec<ListMarker> {
|
||||
let mut markers: Vec<ListMarker> = 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<ListMarker> {
|
||||
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<Fill>) -> 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<TextParagraph>, width: f32) -> Vec<ParagraphLayout> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<Rect> {
|
||||
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<TextDecorationSegment>,
|
||||
pub marker: Option<ListMarker>,
|
||||
}
|
||||
|
||||
#[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<ParagraphLayout> = 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<Option<ListMarker>> = 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 {
|
||||
|
||||
@ -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<skia::Path> = 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<skia::Path> {
|
||||
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<skia::Path> {
|
||||
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<skia::Path>) -> Vec<Rect> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user