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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" => {
|
||||
|
||||
96
crates/obscura-render/tests/text_decoration.rs
Normal file
96
crates/obscura-render/tests/text_decoration.rs
Normal 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());
|
||||
}
|
||||
19
render-repros/text-decoration-lines.html
Normal file
19
render-repros/text-decoration-lines.html
Normal 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>
|
||||
Reference in New Issue
Block a user