LibWeb: Honor transparent copy compositing in canvas

Do not skip fully transparent canvas fill and stroke styles when the
current compositing operator can still affect the destination bitmap.
TradingView uses transparent copy fills while repainting its chart
canvases, and skipping those draws left old translucent chart pixels in
place so later source-over draws accumulated on top.

Add text coverage for transparent copy replacing an existing translucent
pixel before another translucent draw.
This commit is contained in:
Andreas Kling 2026-05-23 16:17:30 +02:00 committed by Andreas Kling
parent 1835d739f5
commit 4c03a6f5fc
3 changed files with 37 additions and 2 deletions

View file

@ -440,6 +440,13 @@ static Gfx::Path::JoinStyle to_gfx_join(Bindings::CanvasLineJoin const& join_sty
VERIFY_NOT_REACHED();
}
static bool transparent_source_paint_can_be_ignored(Gfx::CompositingAndBlendingOperator compositing_and_blending_operator)
{
// https://html.spec.whatwg.org/multipage/canvas.html#drawing-model
// Composite B within the clipping region over the current output bitmap using the current compositing and blending operator.
return compositing_and_blending_operator == Gfx::CompositingAndBlendingOperator::SourceOver;
}
// https://html.spec.whatwg.org/multipage/canvas.html#the-canvas-settings:concept-canvas-alpha
Gfx::Color CanvasRenderingContext2D::clear_color() const
{
@ -454,7 +461,7 @@ void CanvasRenderingContext2D::stroke_internal(Gfx::Path const& path)
auto& state = drawing_state();
auto paint_style = state.stroke_style.to_gfx_paint_style();
if (!paint_style->is_visible())
if (!paint_style->is_visible() && transparent_source_paint_can_be_ignored(state.current_compositing_and_blending_operator))
return;
auto line_cap = to_gfx_cap(state.line_cap);
@ -500,7 +507,7 @@ void CanvasRenderingContext2D::fill_internal(Gfx::Path const& path, Gfx::Winding
auto& state = this->drawing_state();
auto paint_style = state.fill_style.to_gfx_paint_style();
if (!paint_style->is_visible())
if (!paint_style->is_visible() && transparent_source_paint_can_be_ignored(state.current_compositing_and_blending_operator))
return;
paint_shadow_for_fill_internal(path, winding_rule);

View file

@ -0,0 +1,3 @@
first draw: 255,0,0,51
transparent copy: 0,0,0,0
second draw: 255,0,0,51

View file

@ -0,0 +1,25 @@
<!DOCTYPE html>
<script src="../include.js"></script>
<script>
test(() => {
const canvas = document.createElement("canvas");
canvas.width = 1;
canvas.height = 1;
const context = canvas.getContext("2d");
context.fillStyle = "rgba(255, 0, 0, 0.2)";
context.fillRect(0, 0, 1, 1);
println(`first draw: ${Array.from(context.getImageData(0, 0, 1, 1).data).join(",")}`);
context.globalCompositeOperation = "copy";
context.fillStyle = "rgba(0, 0, 0, 0)";
context.fillRect(0, 0, 1, 1);
println(`transparent copy: ${Array.from(context.getImageData(0, 0, 1, 1).data).join(",")}`);
context.globalCompositeOperation = "source-over";
context.fillStyle = "rgba(255, 0, 0, 0.2)";
context.fillRect(0, 0, 1, 1);
println(`second draw: ${Array.from(context.getImageData(0, 0, 1, 1).data).join(",")}`);
});
</script>