fix(render): paint overline and line-through text decorations

Carry decoration flags through inline shaping and paint contiguous glyph runs. Add paint regressions and a deterministic fixture.

Fixes #935.
This commit is contained in:
lisa0314
2026-09-14 11:45:57 +08:00
parent 727cc46d56
commit c68fa2bc8a
5 changed files with 196 additions and 52 deletions

View File

@@ -423,11 +423,27 @@ type ClipTextFill = (f32, Vec<([u8; 4], Option<f32>)>);
/// directly, while the upper half is a one-based index into an optional
/// variation-set table.
const META_UNDERLINE: usize = 1;
const META_FILL_SHIFT: usize = 1;
const META_OVERLINE: usize = 2;
const META_LINE_THROUGH: usize = 4;
const META_DECORATIONS: usize = META_UNDERLINE | META_OVERLINE | META_LINE_THROUGH;
const META_FILL_SHIFT: usize = 3;
const META_VARIATION_BITS: usize = usize::BITS as usize / 2;
const META_VARIATION_SHIFT: usize = usize::BITS as usize - META_VARIATION_BITS;
const META_VARIATION_MASK: usize = ((1usize << META_VARIATION_BITS) - 1) << META_VARIATION_SHIFT;
const META_FILL_MASK: usize = ((1usize << META_VARIATION_SHIFT) - 1) & !META_UNDERLINE;
const META_FILL_MASK: usize = ((1usize << META_VARIATION_SHIFT) - 1) & !META_DECORATIONS;
fn decoration_stroke(
(start, end, size, color, relative): (f32, f32, f32, [u8; 4], (f32, f32)),
baseline: f32,
flag: usize,
) -> (f32, f32, f32, f32, [u8; 4]) {
let offset = match flag {
META_OVERLINE => -size * 0.8,
META_LINE_THROUGH => -size * 0.3,
_ => (size * 0.12).max(1.0),
};
(start, end, baseline + relative.1 + offset, (size / 14.0).max(1.0), color)
}
fn metadata_fill(metadata: usize) -> Option<usize> {
((metadata & META_FILL_MASK) >> META_FILL_SHIFT).checked_sub(1)
@@ -1236,6 +1252,8 @@ impl TextEngine {
italic: context.italic,
synthetic_italic: context.synthetic_italic,
underline: context.underline,
overline: context.overline,
line_through: context.line_through,
color: context.color,
family: context.family,
clip_fill: context.clip_fill,
@@ -1854,6 +1872,8 @@ struct SpanAttrs {
italic: bool,
synthetic_italic: bool,
underline: bool,
overline: bool,
line_through: bool,
color: [u8; 4],
family: Arc<str>,
clip_fill: Option<usize>,
@@ -1955,7 +1975,12 @@ impl SpanAttrs {
word_break,
overflow_wrap,
});
a = a.metadata(fill | variation | usize::from(self.underline));
a = a.metadata(
fill | variation
| usize::from(self.underline) * META_UNDERLINE
| usize::from(self.overline) * META_OVERLINE
| usize::from(self.line_through) * META_LINE_THROUGH,
);
a
}
}
@@ -1976,6 +2001,8 @@ struct SpanCtx {
italic: bool,
synthetic_italic: bool,
underline: bool,
overline: bool,
line_through: bool,
transform: TextTransform,
white_space: crate::WhiteSpace,
overflow_wrap: crate::OverflowWrap,
@@ -2119,6 +2146,8 @@ fn base_span_ctx(base: &LayoutStyle, font: ResolvedFont, collector: &mut Collect
italic: base.font_style_italic.unwrap_or(false),
synthetic_italic: font.synthetic_italic,
underline: base.underline.unwrap_or(false),
overline: base.overline.unwrap_or(false),
line_through: base.line_through.unwrap_or(false),
transform: base.text_transform.unwrap_or(TextTransform::None),
white_space: base.white_space.unwrap_or_default(),
overflow_wrap: base.overflow_wrap.unwrap_or_default(),
@@ -2172,6 +2201,8 @@ fn collect_node_spans(
italic: ctx.italic,
synthetic_italic: ctx.synthetic_italic,
underline: ctx.underline,
overline: ctx.overline,
line_through: ctx.line_through,
color: ctx.color,
family: Arc::clone(&ctx.family),
clip_fill: ctx.clip_fill,
@@ -2204,6 +2235,8 @@ fn collect_node_spans(
italic: ctx.italic,
synthetic_italic: ctx.synthetic_italic,
underline: ctx.underline,
overline: ctx.overline,
line_through: ctx.line_through,
color: ctx.color,
family: Arc::clone(&ctx.family),
clip_fill: ctx.clip_fill,
@@ -2282,6 +2315,8 @@ fn collect_node_spans(
// Underline propagates in: an ancestor's underline covers
// descendant text; an element only sets its own via CSS.
underline: ctx.underline || style.and_then(|s| s.underline).unwrap_or(false),
overline: ctx.overline || style.and_then(|style| style.overline).unwrap_or(false),
line_through: ctx.line_through || style.and_then(|style| style.line_through).unwrap_or(false),
transform: style
.and_then(|s| s.text_transform)
.unwrap_or(ctx.transform),
@@ -3175,10 +3210,6 @@ impl TextEngine {
.map(|source| source_line_starts(&item.buffer, source))
.unwrap_or_default();
// Collect underline segments before drawing glyphs (both borrow the
// buffer). Underline is carried per glyph via metadata; group runs of
// consecutive underlined glyphs on a line into one stroke below the
// baseline. Done first so the draw() mutable borrow does not overlap.
let mut underlines: Vec<(f32, f32, f32, f32, [u8; 4])> = Vec::new(); // x0, x1, y, thickness, color
let mut fill_bounds: Vec<Option<(f32, f32, f32, f32)>> = vec![None; item.clip_fills.len()];
for (line_index, run) in item.buffer.layout_runs().enumerate() {
@@ -3192,7 +3223,8 @@ impl TextEngine {
let inline_alignment =
line_edge_alignment_shift(item, line_source_start, line_source_end);
let base_y = run.line_y;
let mut seg: Option<(f32, f32, f32, [u8; 4], (f32, f32))> = None;
let mut segments: [Option<(f32, f32, f32, [u8; 4], (f32, f32))>; 3] = [None; 3];
let mut active_decorations = 0;
for g in run.glyphs {
// Keep decoration and background-clip bounds in lockstep with
// glyph painting. A truncated glyph must not leave an
@@ -3216,7 +3248,7 @@ impl TextEngine {
line_source_start,
line_source_end,
);
let underlined = g.metadata & META_UNDERLINE != 0;
let decoration_flags = g.metadata & META_DECORATIONS;
if let Some(fill_index) = metadata_fill(g.metadata) {
if let Some(bounds) = fill_bounds.get_mut(fill_index) {
let glyph_bounds = (
@@ -3243,51 +3275,42 @@ impl TextEngine {
if print_economy {
col = crate::paint::print_economy_color(col);
}
if underlined {
match &mut seg {
Some((_, x1, fs, c, prior_relative))
if *c == col && *prior_relative == relative =>
{
*x1 = g.x + line_offset + g.w + relative.0;
*fs = fs.max(g.font_size);
}
_ => {
if let Some((x0, x1, fs, c, prior_relative)) = seg.take() {
underlines.push((
x0,
x1,
base_y + prior_relative.1 + (fs * 0.12).max(1.0),
(fs / 14.0).max(1.0),
c,
if decoration_flags | active_decorations == 0 {
continue;
}
active_decorations = decoration_flags;
for (index, flag) in [META_UNDERLINE, META_OVERLINE, META_LINE_THROUGH].into_iter().enumerate() {
let segment = &mut segments[index];
if decoration_flags & flag != 0 {
match segment {
Some((_, end, size, color, prior_relative))
if *color == col && *prior_relative == relative =>
{
*end = g.x + line_offset + g.w + relative.0;
*size = size.max(g.font_size);
}
_ => {
if let Some(previous) = segment.take() {
underlines.push(decoration_stroke(previous, base_y, flag));
}
*segment = Some((
g.x + line_offset + relative.0,
g.x + line_offset + g.w + relative.0,
g.font_size,
col,
relative,
));
}
seg = Some((
g.x + line_offset + relative.0,
g.x + line_offset + g.w + relative.0,
g.font_size,
col,
relative,
));
}
} else if let Some(previous) = segment.take() {
underlines.push(decoration_stroke(previous, base_y, flag));
}
} else if let Some((x0, x1, fs, c, relative)) = seg.take() {
underlines.push((
x0,
x1,
base_y + relative.1 + (fs * 0.12).max(1.0),
(fs / 14.0).max(1.0),
c,
));
}
}
if let Some((x0, x1, fs, c, relative)) = seg.take() {
underlines.push((
x0,
x1,
base_y + relative.1 + (fs * 0.12).max(1.0),
(fs / 14.0).max(1.0),
c,
));
for (segment, flag) in segments.into_iter().zip([META_UNDERLINE, META_OVERLINE, META_LINE_THROUGH]) {
if let Some(previous) = segment {
underlines.push(decoration_stroke(previous, base_y, flag));
}
}
}
@@ -4126,6 +4149,8 @@ mod tests {
italic: false,
synthetic_italic: false,
underline: true,
overline: true,
line_through: true,
color: [1, 2, 3, 255],
family: Arc::from(FAMILY),
clip_fill: Some(37),
@@ -4134,7 +4159,7 @@ mod tests {
word_break: crate::WordBreak::Normal,
};
let shaped = attrs.to_attrs(42);
assert_ne!(shaped.metadata & META_UNDERLINE, 0);
assert_eq!(shaped.metadata & META_DECORATIONS, META_DECORATIONS);
assert_eq!(metadata_fill(shaped.metadata), Some(37));
assert_eq!(metadata_variation(shaped.metadata), Some(41));
assert_eq!(
@@ -4206,6 +4231,8 @@ mod tests {
italic: false,
synthetic_italic: false,
underline: false,
overline: false,
line_through: false,
color: [0, 0, 0, 255],
family: Arc::from(FAMILY),
clip_fill: None,

View File

@@ -1553,6 +1553,8 @@ pub struct LayoutStyle {
/// text, so it is propagated into the shaped spans of the element's subtree
/// (this is what underlines links, which are underlined by UA default).
pub underline: Option<bool>,
pub overline: Option<bool>,
pub line_through: Option<bool>,
/// `font-style: italic|oblique`. Inherited. Selects an available oblique
/// face when shaping; the bundled Linux `system-ui` face synthesizes its

View File

@@ -1730,8 +1730,6 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
});
}
"text-decoration" | "text-decoration-line" => {
// Shorthand can carry color/style/thickness; we only model the
// underline line (the dominant case, and the UA default for links).
let toks: Vec<String> = value
.split_whitespace()
.map(|t| t.to_ascii_lowercase())
@@ -1739,6 +1737,8 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
let underline = toks.iter().any(|t| t == "underline");
let none = toks.iter().any(|t| t == "none");
style.underline = Some(underline && !none);
style.overline = Some(!none && toks.iter().any(|token| token == "overline"));
style.line_through = Some(!none && toks.iter().any(|token| token == "line-through"));
}
"gap" | "grid-gap" => {
let values = split_ws_paren(value);
@@ -3036,7 +3036,7 @@ fn supports_conservative_known_value(name: &str, value: &str) -> bool {
),
"text-decoration" | "text-decoration-line" => lower
.split_whitespace()
.all(|token| matches!(token, "none" | "underline")),
.all(|token| matches!(token, "none" | "underline" | "overline" | "line-through")),
"line-height" => lower == "normal" || finite_number(value) || dimension(value, false),
"gap" | "grid-gap" => dimensions(value, false, 2),
"row-gap" | "grid-row-gap" | "column-gap" | "grid-column-gap" | "-webkit-column-gap" => {

View File

@@ -0,0 +1,96 @@
#![cfg(feature = "paint")]
use obscura_dom::parse_html;
use obscura_render::paint_dom;
fn render(markup: &str) -> tiny_skia::Pixmap {
let tree = parse_html(&format!(
"<style>body{{margin:8px;font:32px/48px sans-serif;color:green}}</style>{markup}"
));
paint_dom(&tree, (320.0, 160.0), None).unwrap()
}
fn decorated(lines: &str) -> tiny_skia::Pixmap {
render(&format!(
"<span style='text-decoration:{lines}'>MMMM</span>"
))
}
fn changed_rows(baseline: &tiny_skia::Pixmap, actual: &tiny_skia::Pixmap) -> Vec<usize> {
baseline
.pixels()
.chunks(320)
.zip(actual.pixels().chunks(320))
.enumerate()
.filter_map(|(row, (before, after))| (before != after).then_some(row))
.collect()
}
#[test]
fn all_three_text_decoration_lines_paint_in_distinct_vertical_bands() {
let baseline = decorated("none");
let overline = changed_rows(&baseline, &decorated("overline"));
let strike = changed_rows(&baseline, &decorated("line-through"));
let underline = changed_rows(&baseline, &decorated("underline"));
assert!(!overline.is_empty(), "overline must paint");
assert!(!strike.is_empty(), "line-through must paint");
assert!(!underline.is_empty(), "underline must paint");
assert!(overline.last().unwrap() < strike.first().unwrap());
assert!(strike.last().unwrap() < underline.first().unwrap());
let longhand = render("<span style='text-decoration-line:overline line-through'>MMMM</span>");
assert_eq!(longhand.data(), decorated("overline line-through").data());
}
#[test]
fn combined_text_decoration_lines_preserve_each_individual_stroke() {
let baseline = decorated("none");
let individual = [
decorated("underline"),
decorated("overline"),
decorated("line-through"),
];
let combined = decorated("underline overline line-through");
for (index, expected) in combined.pixels().iter().enumerate() {
let painted = individual
.iter()
.find_map(|image| {
(image.pixels()[index] != baseline.pixels()[index]).then_some(image.pixels()[index])
})
.unwrap_or(baseline.pixels()[index]);
assert_eq!(*expected, painted, "pixel {index}");
}
}
#[test]
fn text_decoration_propagates_through_inline_descendants_and_none_resets_own_lines() {
let nested = render("<span style='text-decoration:line-through'><span style='text-decoration:none'>MMMM</span></span>");
assert_eq!(nested.data(), decorated("line-through").data());
let combined = render("<span style='text-decoration:overline'><span style='text-decoration:line-through'>MMMM</span></span>");
assert_eq!(combined.data(), decorated("overline line-through").data());
let reset = render(
"<span style='text-decoration:overline line-through;text-decoration:none'>MMMM</span>",
);
assert_eq!(reset.data(), decorated("none").data());
}
#[test]
fn text_decoration_respects_transparency_and_wrapped_lines() {
let transparent = render("<span style='color:transparent;text-decoration:underline overline line-through'>MMMM</span>");
let empty = render("");
assert_eq!(transparent.data(), empty.data());
let plain = render("<div style='width:90px'>MMMM MMMM</div>");
for lines in ["overline", "line-through"] {
let wrapped = render(&format!(
"<div style='width:90px;text-decoration:{lines}'>MMMM MMMM</div>"
));
let rows = changed_rows(&plain, &wrapped);
assert!(rows.iter().any(|row| *row < 56), "first line {lines}");
assert!(rows.iter().any(|row| *row >= 56), "second line {lines}");
}
}
#[test]
fn supports_accepts_the_implemented_text_decoration_lines() {
let result = render("<span>MMMM</span><style>@supports (text-decoration-line: overline line-through) {span{text-decoration:overline line-through}}</style>");
assert_eq!(result.data(), decorated("overline line-through").data());
}

View File

@@ -0,0 +1,19 @@
<!doctype html>
<meta charset="utf-8">
<title>Text decoration lines</title>
<style>
body { margin: 24px; font: 28px/1.8 sans-serif; color: #19324b; }
.sample { margin: 12px 0; }
.underline { text-decoration: underline; }
.overline { text-decoration: overline; }
.strike { text-decoration: line-through; }
.combined { text-decoration: underline overline line-through; }
.wrapped { width: 260px; text-decoration: overline line-through; }
</style>
<div class="sample">Plain text control</div>
<div class="sample underline">Underline unchanged</div>
<div class="sample overline">Overline visible</div>
<div class="sample strike">Line-through visible</div>
<div class="sample combined">Three lines together</div>
<div class="sample strike">Nested <span style="text-decoration:none">inline text</span></div>
<div class="sample wrapped">Decorations remain visible on wrapped text</div>