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, ) -> (Vec, Option) { 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 = 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 = 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 = (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, Option) { let mut paints = Vec::new(); let mut layer_opacity: Option = 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], surface_id: Option, shadow: Option<&Paint>, blur: Option<&ImageFilter>, stroke_bounds_outset: f32, fill_inset: Option, layer_opacity: Option, ) -> 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], shadow: Option<&Paint>, blur: Option<&ImageFilter>, stroke_bounds_outset: f32, fill_inset: Option, layer_opacity: Option, ) -> 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], surface_id: Option, shadow: Option<&Paint>, blur: Option<&ImageFilter>, stroke_bounds_outset: f32, fill_inset: Option, layer_opacity: Option, 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], surface_id: Option, shadow: Option<&Paint>, blur: Option<&ImageFilter>, fill_inset: Option, layer_opacity: Option, ) -> 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], shadow: Option<&Paint>, blur: Option<&ImageFilter>, fill_inset: Option, layer_opacity: Option, ) -> 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, layout_cache_rotation_only: bool, ) -> Result { 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], shadow: Option<&Paint>, blur: Option<&ImageFilter>, fill_inset: Option, layer_opacity: Option, 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], ) { 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], ) { 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], 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], deco_builders: &mut [Vec], 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], layer_opacity: Option, 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], stroke_builders: &mut [Vec], fill_builders: Option<&mut [Vec]>, stroke_mask_blend: skia::BlendMode, blur: Option<&ImageFilter>, layer_opacity: Option, ) { 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], stroke_builders: &mut [Vec], mut fill_builders: Option<&mut [Vec]>, stroke_mask_blend: skia::BlendMode, surface_id: Option, blur: Option<&ImageFilter>, stroke_bounds_outset: f32, layer_opacity: Option, ) -> 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], fill_builders: &mut [Vec], surface_id: Option, blur: Option<&ImageFilter>, stroke_bounds_outset: f32, layer_opacity: Option, ) -> 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], surface_id: Option, blur: Option<&ImageFilter>, stroke_bounds_outset: f32, layer_opacity: Option, ) -> 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 { 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> = 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 (mut y, thickness) = if kind == TextDecoration::LINE_THROUGH { (strike_y, max_strike_thickness) } else { (underline_y, max_underline_thickness) }; if kind == TextDecoration::UNDERLINE { let index = offsets .ranges .partition_point(|range| range.builder_start <= seg_start) .saturating_sub(1); if let Some(span) = offsets .ranges .get(index) .and_then(|range| 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, Option) { 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, transform: Option, ) -> 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) -> 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()); } }