use anyrender::PaintScene;
use blitz_dom::{BaseDocument, NodeId, node::TextBrush, util::ToColorColor};
use kurbo::{Affine, BezPath, Cap, Circle, Rect, Stroke};
use parley::{Affinity, Cursor, Layout, Line, PositionedLayoutItem, Selection};
use peniko::Fill;
use std::collections::HashMap;
use style::properties::generated::longhands::text_decoration_style::computed_value::T as TextDecorationStyle;
use style::values::computed::{
Length, LengthPercentage, TextDecorationLength, TextDecorationLine, TextUnderlinePosition,
};
use style::values::generics::text::{GenericTextDecorationInset, GenericTextDecorationLength};
use crate::color::{Color, ToColorColor as _};
use crate::{FONT_EMBOLDEN_ENABLED, SELECTION_COLOR};
pub(crate) fn draw_inline_backgrounds<'a>(
scene: &mut impl PaintScene,
lines: impl Iterator<Item = Line<'a, TextBrush>>,
doc: &BaseDocument,
transform: Affine,
inline_root_id: NodeId,
) {
for line in lines {
for item in line.items() {
let PositionedLayoutItem::GlyphRun(glyph_run) = item else {
continue;
};
let node_id = glyph_run.style().brush.id;
if node_id == inline_root_id {
continue;
}
let Some(styles) = doc.get_node(node_id).and_then(|node| node.primary_styles()) else {
continue;
};
let current_color = styles.clone_color();
let bg_color = styles
.get_background()
.background_color
.resolve_to_absolute(¤t_color)
.as_srgb_color();
if bg_color == Color::TRANSPARENT {
continue;
}
let metrics = glyph_run.run().metrics();
let x = glyph_run.offset() as f64;
let w = glyph_run.advance() as f64;
let baseline = glyph_run.baseline() as f64;
let y0 = baseline - metrics.ascent as f64;
let y1 = baseline + metrics.descent as f64;
let rect = Rect::new(x, y0, x + w, y1);
scene.fill(Fill::NonZero, transform, bg_color, None, &rect);
}
}
}
type WinAscentCache = HashMap<(u64, u32), Option<f32>>;
fn win_ascent(cache: &mut WinAscentCache, font: &parley::FontData, font_size: f32) -> Option<f32> {
let ratio = *cache
.entry((font.data.id(), font.index))
.or_insert_with(|| win_ascent_ratio(font));
Some(ratio? * font_size)
}
fn win_ascent_ratio(font: &parley::FontData) -> Option<f32> {
use skrifa::raw::{FontRef, TableProvider as _};
let font_ref = FontRef::from_index(font.data.as_ref(), font.index).ok()?;
let units_per_em = font_ref.head().ok()?.units_per_em();
let win_ascent = font_ref.os2().ok()?.us_win_ascent();
Some(win_ascent as f32 / units_per_em as f32)
}
fn select_best_dash_gap(stroke_length: f64, dash_length: f64, gap_length: f64) -> f64 {
let available_length = stroke_length + gap_length;
let min_num_dashes = (available_length / (dash_length + gap_length)).floor();
let max_num_dashes = min_num_dashes + 1.0;
let min_num_gaps = min_num_dashes - 1.0;
let max_num_gaps = max_num_dashes - 1.0;
if min_num_gaps < 1.0 {
return gap_length;
}
let min_gap = (stroke_length - min_num_dashes * dash_length) / min_num_gaps;
let max_gap = (stroke_length - max_num_dashes * dash_length) / max_num_gaps;
if max_gap <= 0.0 || (min_gap - gap_length).abs() < (max_gap - gap_length).abs() {
min_gap
} else {
max_gap
}
}
#[derive(Clone)]
struct ResolvedDecoration {
line: TextDecorationLine,
style: TextDecorationStyle,
color: Color,
thickness: TextDecorationLength,
underline_offset: Option<LengthPercentage>,
underline_under: bool,
inset: GenericTextDecorationInset<LengthPercentage>,
}
struct DecorationStackEntry {
node_id: NodeId,
text_color: Color,
decoration: Option<ResolvedDecoration>,
}
fn resolve_decoration_entry(doc: &BaseDocument, node_id: NodeId) -> DecorationStackEntry {
let Some(styles) = doc.get_node(node_id).and_then(|node| node.primary_styles()) else {
return DecorationStackEntry {
node_id,
text_color: Color::BLACK,
decoration: None,
};
};
let itext = styles.get_inherited_text();
let text = styles.get_text();
let text_color = itext.color.as_color_color();
let drawn_lines = TextDecorationLine::UNDERLINE
| TextDecorationLine::OVERLINE
| TextDecorationLine::LINE_THROUGH;
let line = text.text_decoration_line;
let is_contents = styles.clone_display().is_contents();
let decoration = (!is_contents && line.intersects(drawn_lines)).then(|| {
let color = text
.text_decoration_color
.as_absolute()
.map(ToColorColor::as_color_color)
.unwrap_or(text_color);
ResolvedDecoration {
line,
style: text.text_decoration_style,
color,
thickness: text.text_decoration_thickness.clone(),
underline_offset: itext.text_underline_offset.non_auto(),
underline_under: itext
.text_underline_position
.contains(TextUnderlinePosition::UNDER),
inset: text.text_decoration_inset.clone(),
}
});
DecorationStackEntry {
node_id,
text_color,
decoration,
}
}
#[derive(Clone)]
struct DecorationRunGeometry {
baseline: f32,
ascent: f32,
descent: f32,
underline_offset: f32,
underline_size: f32,
strikethrough_size: f32,
font: parley::FontData,
font_size: f32,
css_font_size: f64,
}
struct LineDecoration {
node_id: NodeId,
deco: ResolvedDecoration,
min_x: f64,
max_x: f64,
own: Option<DecorationRunGeometry>,
first: Option<DecorationRunGeometry>,
}
#[derive(Default)]
pub(crate) struct DrawTextContext {
stack: Vec<DecorationStackEntry>,
path_scratch: Vec<NodeId>,
deco_boxes: Vec<LineDecoration>,
win_ascent_ratios: WinAscentCache,
}
fn decoration_size(
thickness: &TextDecorationLength,
metric_size: f32,
css_font_size: f64,
scale: f64,
) -> f32 {
(match thickness {
GenericTextDecorationLength::LengthPercentage(lp) => {
lp.resolve(Length::new(css_font_size as f32)).px() as f64 * scale
}
GenericTextDecorationLength::FromFont => metric_size as f64,
GenericTextDecorationLength::Auto => (css_font_size / 10.0).max(1.0) * scale,
})
.floor()
.max(1.0) as f32
}
#[allow(clippy::too_many_arguments)]
fn draw_decoration_line(
scene: &mut impl PaintScene,
transform: Affine,
scale: f64,
x0: f64,
width: f64,
baseline: f32,
offset: f32,
size: f32,
brush: &anyrender::Paint,
double_dir: f64,
deco_style: TextDecorationStyle,
inset_start: f64,
inset_end: f64,
) {
let x = x0 + inset_start;
let w = width - inset_start - inset_end;
if w <= 0.0 {
return;
}
let y = (baseline - offset + size / 2.0) as f64;
let size = size as f64;
let butt_stroke = Stroke::new(size).with_caps(Cap::Butt);
match deco_style {
TextDecorationStyle::MozNone => {
}
TextDecorationStyle::Solid => {
let line = kurbo::Line::new((x, y), (x + w, y));
scene.stroke(&butt_stroke, transform, brush, None, &line);
}
TextDecorationStyle::Double => {
let one_css_px = scale;
for cy in [y, y + double_dir * (size + one_css_px)] {
let line = kurbo::Line::new((x, cy), (x + w, cy));
scene.stroke(&butt_stroke, transform, brush, None, &line);
}
}
TextDecorationStyle::Dotted => {
let radius = size / 2.0;
let step = 2.0 * size;
let mut cx = x + radius;
while cx <= x + w - radius {
let dot = Circle::new((cx, y), radius);
scene.fill(Fill::NonZero, transform, brush, None, &dot);
cx += step;
}
}
TextDecorationStyle::Dashed => {
let (dash_ratio, gap_ratio) = if size >= 3.0 { (2.0, 1.0) } else { (3.0, 2.0) };
let dash = size * dash_ratio;
let nominal_gap = size * gap_ratio;
let gap = if w > 2.0 * dash {
select_best_dash_gap(w, dash, nominal_gap)
} else {
nominal_gap
};
let mut dx = x;
while dx < x + w {
let end = (dx + dash).min(x + w);
let rect = Rect::new(dx, y - size / 2.0, end, y + size / 2.0);
scene.fill(Fill::NonZero, transform, brush, None, &rect);
dx += dash + gap;
}
}
TextDecorationStyle::Wavy => {
let amplitude = size;
let half_wave = (2.0 * size).max(1.0);
let mut path = BezPath::new();
path.move_to((x, y));
let mut px = x;
let mut up = true;
while px < x + w {
let nx = (px + half_wave).min(x + w);
let ctrl_y = if up {
y - 2.0 * amplitude
} else {
y + 2.0 * amplitude
};
path.quad_to(((px + nx) / 2.0, ctrl_y), (nx, y));
px = nx;
up = !up;
}
let stroke = Stroke::new(size).with_caps(Cap::Round);
scene.stroke(&stroke, transform, brush, None, &path);
}
}
}
fn flush_line_decorations(
scene: &mut impl PaintScene,
transform: Affine,
scale: f64,
deco_boxes: &[LineDecoration],
win_ascent_ratios: &mut WinAscentCache,
) {
for acc in deco_boxes.iter().rev() {
let deco = &acc.deco;
let Some(geom) = acc.own.as_ref().or(acc.first.as_ref()) else {
continue;
};
let width = acc.max_x - acc.min_x;
if width <= 0.0 {
continue;
}
let brush = anyrender::Paint::from(deco.color);
let (inset_start, inset_end) = match &deco.inset {
GenericTextDecorationInset::LengthPercentage { start, end } => {
let line_length = Length::new((width / scale) as f32);
(
start.resolve(line_length).px() as f64 * scale,
end.resolve(line_length).px() as f64 * scale,
)
}
GenericTextDecorationInset::Auto => (0.0, 0.0),
};
if deco.line.contains(TextDecorationLine::UNDERLINE) {
let size = decoration_size(
&deco.thickness,
geom.underline_size,
geom.css_font_size,
scale,
);
let extra_underline_offset = deco
.underline_offset
.as_ref()
.map(|lp| lp.resolve(Length::new(geom.css_font_size as f32)).px() as f64 * scale)
.unwrap_or(0.0);
let base_offset = if deco.underline_under {
-geom.descent
} else {
geom.underline_offset
};
let offset = base_offset - extra_underline_offset as f32;
draw_decoration_line(
scene,
transform,
scale,
acc.min_x,
width,
geom.baseline,
offset,
size,
&brush,
1.0,
deco.style,
inset_start,
inset_end,
);
}
if deco.line.contains(TextDecorationLine::OVERLINE) {
let size = decoration_size(
&deco.thickness,
geom.underline_size,
geom.css_font_size,
scale,
);
let ascent =
win_ascent(win_ascent_ratios, &geom.font, geom.font_size).unwrap_or(geom.ascent);
let offset = ascent + size;
draw_decoration_line(
scene,
transform,
scale,
acc.min_x,
width,
geom.baseline,
offset,
size,
&brush,
-1.0,
deco.style,
inset_start,
inset_end,
);
}
if deco.line.contains(TextDecorationLine::LINE_THROUGH) {
let size = decoration_size(
&deco.thickness,
geom.strikethrough_size,
geom.css_font_size,
scale,
);
let ascent =
win_ascent(win_ascent_ratios, &geom.font, geom.font_size).unwrap_or(geom.ascent);
let offset = ascent / 3.0 + size / 2.0;
draw_decoration_line(
scene,
transform,
scale,
acc.min_x,
width,
geom.baseline,
offset,
size,
&brush,
1.0,
deco.style,
inset_start,
inset_end,
);
}
}
}
pub(crate) fn stroke_text<'a>(
scene: &mut impl PaintScene,
lines: impl Iterator<Item = Line<'a, TextBrush>>,
doc: &BaseDocument,
transform: Affine,
scale: f64,
inline_root_id: NodeId,
context: &mut DrawTextContext,
) {
let DrawTextContext {
stack,
path_scratch,
deco_boxes,
win_ascent_ratios,
} = context;
stack.clear();
path_scratch.clear();
deco_boxes.clear();
for line in lines {
deco_boxes.clear();
for item in line.items() {
if let PositionedLayoutItem::GlyphRun(glyph_run) = item {
let run = glyph_run.run();
let font = run.font();
let font_size = run.font_size();
let metrics = run.metrics();
let style = glyph_run.style();
let synthesis = run.synthesis();
let glyph_xform = synthesis
.skew()
.map(|angle| Affine::skew(angle.to_radians().tan() as f64, 0.0));
let css_font_size = font_size as f64 / scale;
path_scratch.clear();
let mut walk_id = Some(style.brush.id);
while let Some(node_id) = walk_id {
path_scratch.push(node_id);
if node_id == inline_root_id {
break;
}
walk_id = doc.get_node(node_id).and_then(|node| node.parent);
}
path_scratch.reverse();
let shared = stack
.iter()
.zip(path_scratch.iter())
.take_while(|(entry, node_id)| entry.node_id == **node_id)
.count();
stack.truncate(shared);
for &node_id in &path_scratch[shared..] {
stack.push(resolve_decoration_entry(doc, node_id));
}
let text_color = stack.last().map(|e| e.text_color).unwrap_or(Color::BLACK);
let embolden = if FONT_EMBOLDEN_ENABLED {
let fs = font_size as f64 / scale;
kurbo::Vec2::new((0.015125 * fs).min(0.3), (0.0121 * fs).min(0.3))
} else {
kurbo::Vec2::default()
};
scene.draw_glyphs(
font,
font_size,
!FONT_EMBOLDEN_ENABLED, run.normalized_coords(),
embolden,
Fill::NonZero,
&anyrender::Paint::from(text_color),
1.0, transform,
glyph_xform,
glyph_run.positioned_glyphs().map(|glyph| anyrender::Glyph {
id: glyph.id as _,
x: glyph.x,
y: glyph.y,
}),
);
let geometry = DecorationRunGeometry {
baseline: glyph_run.baseline(),
ascent: metrics.ascent,
descent: metrics.descent,
underline_offset: metrics.underline_offset,
underline_size: metrics.underline_size,
strikethrough_size: metrics.strikethrough_size,
font: font.clone(),
font_size,
css_font_size,
};
let run_node_id = style.brush.id;
let run_x0 = glyph_run.offset() as f64;
let run_x1 = run_x0 + glyph_run.advance() as f64;
for entry in stack.iter() {
if entry.decoration.is_none() {
continue;
}
let idx = match deco_boxes.iter().position(|d| d.node_id == entry.node_id) {
Some(idx) => idx,
None => {
deco_boxes.push(LineDecoration {
node_id: entry.node_id,
deco: entry.decoration.clone().unwrap(),
min_x: f64::INFINITY,
max_x: f64::NEG_INFINITY,
own: None,
first: None,
});
deco_boxes.len() - 1
}
};
let acc = &mut deco_boxes[idx];
acc.min_x = acc.min_x.min(run_x0);
acc.max_x = acc.max_x.max(run_x1);
if acc.first.is_none() {
acc.first = Some(geometry.clone());
}
if entry.node_id == run_node_id {
acc.own = Some(geometry.clone());
}
}
}
}
flush_line_decorations(scene, transform, scale, deco_boxes, win_ascent_ratios);
}
}
pub(crate) fn draw_text_selection(
scene: &mut impl PaintScene,
layout: &Layout<TextBrush>,
transform: Affine,
selection_start: usize,
selection_end: usize,
) {
let anchor = Cursor::from_byte_index(layout, selection_start, Affinity::Downstream);
let focus = Cursor::from_byte_index(layout, selection_end, Affinity::Downstream);
let selection = Selection::new(anchor, focus);
selection.geometry_with(layout, |rect, _line_idx| {
let rect = kurbo::Rect::new(rect.x0, rect.y0, rect.x1, rect.y1);
scene.fill(Fill::NonZero, transform, SELECTION_COLOR, None, &rect);
});
}