From 53c0e64cfe66620dcbb346af5e82ed38cd586ea0 Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Fri, 31 Jul 2026 03:04:10 +0000 Subject: [PATCH] Preserve authored alpha in shaped text --- crates/obscura-render/src/inline.rs | 68 +++++++++++++++++++++++++++-- crates/obscura-render/src/paint.rs | 64 +++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 3 deletions(-) diff --git a/crates/obscura-render/src/inline.rs b/crates/obscura-render/src/inline.rs index 7db3384..e83fbc8 100644 --- a/crates/obscura-render/src/inline.rs +++ b/crates/obscura-render/src/inline.rs @@ -1845,7 +1845,11 @@ impl TextEngine { let glyph_color = glyph.color_opt.unwrap_or(default); let fill_index = metadata_fill(glyph.metadata); let mut draw_pixel = |x, y, color: Color| { - let a = color.a() as u32; + // cosmic-text's mask rasterizer replaces the authored + // alpha with glyph coverage (see its `blend base alpha?` + // TODO). Reapply the span alpha here so transparent and + // translucent CSS text do not become opaque at paint. + let a = color.a() as u32 * glyph_color.a() as u32 / 255; if a == 0 { return; } @@ -1924,8 +1928,12 @@ impl TextEngine { } } - // Stroke the underline segments (opaque; text is already drawn above). + // Stroke underline segments with the same authored alpha as their + // glyphs. Transparent text decorations are transparent too. for (x0, x1, y, thick, col) in underlines { + if col[3] == 0 { + continue; + } let t = thick.max(1.0).round() as i32; for dt in 0..t { let py = oy as i32 + y as i32 + dt; @@ -1947,7 +1955,23 @@ impl TextEngine { } } let i = (py * pw + px) as usize; - pixels[i] = tiny_skia::PremultipliedColorU8::from_rgba(col[0], col[1], col[2], 255).unwrap_or(pixels[i]); + let dst = pixels[i]; + let sa = col[3] as u32; + let inv = 255 - sa; + let out_a = sa + dst.alpha() as u32 * inv / 255; + let out_r = col[0] as u32 * sa / 255 + + dst.red() as u32 * inv / 255; + let out_g = col[1] as u32 * sa / 255 + + dst.green() as u32 * inv / 255; + let out_b = col[2] as u32 * sa / 255 + + dst.blue() as u32 * inv / 255; + pixels[i] = tiny_skia::PremultipliedColorU8::from_rgba( + out_r as u8, + out_g as u8, + out_b as u8, + out_a as u8, + ) + .unwrap_or(dst); } } } @@ -2590,6 +2614,44 @@ mod tests { (geometry, ink, cached_weights) } + #[test] + fn shaped_text_preserves_authored_alpha_through_mask_rasterization() { + let render_alpha = |alpha: u8, underline: bool| { + let tree = obscura_dom::parse_html("

Alpha

"); + let copy = tree.get_element_by_id("copy").unwrap(); + let mut style = LayoutStyle::default(); + style.display = Display::Block; + style.font_size = Some(32.0); + style.line_height = Some(crate::LineHeight::Px(40.0)); + style.color = Some([0, 0, 0, alpha]); + style.underline = Some(underline); + let styles = HashMap::from([(copy, style)]); + let mut engine = TextEngine::new(); + let item = engine.try_build(&tree, copy, &styles).unwrap(); + engine.finalize(item, (0.0, 0.0), 160.0, None); + let mut pixmap = tiny_skia::Pixmap::new(160, 48).unwrap(); + engine.paint_item(item, &mut pixmap, (0.0, 0.0)); + pixmap + .pixels() + .iter() + .map(|pixel| u64::from(pixel.alpha())) + .sum::() + }; + + assert_eq!( + render_alpha(0, true), + 0, + "transparent glyphs and decorations must not paint" + ); + let half = render_alpha(128, false); + let opaque = render_alpha(255, false); + let ratio = half * 100 / opaque; + assert!( + (48..=52).contains(&ratio), + "50% CSS alpha must retain half the opaque coverage: half={half}, opaque={opaque}" + ); + } + #[test] fn variable_wght_changes_shape_and_true_outline() { let (regular_geometry, regular_ink, regular_cache) = render_variable_weight(400); diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index 535c5c9..b3f4f61 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -5667,6 +5667,70 @@ mod tests { assert_eq!(red_pixels, 0, "::before must not bleed through ::after"); } + #[test] + fn angular_absolute_primary_button_paints_one_generated_label() { + let tree = parse_html( + r#" +
+ +
+ "#, + ); + let laid = crate::layout_dom(&tree, (500.0, 300.0)); + let cta = tree.get_element_by_id("cta").unwrap(); + let rect = laid.rects[&cta]; + let style = &laid.styles[&cta]; + assert_eq!(style.color, Some([0, 0, 0, 0])); + let pixmap = paint_dom(&tree, (500.0, 300.0), None).expect("pixmap"); + let rows = (rect.y.floor() as u32..(rect.y + rect.height).ceil() as u32) + .map(|y| { + (rect.x.floor() as u32..(rect.x + rect.width).ceil() as u32) + .filter(|&x| { + let pixel = pixmap.pixel(x, y).unwrap(); + pixel.red() < 100 && pixel.green() < 100 && pixel.blue() < 100 + }) + .count() + }) + .collect::>(); + let ink_rows = rows + .iter() + .enumerate() + .filter_map(|(row, &ink)| (ink > 0).then_some(row)) + .collect::>(); + assert!( + ink_rows.last().unwrap() - ink_rows.first().unwrap() <= 14, + "only the generated label may paint; transparent host text leaked across rows {rows:?}" + ); + } + #[test] fn native_select_paints_only_the_selected_label_and_arrow() { let tree = parse_html(