alonso.torres aa2dbacd23 🐛 Paint CJK underlines with the color of their own span
Skia keys a line's style runs by UTF-8 byte offset, while line indices
are UTF-16. Decoration segments searched those runs with UTF-16 offsets,
so with CJK text every underline or strike on a line took the paint of
the first run, and a decorated span wrapped onto a new line lost its bar.

Look up each segment's run by the span's UTF-8 style anchor, falling
back to the line's first run for a span continued from an earlier line.

AI-assisted-by: claude-opus-5-5
2026-11-05 17:49:00 +01:00

1691 lines
54 KiB
Rust

use super::{filters, RenderState, Shape, SurfaceId, DEFAULT_EMOJI_FONT};
use crate::shapes::text_paragraph::{HorizontalBuilder, LayoutParagraph};
use crate::{
error::Result,
math::Rect,
shapes::{
add_horizontal_span, calculate_text_layout_data, set_paint_fill, text_vertical,
vertical_align_offset, HorizontalOffsets, HorizontalSpanRange, Paragraph as TextParagraph,
ParagraphBuilderGroup, ParagraphLayout, Stroke, StrokeKind, TextContent,
TextDecorationSegment,
},
utils::{get_fallback_fonts, get_font_collection},
};
use skia_safe::{
self as skia,
canvas::SaveLayerRec,
textlayout::{RectHeightStyle, RectWidthStyle, StyleMetrics, TextDecoration},
Canvas, ImageFilter, Paint,
};
use std::borrow::Cow;
use text_vertical::GlyphOutput;
/// Vertical text: shadows, fill, strokes and the debug grid, all painted from
/// one layout. Rebinds the content to the selrect: stored text bounds
/// describe the measured content and can be taller than a fixed shape, whose
/// height is the column-wrap budget.
pub fn render_vertical_text(
state: &mut RenderState,
shape: &Shape,
text_content: &TextContent,
fills_surface_id: SurfaceId,
strokes_surface_id: SurfaceId,
skip_effects: bool,
) -> Result<()> {
let blur_filter = (!skip_effects).then(|| shape.image_filter(1.0)).flatten();
let bounds = shape.selrect();
let vertical_align = shape.vertical_align();
let layout = text_content.vertical_layout(&bounds);
let skip_shadows = skip_effects || state.should_skip_drop_shadows();
if !skip_shadows {
let mut drop_shadows = shape.drop_shadow_paints();
if let Some(inherited_shadows) = state.get_inherited_drop_shadows() {
drop_shadows.extend(inherited_shadows);
}
if !drop_shadows.is_empty() {
let canvas = state.surfaces.canvas_and_mark_dirty(fills_surface_id);
for shadow in &drop_shadows {
text_vertical::paint_drop_shadow(
canvas,
&layout,
&bounds,
vertical_align,
shadow,
GlyphOutput::Text,
);
}
}
}
render_to_surface(
state,
shape,
fills_surface_id,
blur_filter.as_ref(),
0.0,
|canvas| {
paint_vertical_fill(
canvas,
shape,
&layout,
&bounds,
blur_filter.as_ref(),
GlyphOutput::Text,
)
},
)?;
let strokes: Vec<&Stroke> = shape.visible_strokes().rev().collect();
if !strokes.is_empty() {
let selrect = shape.selrect();
let canvas = state.surfaces.canvas_and_mark_dirty(strokes_surface_id);
for stroke in strokes {
text_vertical::paint_stroke(
canvas,
&layout,
&bounds,
vertical_align,
stroke,
&selrect,
blur_filter.as_ref(),
GlyphOutput::Text,
);
}
}
if state.options.is_text_grid_visible() {
let canvas = state.surfaces.canvas_and_mark_dirty(fills_surface_id);
text_vertical::paint_grid(canvas, &layout, &bounds, vertical_align);
}
Ok(())
}
/// Paint a viewport-only debug grid over SkParagraph horizontal text: blue
/// line boxes, green per-scalar tight rects from SkParagraph, amber
/// baselines. Matches the vertical text grid and shows horizontal annotation
/// anchors.
pub fn paint_horizontal_grid(canvas: &Canvas, shape: &Shape, text_content: &TextContent) {
let mut builders = text_content.paragraph_builder_group_from_text(None);
let layout = calculate_text_layout_data(shape, text_content, &mut builders, true);
let mut line_paint = Paint::default();
line_paint.set_anti_alias(true);
line_paint.set_style(skia::PaintStyle::Stroke);
line_paint.set_stroke_width(1.0);
line_paint.set_color(skia::Color::from_argb(0xAA, 0x2F, 0x80, 0xED));
let mut glyph_paint = Paint::default();
glyph_paint.set_anti_alias(true);
glyph_paint.set_style(skia::PaintStyle::Stroke);
glyph_paint.set_stroke_width(1.0);
glyph_paint.set_color(skia::Color::from_argb(0x99, 0x27, 0xAE, 0x60));
let mut baseline_paint = Paint::default();
baseline_paint.set_anti_alias(true);
baseline_paint.set_stroke_width(1.0);
baseline_paint.set_color(skia::Color::from_argb(0xCC, 0xEB, 0x57, 0x57));
for paragraph in &layout.paragraphs {
for line in paragraph.paragraph.get_line_metrics() {
let baseline = line.baseline as f32;
let top = paragraph.y + baseline - line.ascent as f32;
let left = paragraph.x + line.left as f32;
let width = line.width as f32;
canvas.draw_rect(
Rect::from_xywh(left, top, width, line.height as f32),
&line_paint,
);
canvas.draw_line(
(left, paragraph.y + baseline),
(left + width, paragraph.y + baseline),
&baseline_paint,
);
for offset in line.start_index..line.end_index {
for textbox in paragraph.paragraph.get_rects_for_range(
offset..offset + 1,
RectHeightStyle::Tight,
RectWidthStyle::Tight,
) {
let mut rect = textbox.rect;
rect.offset((paragraph.x, paragraph.y));
canvas.draw_rect(rect, &glyph_paint);
}
}
}
}
}
pub fn stroke_paragraph_builder_group_from_text(
text_content: &TextContent,
stroke: &Stroke,
bounds: &Rect,
use_shadow: Option<bool>,
) -> (Vec<ParagraphBuilderGroup>, Option<f32>) {
let fallback_fonts = get_fallback_fonts();
let fonts = get_font_collection();
let mut paragraph_group = Vec::new();
let remove_stroke_alpha = use_shadow.unwrap_or(false) && !stroke.is_transparent();
let mut group_layer_opacity: Option<f32> = None;
let plans = text_content.horizontal_plans();
let adjustments = text_content.horizontal_line_adjustments();
for (index, (paragraph, plan)) in text_content
.paragraphs()
.iter()
.zip(plans.iter())
.enumerate()
{
let adjustment = adjustments
.as_ref()
.and_then(|adjustments| adjustments[index].as_ref());
let mut stroke_paragraphs_map: std::collections::HashMap<usize, HorizontalBuilder> =
std::collections::HashMap::new();
for (span_index, (((span, text), sheds), extra)) in paragraph
.children()
.iter()
.zip(&plan.texts)
.zip(&plan.sheds)
.zip(&plan.ruby_spacing.adjustments)
.enumerate()
{
let sheds: Cow<'_, [(usize, f32)]> = adjustment
.map_or(Cow::Borrowed(sheds.as_slice()), |a| {
Cow::Owned(sheds.iter().chain(&a.sheds[span_index]).copied().collect())
});
let extra: Cow<'_, [(usize, f32)]> =
adjustment.map_or(Cow::Borrowed(extra.as_slice()), |a| {
Cow::Owned(
extra
.iter()
.chain(&a.spacing[span_index])
.copied()
.collect(),
)
});
let (stroke_paints, stroke_layer_opacity) =
get_text_stroke_paints(stroke, bounds, remove_stroke_alpha);
if group_layer_opacity.is_none() {
group_layer_opacity = stroke_layer_opacity;
}
for (paint_idx, stroke_paint) in stroke_paints.iter().enumerate() {
let builder = stroke_paragraphs_map.entry(paint_idx).or_insert_with(|| {
let mut paragraph_style = paragraph.paragraph_to_style();
if plan.has_atomic_overflow() || adjustment.is_some() {
paragraph_style.set_apply_rounding_hack(false);
if matches!(
paragraph.text_align(),
crate::shapes::TextAlign::Justify
| crate::shapes::TextAlign::Distribute
) {
paragraph_style.set_text_align(skia::textlayout::TextAlign::Left);
}
}
HorizontalBuilder::new(&paragraph_style, fonts)
});
let stroke_paint = stroke_paint.clone();
let remove_alpha = use_shadow.unwrap_or(false) && !span.is_transparent();
let stroke_style = span.to_stroke_style(
&stroke_paint,
fallback_fonts,
remove_alpha,
paragraph.line_height(),
);
builder.push_style(&stroke_style);
add_horizontal_span(builder, span, text, &sheds, &extra, &stroke_style, fonts);
}
}
let stroke_paragraphs: ParagraphBuilderGroup = (0..stroke_paragraphs_map.len())
.filter_map(|i| stroke_paragraphs_map.remove(&i))
.collect();
paragraph_group.push(stroke_paragraphs);
}
(paragraph_group, group_layer_opacity)
}
pub(crate) fn get_text_stroke_paints(
stroke: &Stroke,
bounds: &Rect,
remove_stroke_alpha: bool,
) -> (Vec<Paint>, Option<f32>) {
let mut paints = Vec::new();
let mut layer_opacity: Option<f32> = None;
let stroke_opacity = stroke.fill.opacity();
let needs_opacity_layer = stroke_opacity < 1.0 && !remove_stroke_alpha;
let fill_for_paint = |paint: &mut Paint| {
if needs_opacity_layer {
let opaque_fill = stroke.fill.with_full_opacity();
set_paint_fill(paint, &opaque_fill, bounds, remove_stroke_alpha);
} else {
set_paint_fill(paint, &stroke.fill, bounds, remove_stroke_alpha);
}
};
if needs_opacity_layer {
layer_opacity = Some(stroke_opacity);
}
match stroke.kind {
StrokeKind::Inner => {
let mut paint = skia::Paint::default();
paint.set_style(skia::PaintStyle::Stroke);
paint.set_anti_alias(true);
paint.set_stroke_width(stroke.width * 2.0);
if remove_stroke_alpha {
paint.set_color(skia::Color::BLACK);
paint.set_alpha(255);
} else {
fill_for_paint(&mut paint);
}
paints.push(paint);
}
StrokeKind::Center => {
let mut paint = skia::Paint::default();
paint.set_style(skia::PaintStyle::Stroke);
paint.set_anti_alias(true);
paint.set_stroke_width(stroke.width);
fill_for_paint(&mut paint);
paints.push(paint);
}
StrokeKind::Outer => {
let mut paint = skia::Paint::default();
paint.set_style(skia::PaintStyle::Stroke);
paint.set_anti_alias(true);
paint.set_stroke_width(stroke.width * 2.0);
if remove_stroke_alpha {
paint.set_color(skia::Color::BLACK);
paint.set_alpha(255);
} else {
fill_for_paint(&mut paint);
}
paints.push(paint);
}
}
(paints, layer_opacity)
}
#[allow(clippy::too_many_arguments)]
pub fn render_with_bounds_outset(
render_state: Option<&mut RenderState>,
canvas: Option<&Canvas>,
shape: &Shape,
paragraph_builders: &mut [ParagraphBuilderGroup],
surface_id: Option<SurfaceId>,
shadow: Option<&Paint>,
blur: Option<&ImageFilter>,
stroke_bounds_outset: f32,
fill_inset: Option<f32>,
layer_opacity: Option<f32>,
) -> Result<()> {
render_with_bounds_outset_inner(
render_state,
canvas,
shape,
paragraph_builders,
surface_id,
shadow,
blur,
stroke_bounds_outset,
fill_inset,
layer_opacity,
false,
)
}
/// Like [`render_with_bounds_outset`] but with emoji bitmap overlay for PDF/vector export.
#[allow(clippy::too_many_arguments)]
pub fn render_with_bounds_outset_overlay_emoji(
canvas: &Canvas,
shape: &Shape,
paragraph_builders: &mut [ParagraphBuilderGroup],
shadow: Option<&Paint>,
blur: Option<&ImageFilter>,
stroke_bounds_outset: f32,
fill_inset: Option<f32>,
layer_opacity: Option<f32>,
) -> Result<()> {
render_with_bounds_outset_inner(
None,
Some(canvas),
shape,
paragraph_builders,
None,
shadow,
blur,
stroke_bounds_outset,
fill_inset,
layer_opacity,
true,
)
}
/// Run `paint` on `target_surface`. With a blur, it paints into a filter
/// surface sized to the blurred text bounds (grown by `stroke_bounds_outset`)
/// when those bounds are usable.
fn render_to_surface(
render_state: &mut RenderState,
shape: &Shape,
target_surface: SurfaceId,
blur: Option<&ImageFilter>,
stroke_bounds_outset: f32,
mut paint: impl FnMut(&Canvas),
) -> Result<()> {
if let Some(blur_filter) = blur {
let mut text_bounds = shape
.get_text_content()
.calculate_bounds(shape, false)
.to_rect();
if stroke_bounds_outset > 0.0 {
text_bounds.inset((-stroke_bounds_outset, -stroke_bounds_outset));
}
let bounds = blur_filter.compute_fast_bounds(text_bounds);
if bounds.is_finite()
&& bounds.width() > 0.0
&& bounds.height() > 0.0
&& filters::render_with_filter_surface(
render_state,
bounds,
target_surface,
|state, temp_surface| {
paint(state.surfaces.canvas(temp_surface));
Ok(())
},
)?
{
return Ok(());
}
}
paint(render_state.surfaces.canvas_and_mark_dirty(target_surface));
Ok(())
}
/// Fill pass of vertical text: `layout` painted inside the shape's layer
/// blur.
pub fn paint_vertical_fill(
canvas: &Canvas,
shape: &Shape,
layout: &text_vertical::VerticalLayout,
bounds: &Rect,
blur: Option<&ImageFilter>,
output: GlyphOutput,
) {
if let Some(blur_filter) = blur {
let mut blur_paint = Paint::default();
blur_paint.set_image_filter(blur_filter.clone());
canvas.save_layer(
&SaveLayerRec::default()
.bounds(&shape.layer_bounds())
.paint(&blur_paint),
);
}
text_vertical::paint_layout(canvas, layout, bounds, shape.vertical_align(), output);
if blur.is_some() {
canvas.restore();
}
}
#[allow(clippy::too_many_arguments)]
fn render_with_bounds_outset_inner(
render_state: Option<&mut RenderState>,
canvas: Option<&Canvas>,
shape: &Shape,
paragraph_builders: &mut [ParagraphBuilderGroup],
surface_id: Option<SurfaceId>,
shadow: Option<&Paint>,
blur: Option<&ImageFilter>,
stroke_bounds_outset: f32,
fill_inset: Option<f32>,
layer_opacity: Option<f32>,
overlay_emoji: bool,
) -> Result<()> {
if let Some(render_state) = render_state {
return render_to_surface(
render_state,
shape,
surface_id.unwrap_or(SurfaceId::Fills),
blur,
stroke_bounds_outset,
|canvas| {
render_text_on_canvas(
canvas,
shape,
paragraph_builders,
shadow,
blur,
fill_inset,
layer_opacity,
false,
)
},
);
}
if let Some(canvas) = canvas {
render_text_on_canvas(
canvas,
shape,
paragraph_builders,
shadow,
blur,
fill_inset,
layer_opacity,
overlay_emoji,
);
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub fn render(
render_state: Option<&mut RenderState>,
canvas: Option<&Canvas>,
shape: &Shape,
paragraph_builders: &mut [ParagraphBuilderGroup],
surface_id: Option<SurfaceId>,
shadow: Option<&Paint>,
blur: Option<&ImageFilter>,
fill_inset: Option<f32>,
layer_opacity: Option<f32>,
) -> Result<()> {
render_with_bounds_outset(
render_state,
canvas,
shape,
paragraph_builders,
surface_id,
shadow,
blur,
0.0,
fill_inset,
layer_opacity,
)
}
/// Like [`render`] but rasterizes color emoji as bitmap overlays for PDF/vector export.
#[allow(clippy::too_many_arguments)]
pub fn render_overlay_emoji(
canvas: &Canvas,
shape: &Shape,
paragraph_builders: &mut [ParagraphBuilderGroup],
shadow: Option<&Paint>,
blur: Option<&ImageFilter>,
fill_inset: Option<f32>,
layer_opacity: Option<f32>,
) -> Result<()> {
render_with_bounds_outset_overlay_emoji(
canvas,
shape,
paragraph_builders,
shadow,
blur,
0.0,
fill_inset,
layer_opacity,
)
}
/// Paint fill glyphs from `TextContent.layout` when the cache is valid.
///
/// Avoids rebuilding ParagraphBuilders and re-running Skia layout on every
/// paint. Only safe for the plain fill pass (no stroke/shadow-specific builders).
/// Returns `true` when painting was done from cache.
pub fn try_paint_from_layout_cache(
render_state: Option<&mut RenderState>,
canvas: Option<&Canvas>,
shape: &Shape,
surface_id: Option<SurfaceId>,
layout_cache_rotation_only: bool,
) -> Result<bool> {
let text_content = shape.get_text_content();
if !text_content.can_paint_from_layout_cache() {
return Ok(false);
}
let cache_usable = if layout_cache_rotation_only {
text_content.layout_cache_versions_match()
} else {
text_content.has_usable_paint_layout(shape)
};
if !cache_usable {
return Ok(false);
}
if let Some(render_state) = render_state {
let target_surface = surface_id.unwrap_or(SurfaceId::Fills);
let canvas = render_state.surfaces.canvas_and_mark_dirty(target_surface);
paint_from_cached_layout(canvas, shape, text_content);
return Ok(true);
}
if let Some(canvas) = canvas {
paint_from_cached_layout(canvas, shape, text_content);
return Ok(true);
}
Ok(false)
}
fn paint_from_cached_layout(canvas: &Canvas, shape: &Shape, text_content: &TextContent) {
let selrect = shape.selrect();
// Absolute image/gradient shaders were baked at `layout_paint_origin`. Paint
// glyphs in that space and translate the canvas so both track selrect moves
// without rebuilding Skia paragraphs.
let anchor = text_content.cached_layout_paint_anchor(&selrect);
let offset = text_content.cached_layout_paint_offset(&selrect);
let needs_translate = offset.x.abs() > f32::EPSILON || offset.y.abs() > f32::EPSILON;
if needs_translate {
canvas.save();
canvas.translate((offset.x, offset.y));
}
let x = anchor.x;
let base_y = anchor.y;
let paragraphs = &text_content.layout.paragraphs;
let draw_decorations = text_content.has_text_decorations();
let total_text_height: f32 = paragraphs
.iter()
.filter_map(|group| group.first())
.map(|p| p.height())
.sum();
let vertical_offset =
vertical_align_offset(selrect.height(), total_text_height, shape.vertical_align());
let plans = text_content.horizontal_plans();
let mut y_accum = base_y + vertical_offset;
for (index, group) in paragraphs.iter().enumerate() {
let Some(paragraph) = group.first() else {
continue;
};
let x = x + plans
.get(index)
.zip(text_content.paragraphs().get(index))
.map_or(0.0, |(plan, model)| plan.x_offset(model, selrect.width()));
paragraph.paint(canvas, (x, y_accum));
if let (Some(text_paragraph), Some(plan)) =
(text_content.paragraphs().get(index), plans.get(index))
{
crate::shapes::paint_horizontal_warichu(
canvas,
text_paragraph,
plan,
paragraph,
x,
y_accum,
);
crate::shapes::paint_horizontal_emphasis(
canvas,
text_paragraph,
plan,
paragraph,
x,
y_accum,
);
crate::shapes::paint_horizontal_ruby(
canvas,
text_content,
index,
paragraph,
x,
y_accum,
);
if draw_decorations {
for deco in
decoration_segments(paragraph, text_paragraph, &plan.offsets, x, y_accum)
{
draw_decoration_segment(canvas, &deco);
}
}
}
y_accum += paragraph.height();
}
if needs_translate {
canvas.restore();
}
}
#[allow(clippy::too_many_arguments)]
fn render_text_on_canvas(
canvas: &Canvas,
shape: &Shape,
paragraph_builders: &mut [ParagraphBuilderGroup],
shadow: Option<&Paint>,
blur: Option<&ImageFilter>,
fill_inset: Option<f32>,
layer_opacity: Option<f32>,
overlay_emoji: bool,
) {
let layer_bounds = shape.layer_bounds();
// Layer stack is managed here (blur / shadow / inset). `draw_text` is
// self-contained and only opens a layer when stroke-group opacity needs it.
if let Some(blur_filter) = blur {
let mut blur_paint = Paint::default();
blur_paint.set_image_filter(blur_filter.clone());
let blur_layer = SaveLayerRec::default()
.bounds(&layer_bounds)
.paint(&blur_paint);
canvas.save_layer(&blur_layer);
}
if let Some(shadow_paint) = shadow {
let layer_rec = SaveLayerRec::default()
.bounds(&layer_bounds)
.paint(shadow_paint);
canvas.save_layer(&layer_rec);
draw_text(
canvas,
shape,
paragraph_builders,
layer_opacity,
overlay_emoji,
);
canvas.restore();
} else if let Some(eps) = fill_inset.filter(|&e| e > 0.0) {
if let Some(erode) = skia_safe::image_filters::erode((eps, eps), None, None) {
let mut layer_paint = Paint::default();
layer_paint.set_image_filter(erode);
let layer_rec = SaveLayerRec::default()
.bounds(&layer_bounds)
.paint(&layer_paint);
canvas.save_layer(&layer_rec);
draw_text(
canvas,
shape,
paragraph_builders,
layer_opacity,
overlay_emoji,
);
canvas.restore();
} else {
draw_text(
canvas,
shape,
paragraph_builders,
layer_opacity,
overlay_emoji,
);
}
} else {
draw_text(
canvas,
shape,
paragraph_builders,
layer_opacity,
overlay_emoji,
);
}
if blur.is_some() {
canvas.restore();
}
}
/// Paints pre-built paragraph groups (SVG export path for selective fill layers).
pub fn paint_text_paragraphs(
canvas: &Canvas,
shape: &Shape,
paragraph_builder_groups: &mut [ParagraphBuilderGroup],
) {
paint_text_with_emoji_overlay(canvas, shape, paragraph_builder_groups, false);
}
/// Lays out and paints paragraph builders without any layer management.
fn paint_text(
canvas: &Canvas,
shape: &Shape,
paragraph_builder_groups: &mut [ParagraphBuilderGroup],
) {
paint_text_with_emoji_overlay(canvas, shape, paragraph_builder_groups, false);
}
/// Alpha mask for background blur coverage
pub fn paint_text_mask(canvas: &Canvas, shape: &Shape) {
let text_content = shape.get_text_content();
let mut mask_builders = text_content.paragraph_builder_group_opaque();
paint_text(canvas, shape, &mut mask_builders);
// take strokes into account for the bblur mask
for stroke in shape.visible_strokes() {
let (mut stroke_builders, _) = stroke_paragraph_builder_group_from_text(
text_content,
stroke,
&shape.selrect(),
Some(true),
);
paint_text(canvas, shape, &mut stroke_builders);
}
}
fn paint_text_with_emoji_overlay(
canvas: &Canvas,
shape: &Shape,
paragraph_builder_groups: &mut [ParagraphBuilderGroup],
overlay_emoji: bool,
) {
let text_content = shape.get_text_content();
// Vertical writing paints through the vertical pass. Stored text bounds
// describe the measured content and can be taller than a fixed shape, so
// the selrect is the column-wrap budget.
if crate::shapes::text_vertical::paint_text_vertical(
canvas,
text_content,
&shape.selrect(),
shape.vertical_align(),
crate::render::svg::vertical_glyph_output(),
) {
return;
}
let mut layout_info =
calculate_text_layout_data(shape, text_content, paragraph_builder_groups, true);
let plans = text_content.horizontal_plans();
for para in &mut layout_info.paragraphs {
para.paragraph.paint(canvas, (para.x, para.y));
if let (Some(source_paragraph), Some(plan)) = (
text_content.paragraphs().get(para.source_paragraph),
plans.get(para.source_paragraph),
) {
crate::shapes::paint_horizontal_warichu(
canvas,
source_paragraph,
plan,
&para.paragraph,
para.x,
para.y,
);
crate::shapes::paint_horizontal_emphasis(
canvas,
source_paragraph,
plan,
&para.paragraph,
para.x,
para.y,
);
}
if overlay_emoji {
paint_emoji_overlay(canvas, para);
}
// Like warichu and emphasis, ruby takes the paint of this pass.
crate::shapes::paint_horizontal_ruby(
canvas,
text_content,
para.source_paragraph,
&para.paragraph,
para.x,
para.y,
);
for deco in &para.decorations {
draw_decoration_segment(canvas, deco);
}
}
}
fn paragraph_has_emoji(paragraph: &mut LayoutParagraph) -> bool {
let line_bounds: Vec<(usize, usize)> = paragraph
.get_line_metrics()
.iter()
.map(|l| (l.start_index, l.end_index))
.collect();
line_bounds
.into_iter()
.any(|(start, end)| !line_emoji_ranges(paragraph, start, end).is_empty())
}
fn line_emoji_ranges(
paragraph: &mut LayoutParagraph,
line_start: usize,
line_end: usize,
) -> Vec<(usize, usize)> {
let mut merged: Vec<(usize, usize)> = Vec::new();
for idx in line_start..line_end {
let font = paragraph.get_font_at_utf16_offset(idx);
let normalized = font
.typeface()
.family_name()
.to_lowercase()
.replace(' ', "-");
if !normalized.contains(DEFAULT_EMOJI_FONT) {
continue;
}
match merged.last_mut() {
Some(last) if last.1 == idx => last.1 = idx + 1,
_ => merged.push((idx, idx + 1)),
}
}
merged
}
/// Rasterizes color emoji runs as bitmap overlays. Skia's PDF backend can't
/// embed COLR/CBDT color glyphs, so each emoji is drawn to a raster surface and
/// blitted; `paragraph.paint()` already wrote placeholder glyphs (keeps text
/// selectable).
fn paint_emoji_overlay(canvas: &Canvas, para: &mut ParagraphLayout) {
let line_metrics = para.paragraph.get_line_metrics();
// Rasterize at TARGET_DPI relative to the emoji's on-page size (72 user
// units = 1 inch), capped at MAX_RASTER_PX so a huge font can't allocate
// an unbounded surface.
const TARGET_DPI: f32 = 600.0;
const PDF_POINTS_PER_INCH: f32 = 72.0;
const MAX_RASTER_PX: f32 = 2048.0;
let ctm = canvas.local_to_device_as_3x3();
let sx = (ctm.scale_x().powi(2) + ctm.skew_y().powi(2)).sqrt();
let sy = (ctm.skew_x().powi(2) + ctm.scale_y().powi(2)).sqrt();
let output_scale = sx.max(sy).max(1.0);
let line_bounds: Vec<(usize, usize)> = line_metrics
.iter()
.map(|l| (l.start_index, l.end_index))
.collect();
drop(line_metrics);
for (line_start, line_end) in line_bounds {
for (range_start, range_end) in line_emoji_ranges(&mut para.paragraph, line_start, line_end)
{
// Get the bounding rects for this (possibly merged) emoji run
let rects = para.paragraph.get_rects_for_range(
range_start..range_end,
skia::textlayout::RectHeightStyle::Tight,
skia::textlayout::RectWidthStyle::Tight,
);
for text_box in &rects {
let r = &text_box.rect;
let w = r.width();
let h = r.height();
if w <= 0.0 || h <= 0.0 {
continue;
}
// Render at TARGET_DPI relative to the emoji's final on-page
// size, clamped so the surface stays within MAX_RASTER_PX.
let mut raster_scale = output_scale * (TARGET_DPI / PDF_POINTS_PER_INCH);
let max_dim = w.max(h) * raster_scale;
if max_dim > MAX_RASTER_PX {
raster_scale *= MAX_RASTER_PX / max_dim;
}
let raster_w = (w * raster_scale).ceil() as i32;
let raster_h = (h * raster_scale).ceil() as i32;
let info = skia::ImageInfo::new_n32_premul((raster_w, raster_h), None);
let Some(mut raster) = skia::surfaces::raster(&info, None, None) else {
continue;
};
let rc = raster.canvas();
rc.clear(skia::Color::TRANSPARENT);
rc.scale((raster_scale, raster_scale));
// Translate so the emoji rect origin maps to (0,0)
rc.translate((-r.left, -r.top));
para.paragraph.paint(rc, (0.0, 0.0));
let image = raster.image_snapshot();
// Draw the rasterized emoji onto the PDF canvas at the
// correct position (paragraph offset + emoji rect origin).
let dest = skia::Rect::from_xywh(para.x + r.left, para.y + r.top, w, h);
let sampling = skia::SamplingOptions::from(skia::CubicResampler::mitchell());
canvas.draw_image_rect_with_sampling_options(
&image,
None,
dest,
sampling,
&Paint::default(),
);
}
}
}
}
fn draw_decoration_stroke(
canvas: &Canvas,
kind: StrokeKind,
stroke_paint: &Paint,
bar: skia::Rect,
) {
if kind == StrokeKind::Center {
canvas.draw_rect(bar, stroke_paint);
return;
}
let blend = if kind == StrokeKind::Inner {
skia::BlendMode::SrcIn
} else {
skia::BlendMode::SrcOut
};
let outset = stroke_paint.stroke_width().max(0.0);
let layer_bounds = bar.with_outset((outset, outset));
canvas.save_layer(&SaveLayerRec::default().bounds(&layer_bounds));
let mut mask_paint = Paint::default();
mask_paint.set_color(skia::Color::BLACK);
mask_paint.set_anti_alias(true);
canvas.draw_rect(bar, &mask_paint);
let mut blend_paint = Paint::default();
blend_paint.set_blend_mode(blend);
canvas.save_layer(
&SaveLayerRec::default()
.bounds(&layer_bounds)
.paint(&blend_paint),
);
canvas.draw_rect(bar, stroke_paint);
canvas.restore();
canvas.restore();
}
fn paint_emoji_opaque(
canvas: &Canvas,
emoji_para: &mut ParagraphLayout,
deco_para: &ParagraphLayout,
stroke_decos: &[(StrokeKind, Paint)],
) {
let line_bounds: Vec<(usize, usize)> = emoji_para
.paragraph
.get_line_metrics()
.iter()
.map(|l| (l.start_index, l.end_index))
.collect();
let mut clip = skia::PathBuilder::new();
let mut has_emoji = false;
for (line_start, line_end) in line_bounds {
for (range_start, range_end) in
line_emoji_ranges(&mut emoji_para.paragraph, line_start, line_end)
{
let rects = emoji_para.paragraph.get_rects_for_range(
range_start..range_end,
skia::textlayout::RectHeightStyle::Tight,
skia::textlayout::RectWidthStyle::Tight,
);
for text_box in &rects {
let r = &text_box.rect;
if r.width() <= 0.0 || r.height() <= 0.0 {
continue;
}
clip.add_rect(
skia::Rect::from_xywh(
emoji_para.x + r.left,
emoji_para.y + r.top,
r.width(),
r.height(),
),
None,
None,
);
has_emoji = true;
}
}
}
if !has_emoji {
return;
}
canvas.save();
canvas.clip_path(&clip.detach(), skia::ClipOp::Intersect, true);
emoji_para
.paragraph
.paint(canvas, (emoji_para.x, emoji_para.y));
for deco in &deco_para.decorations {
draw_decoration_segment(canvas, deco);
for (kind, paint) in stroke_decos {
draw_decoration_stroke(canvas, *kind, paint, deco.rect());
}
}
canvas.restore();
}
pub fn render_emoji_overlay(
render_state: &mut RenderState,
shape: &Shape,
emoji_builders: &mut [ParagraphBuilderGroup],
deco_builders: &mut [ParagraphBuilderGroup],
surface_id: SurfaceId,
blur: Option<&ImageFilter>,
) {
let text_content = shape.get_text_content();
let mut emoji_layout = calculate_text_layout_data(shape, text_content, emoji_builders, true);
if !emoji_layout
.paragraphs
.iter_mut()
.any(|para| paragraph_has_emoji(&mut para.paragraph))
{
return;
}
let selrect = shape.selrect();
let mut stroke_decos: Vec<(StrokeKind, Paint)> = Vec::new();
for stroke in shape.visible_strokes().rev() {
let (paints, opacity) = get_text_stroke_paints(stroke, &selrect, false);
for mut paint in paints {
if let Some(opacity) = opacity {
paint.set_alpha_f(opacity);
}
stroke_decos.push((stroke.kind, paint));
}
}
let deco_layout = calculate_text_layout_data(shape, text_content, deco_builders, true);
let canvas = render_state.surfaces.canvas_and_mark_dirty(surface_id);
if let Some(blur_filter) = blur {
let mut blur_paint = Paint::default();
blur_paint.set_image_filter(blur_filter.clone());
let layer_bounds = shape.layer_bounds();
canvas.save_layer(
&SaveLayerRec::default()
.bounds(&layer_bounds)
.paint(&blur_paint),
);
}
for (emoji_para, deco_para) in emoji_layout
.paragraphs
.iter_mut()
.zip(deco_layout.paragraphs.iter())
{
paint_emoji_opaque(canvas, emoji_para, deco_para, &stroke_decos);
}
if blur.is_some() {
canvas.restore();
}
}
fn draw_text(
canvas: &Canvas,
shape: &Shape,
paragraph_builder_groups: &mut [ParagraphBuilderGroup],
layer_opacity: Option<f32>,
overlay_emoji: bool,
) {
// Multi-style spans are already encoded in each ParagraphBuilder's
// TextStyles; paragraph.paint handles them without an isolation layer.
// Only open a save_layer when stroke-group opacity must composite as one.
if let Some(opacity) = layer_opacity {
let layer_bounds = shape.layer_bounds();
let mut opacity_paint = Paint::default();
opacity_paint.set_alpha_f(opacity);
let layer_rec = SaveLayerRec::default()
.bounds(&layer_bounds)
.paint(&opacity_paint);
canvas.save_layer(&layer_rec);
paint_text_with_emoji_overlay(canvas, shape, paragraph_builder_groups, overlay_emoji);
canvas.restore();
} else {
paint_text_with_emoji_overlay(canvas, shape, paragraph_builder_groups, overlay_emoji);
}
}
/// Renders a text stroke masked to the glyph shape.
///
/// `stroke_mask_blend` selects which side of the glyph the stroke keeps:
/// `SrcIn` for inner strokes (stroke clipped to the glyph), `SrcOut` for outer
/// strokes (stroke kept outside the glyph). When `fill_builders` is provided
/// (inner strokes) the fill is composited with `DstOver` *inside* the masked
/// layer so its anti-aliased edge aligns with the stroke — no seam at the glyph
/// boundary. Outer strokes pass `None` (fill is drawn separately).
#[allow(clippy::too_many_arguments)]
fn render_masked_stroke_on_canvas(
canvas: &Canvas,
shape: &Shape,
mask_builders: &mut [ParagraphBuilderGroup],
stroke_builders: &mut [ParagraphBuilderGroup],
fill_builders: Option<&mut [ParagraphBuilderGroup]>,
stroke_mask_blend: skia::BlendMode,
blur: Option<&ImageFilter>,
layer_opacity: Option<f32>,
) {
let layer_bounds = shape.layer_bounds();
if let Some(blur_filter) = blur {
let mut blur_paint = Paint::default();
blur_paint.set_image_filter(blur_filter.clone());
canvas.save_layer(
&SaveLayerRec::default()
.bounds(&layer_bounds)
.paint(&blur_paint),
);
}
if let Some(opacity) = layer_opacity {
let mut opacity_paint = Paint::default();
opacity_paint.set_alpha_f(opacity);
canvas.save_layer(
&SaveLayerRec::default()
.bounds(&layer_bounds)
.paint(&opacity_paint),
);
}
canvas.save_layer(&SaveLayerRec::default().bounds(&layer_bounds));
canvas.save_layer(&SaveLayerRec::default().bounds(&layer_bounds));
paint_text(canvas, shape, mask_builders);
let mut stroke_paint = Paint::default();
stroke_paint.set_blend_mode(stroke_mask_blend);
canvas.save_layer(
&SaveLayerRec::default()
.bounds(&layer_bounds)
.paint(&stroke_paint),
);
paint_text(canvas, shape, stroke_builders);
// Fill with DstOver behind the stroke, inside the masked layer so the fill's
// anti-aliased edge aligns with the stroke (no seam at the glyph edge).
// Outer strokes have no fill here (`None`).
if let Some(fill_builders) = fill_builders {
let mut dst_over_paint = Paint::default();
dst_over_paint.set_blend_mode(skia::BlendMode::DstOver);
canvas.save_layer(
&SaveLayerRec::default()
.bounds(&layer_bounds)
.paint(&dst_over_paint),
);
paint_text(canvas, shape, fill_builders);
canvas.restore(); // DstOver layer
}
canvas.restore(); // SrcIn / SrcOut layer
canvas.restore(); // mask group layer
canvas.restore(); // outer layer
if layer_opacity.is_some() {
canvas.restore();
}
if blur.is_some() {
canvas.restore();
}
}
#[allow(clippy::too_many_arguments)]
fn render_masked_stroke(
render_state: Option<&mut RenderState>,
canvas: Option<&Canvas>,
shape: &Shape,
mask_builders: &mut [ParagraphBuilderGroup],
stroke_builders: &mut [ParagraphBuilderGroup],
mut fill_builders: Option<&mut [ParagraphBuilderGroup]>,
stroke_mask_blend: skia::BlendMode,
surface_id: Option<SurfaceId>,
blur: Option<&ImageFilter>,
stroke_bounds_outset: f32,
layer_opacity: Option<f32>,
) -> Result<()> {
if let Some(render_state) = render_state {
let target_surface = surface_id.unwrap_or(SurfaceId::Fills);
if let Some(blur_filter) = blur {
let mut text_bounds = shape
.get_text_content()
.calculate_bounds(shape, false)
.to_rect();
if stroke_bounds_outset > 0.0 {
text_bounds.inset((-stroke_bounds_outset, -stroke_bounds_outset));
}
let bounds = blur_filter.compute_fast_bounds(text_bounds);
if bounds.is_finite() && bounds.width() > 0.0 && bounds.height() > 0.0 {
let blur_filter_clone = blur_filter.clone();
let fill_builders = &mut fill_builders;
if filters::render_with_filter_surface(
render_state,
bounds,
target_surface,
|state, temp_surface| {
let temp_canvas = state.surfaces.canvas(temp_surface);
render_masked_stroke_on_canvas(
temp_canvas,
shape,
mask_builders,
stroke_builders,
fill_builders.as_deref_mut(),
stroke_mask_blend,
Some(&blur_filter_clone),
layer_opacity,
);
Ok(())
},
)? {
return Ok(());
}
}
}
let canvas = render_state.surfaces.canvas_and_mark_dirty(target_surface);
render_masked_stroke_on_canvas(
canvas,
shape,
mask_builders,
stroke_builders,
fill_builders.as_deref_mut(),
stroke_mask_blend,
blur,
layer_opacity,
);
return Ok(());
}
if let Some(canvas) = canvas {
render_masked_stroke_on_canvas(
canvas,
shape,
mask_builders,
stroke_builders,
fill_builders,
stroke_mask_blend,
blur,
layer_opacity,
);
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub fn render_inner_stroke(
render_state: Option<&mut RenderState>,
canvas: Option<&Canvas>,
shape: &Shape,
stroke_builders: &mut [ParagraphBuilderGroup],
fill_builders: &mut [ParagraphBuilderGroup],
surface_id: Option<SurfaceId>,
blur: Option<&ImageFilter>,
stroke_bounds_outset: f32,
layer_opacity: Option<f32>,
) -> Result<()> {
let mut mask_builders = shape.get_text_content().paragraph_builder_group_opaque();
render_masked_stroke(
render_state,
canvas,
shape,
&mut mask_builders,
stroke_builders,
Some(fill_builders),
skia::BlendMode::SrcIn,
surface_id,
blur,
stroke_bounds_outset,
layer_opacity,
)
}
#[allow(clippy::too_many_arguments)]
pub fn render_outer_stroke(
render_state: Option<&mut RenderState>,
canvas: Option<&Canvas>,
shape: &Shape,
stroke_builders: &mut [ParagraphBuilderGroup],
surface_id: Option<SurfaceId>,
blur: Option<&ImageFilter>,
stroke_bounds_outset: f32,
layer_opacity: Option<f32>,
) -> Result<()> {
let mut mask_builders = shape.get_text_content().paragraph_builder_group_opaque();
render_masked_stroke(
render_state,
canvas,
shape,
&mut mask_builders,
stroke_builders,
None,
skia::BlendMode::SrcOut,
surface_id,
blur,
stroke_bounds_outset,
layer_opacity,
)
}
fn draw_decoration_segment(canvas: &Canvas, deco: &TextDecorationSegment) {
let mut decoration_paint = deco.text_style.foreground();
decoration_paint.set_anti_alias(true);
canvas.draw_rect(deco.rect(), &decoration_paint);
}
/// One decorated span clipped to a line: UTF-16 range, decoration, the span's
/// offsets and the Skia style run it falls in (paint + font metrics).
type LineDecoration<'a> = (
usize,
usize,
TextDecoration,
&'a HorizontalSpanRange,
&'a StyleMetrics<'a>,
);
/// UTF-16 ranges of the spans that ask for a decoration we draw.
/// Builder-text range of every underlined or struck span. Warichu spans
/// collapse to a placeholder and get no bar.
fn decorated_span_ranges(
text_paragraph: &TextParagraph,
offsets: &HorizontalOffsets,
) -> Vec<(usize, usize, TextDecoration)> {
text_paragraph
.children()
.iter()
.zip(&offsets.ranges)
.filter_map(|(span, range)| match span.text_decoration {
Some(kind)
if !range.warichu
&& (kind == TextDecoration::UNDERLINE
|| kind == TextDecoration::LINE_THROUGH) =>
{
Some((range.builder_start, range.builder_end, kind))
}
_ => None,
})
.collect()
}
/// Builder-text range of the span holding UTF-16 `offset`.
fn span_range_at(offsets: &HorizontalOffsets, offset: usize) -> Option<&HorizontalSpanRange> {
let index = offsets
.ranges
.partition_point(|range| range.builder_start <= offset)
.saturating_sub(1);
offsets.ranges.get(index)
}
/// Style run of a line covering the UTF-8 `byte_anchor`. Runs are keyed by
/// their UTF-8 start, unlike the UTF-16 line indices. An anchor before the
/// first run belongs to a span continued from an earlier line, which the
/// line's first run carries on.
fn style_metric_at<'a>(
style_metrics: &[(usize, &'a StyleMetrics<'a>)],
byte_anchor: usize,
) -> Option<&'a StyleMetrics<'a>> {
style_metrics
.iter()
.rev()
.find(|(start, _)| *start <= byte_anchor)
.or_else(|| style_metrics.first())
.map(|(_, metrics)| *metrics)
}
/// Decoration bars for one laid out paragraph, in shape coordinates.
///
/// Segmented by the model's spans, so which spans get a bar never depends on
/// how Skia grouped the line into style runs; the runs only supply the paint
/// and font metrics covering each segment.
pub fn decoration_segments(
skia_paragraph: &LayoutParagraph,
text_paragraph: &TextParagraph,
offsets: &HorizontalOffsets,
x: f32,
y_accum: f32,
) -> Vec<TextDecorationSegment> {
let decorated = decorated_span_ranges(text_paragraph, offsets);
if decorated.is_empty() {
return Vec::new();
}
let mut segments = Vec::new();
for line in &skia_paragraph.get_line_metrics() {
let style_metrics = line.get_style_metrics(0..usize::MAX);
let line_baseline = y_accum + line.baseline as f32;
let line_decorations: Vec<LineDecoration<'_>> = decorated
.iter()
.filter_map(|&(start, end, kind)| {
let seg_start = start.max(line.start_index);
let seg_end = end.min(line.end_index);
if seg_start >= seg_end {
return None;
}
let range = span_range_at(offsets, seg_start)?;
let metrics = style_metric_at(&style_metrics, range.style_anchor_start)?;
Some((seg_start, seg_end, kind, range, metrics))
})
.collect();
let (max_underline_thickness, underline_y, max_strike_thickness, strike_y) =
calculate_decoration_metrics(&line_decorations, line_baseline);
for (seg_start, seg_end, kind, range, metrics) in line_decorations {
let rects = skia_paragraph.get_rects_for_range(
seg_start..seg_end,
skia::textlayout::RectHeightStyle::Tight,
skia::textlayout::RectWidthStyle::Tight,
);
let (width, x_offset) = match rects.first() {
Some(first) => {
let total_width: f32 = rects.iter().map(|r| r.rect.width()).sum();
(total_width, first.rect.left - line.left as f32)
}
None => (0.0, 0.0),
};
let (mut y, thickness) = if kind == TextDecoration::LINE_THROUGH {
(strike_y, max_strike_thickness)
} else {
(underline_y, max_underline_thickness)
};
if kind == TextDecoration::UNDERLINE {
if let Some(span) = text_paragraph
.children()
.get(range.span)
.filter(|span| span.underline_below_ink())
{
let bottom = rects
.iter()
.map(|r| r.rect.bottom())
.fold(f32::NEG_INFINITY, f32::max);
if bottom.is_finite() {
y = Some(y_accum + bottom + span.font_size / 9.0 + thickness / 2.0);
}
}
}
segments.push(TextDecorationSegment {
kind,
text_style: (*metrics.text_style).clone(),
y: y.unwrap_or(line_baseline),
thickness,
left: x + line.left as f32 + x_offset,
width,
});
}
}
segments
}
fn calculate_decoration_metrics(
line_decorations: &[LineDecoration<'_>],
line_baseline: f32,
) -> (f32, Option<f32>, f32, Option<f32>) {
let mut max_underline_thickness: f32 = 0.0;
let mut underline_y = None;
let mut max_strike_thickness: f32 = 0.0;
let mut strike_y = None;
for (_seg_start, _seg_end, kind, _range, style_metric) in line_decorations.iter() {
let font_metrics = style_metric.font_metrics;
let font_size = font_metrics
.cap_height
.abs()
.max(font_metrics.x_height.abs());
let min_thickness = (font_size * 0.06).max(1.0);
// Magic numbers for line thickness partially based on Chromium
// (see https://source.chromium.org/chromium/chromium/src/+/main:ui/gfx/render_text.cc
let raw_font_size = style_metric.text_style.font_size();
let thickness_factor = raw_font_size.powf(0.4) * 6.0 / 18.0;
let thickness = (font_metrics.underline_thickness().unwrap_or(1.0) * thickness_factor)
.max(min_thickness);
if *kind == TextDecoration::UNDERLINE {
// Same gap from baseline to underline as in Chromium
// (see https://source.chromium.org/chromium/chromium/src/+/main:ui/gfx/render_text.cc
let gap_scaling = raw_font_size * 1.0 / 9.0;
let y = line_baseline + gap_scaling;
max_underline_thickness = max_underline_thickness.max(thickness);
underline_y = Some(y);
}
if *kind == TextDecoration::LINE_THROUGH {
let y = line_baseline
+ font_metrics
.strikeout_position()
.unwrap_or(-font_metrics.cap_height / 2.0);
max_strike_thickness = max_strike_thickness.max(thickness);
strike_y = Some(y);
}
}
(
max_underline_thickness,
underline_y,
max_strike_thickness,
strike_y,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shapes::{FontFamily, FontStyle, TextAlign, TextDirection, TextSpan, TextTransform};
use crate::uuid::Uuid;
fn span(
text: &str,
decoration: Option<TextDecoration>,
transform: Option<TextTransform>,
) -> TextSpan {
TextSpan::new(
text.to_string(),
FontFamily::new(Uuid::nil(), 400, FontStyle::Normal),
14.0,
1.2,
0.0,
decoration,
transform,
TextDirection::LTR,
400,
Uuid::nil(),
vec![],
)
}
fn paragraph(spans: Vec<TextSpan>) -> TextParagraph {
TextParagraph::new(
TextAlign::Left,
TextDirection::LTR,
None,
None,
1.2,
0.0,
spans,
)
}
#[test]
fn decorated_ranges_follow_spans_not_paint_runs() {
let para = paragraph(vec![
span("plain ", None, None),
span("under", Some(TextDecoration::UNDERLINE), None),
span(" plain ", None, None),
span("struck", Some(TextDecoration::LINE_THROUGH), None),
]);
assert_eq!(
decorated_span_ranges(&para, &HorizontalOffsets::new(&para)),
vec![
(6, 11, TextDecoration::UNDERLINE),
(18, 24, TextDecoration::LINE_THROUGH),
]
);
}
#[test]
fn decorated_ranges_are_utf16_offsets_of_the_transformed_text() {
let para = paragraph(vec![
span("🎉", None, None),
span(
"straße",
Some(TextDecoration::UNDERLINE),
Some(TextTransform::Uppercase),
),
span("x", Some(TextDecoration::UNDERLINE), None),
]);
// The emoji takes two UTF-16 units and `ß` uppercases to `SS`.
assert_eq!(
decorated_span_ranges(&para, &HorizontalOffsets::new(&para)),
vec![
(2, 9, TextDecoration::UNDERLINE),
(9, 10, TextDecoration::UNDERLINE),
]
);
}
#[test]
fn decorated_ranges_follow_inserted_kinsoku_characters() {
// A word joiner goes before 。, so the next span starts one unit later.
let mut para = paragraph(vec![
span("あ。", None, None),
span("い", Some(TextDecoration::UNDERLINE), None),
]);
para.set_language("ja".to_string());
assert_eq!(
decorated_span_ranges(&para, &HorizontalOffsets::new(&para)),
vec![(3, 4, TextDecoration::UNDERLINE)]
);
}
#[test]
fn decorated_ranges_skip_warichu_placeholders() {
let mut note = span("割注入り", None, None);
note.warichu = true;
let para = paragraph(vec![
note,
span("い", Some(TextDecoration::UNDERLINE), None),
]);
// The warichu span collapses to a three-unit placeholder.
assert_eq!(
decorated_span_ranges(&para, &HorizontalOffsets::new(&para)),
vec![(3, 4, TextDecoration::UNDERLINE)]
);
}
#[test]
fn undecorated_paragraphs_have_no_ranges() {
let para = paragraph(vec![
span("plain", None, None),
span("none", Some(TextDecoration::NO_DECORATION), None),
]);
assert!(decorated_span_ranges(&para, &HorizontalOffsets::new(&para)).is_empty());
}
}