mirror of
https://github.com/penpot/penpot.git
synced 2026-10-30 22:40:33 +00:00
Choose paragraph layout rules from an optional language tag. Pass language to horizontal and vertical shaping, add basic Chinese punctuation rules, and add experimental Korean spaces-only wrapping. Keep text boundaries grapheme-safe and preserve paragraph profiles across editor splits and merges. Add reproducible font fixtures for localized glyphs and regression tests for the shared WASM protocol and layout paths. Validation: renderer, common JVM/JS, frontend and exporter tests pass. Both WASM targets build. Full CI still reports baseline lint and format errors and one backend multipart-upload test failure. AI-assisted-by: GPT-6
1619 lines
52 KiB
Rust
1619 lines
52 KiB
Rust
use super::{filters, RenderState, Shape, SurfaceId, DEFAULT_EMOJI_FONT};
|
|
use crate::{
|
|
error::Result,
|
|
math::Rect,
|
|
shapes::{
|
|
add_horizontal_span, calculate_text_layout_data, set_paint_fill, text_vertical,
|
|
vertical_align_offset, HorizontalOffsets, 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::{ParagraphBuilder, RectHeightStyle, RectWidthStyle, StyleMetrics, TextDecoration},
|
|
Canvas, ImageFilter, Paint,
|
|
};
|
|
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();
|
|
for (paragraph, plan) in text_content.paragraphs().iter().zip(plans.iter()) {
|
|
let mut stroke_paragraphs_map: std::collections::HashMap<usize, ParagraphBuilder> =
|
|
std::collections::HashMap::new();
|
|
|
|
for (((span, text), sheds), extra) in paragraph
|
|
.children()
|
|
.iter()
|
|
.zip(&plan.texts)
|
|
.zip(&plan.sheds)
|
|
.zip(&plan.ruby_spacing.adjustments)
|
|
{
|
|
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 paragraph_style = paragraph.paragraph_to_style();
|
|
ParagraphBuilder::new(¶graph_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: Vec<ParagraphBuilder> = (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 [Vec<ParagraphBuilder>],
|
|
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 [Vec<ParagraphBuilder>],
|
|
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 [Vec<ParagraphBuilder>],
|
|
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 [Vec<ParagraphBuilder>],
|
|
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 [Vec<ParagraphBuilder>],
|
|
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;
|
|
};
|
|
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 [Vec<ParagraphBuilder>],
|
|
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 [Vec<ParagraphBuilder>],
|
|
) {
|
|
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 [Vec<ParagraphBuilder>],
|
|
) {
|
|
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 [Vec<ParagraphBuilder>],
|
|
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,
|
|
¶.paragraph,
|
|
para.x,
|
|
para.y,
|
|
);
|
|
crate::shapes::paint_horizontal_emphasis(
|
|
canvas,
|
|
source_paragraph,
|
|
plan,
|
|
¶.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,
|
|
¶.paragraph,
|
|
para.x,
|
|
para.y,
|
|
);
|
|
|
|
for deco in ¶.decorations {
|
|
draw_decoration_segment(canvas, deco);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn paragraph_has_emoji(paragraph: &mut skia::textlayout::Paragraph) -> 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 skia::textlayout::Paragraph,
|
|
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 [Vec<ParagraphBuilder>],
|
|
deco_builders: &mut [Vec<ParagraphBuilder>],
|
|
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 [Vec<ParagraphBuilder>],
|
|
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 [Vec<ParagraphBuilder>],
|
|
stroke_builders: &mut [Vec<ParagraphBuilder>],
|
|
fill_builders: Option<&mut [Vec<ParagraphBuilder>]>,
|
|
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 [Vec<ParagraphBuilder>],
|
|
stroke_builders: &mut [Vec<ParagraphBuilder>],
|
|
mut fill_builders: Option<&mut [Vec<ParagraphBuilder>]>,
|
|
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 [Vec<ParagraphBuilder>],
|
|
fill_builders: &mut [Vec<ParagraphBuilder>],
|
|
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 [Vec<ParagraphBuilder>],
|
|
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 and the
|
|
/// Skia style run it falls in (paint + font metrics).
|
|
type LineDecoration<'a> = (usize, usize, TextDecoration, &'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()
|
|
}
|
|
|
|
/// Style run covering `offset`; runs are keyed by their start index.
|
|
fn style_metric_at<'a>(
|
|
style_metrics: &[(usize, &'a StyleMetrics<'a>)],
|
|
offset: usize,
|
|
) -> Option<&'a StyleMetrics<'a>> {
|
|
style_metrics
|
|
.iter()
|
|
.rev()
|
|
.find(|(start, _)| *start <= offset)
|
|
.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: &skia::textlayout::Paragraph,
|
|
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: Vec<_> = line
|
|
.get_style_metrics(line.start_index..line.end_index)
|
|
.into_iter()
|
|
.collect();
|
|
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 metrics = style_metric_at(&style_metrics, seg_start)?;
|
|
Some((seg_start, seg_end, kind, 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, 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 (y, thickness) = if kind == TextDecoration::LINE_THROUGH {
|
|
(strike_y, max_strike_thickness)
|
|
} else {
|
|
(underline_y, max_underline_thickness)
|
|
};
|
|
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, 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(¶, &HorizontalOffsets::new(¶)),
|
|
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(¶, &HorizontalOffsets::new(¶)),
|
|
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(¶, &HorizontalOffsets::new(¶)),
|
|
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(¶, &HorizontalOffsets::new(¶)),
|
|
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(¶, &HorizontalOffsets::new(¶)).is_empty());
|
|
}
|
|
}
|