LibWeb: Use advance width for canvas text alignment

Previously, `text_path() used the glyph outline bounding box width to
compute text alignment offsets and maxWidth scaling. The bounding box
only covers visible glyph outlines, excluding the advance of spaces and
glyph side bearings. This caused text with `textAlign` "end", "right",
or "center" to be mispositioned. We now use the sum of glyph run
advance widths instead.
This commit is contained in:
Tim Ledbetter 2026-05-27 03:58:29 +01:00 committed by Andreas Kling
parent 8039419775
commit 3049d77979
12 changed files with 265 additions and 2 deletions

View file

@ -311,11 +311,11 @@ Gfx::Path CanvasRenderingContext2D::text_path(Utf16String const& text, float x,
auto glyph_runs = Gfx::shape_text({ x, y }, text.utf16_view(), *font_cascade_list, auto glyph_runs = Gfx::shape_text({ x, y }, text.utf16_view(), *font_cascade_list,
resolved_letter_spacing(drawing_state, canvas_element())); resolved_letter_spacing(drawing_state, canvas_element()));
Gfx::Path path; Gfx::Path path;
float text_width = 0;
for (auto const& glyph_run : glyph_runs) { for (auto const& glyph_run : glyph_runs) {
path.glyph_run(glyph_run); path.glyph_run(glyph_run);
text_width += glyph_run->width();
} }
auto text_width = path.bounding_box().width();
Gfx::AffineTransform transform = {}; Gfx::AffineTransform transform = {};
// https://html.spec.whatwg.org/multipage/canvas.html#text-preparation-algorithm: // https://html.spec.whatwg.org/multipage/canvas.html#text-preparation-algorithm:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

View file

@ -0,0 +1,6 @@
Harness status: OK
Found 1 tests
1 Pass
Pass textAlign center is the center of the em squares (not the bounding box)

View file

@ -0,0 +1,6 @@
Harness status: OK
Found 1 tests
1 Pass
Pass textAlign end with ltr is the right edge

View file

@ -0,0 +1,6 @@
Harness status: OK
Found 1 tests
1 Pass
Pass textAlign right is the right of the last em square (not the bounding box)

View file

@ -0,0 +1,6 @@
Harness status: OK
Found 1 tests
1 Pass
Pass fillText handles maxWidth based on line size, not bounding box size

View file

@ -0,0 +1,6 @@
Harness status: OK
Found 1 tests
1 Pass
Pass Space characters at the end of a line are NOT collapsed

View file

@ -0,0 +1,48 @@
<!DOCTYPE html>
<!-- DO NOT EDIT! This test has been generated by /html/canvas/tools/gentest.py. -->
<meta charset="UTF-8">
<title>Canvas test: 2d.text.draw.align.center</title>
<script src="../../../../resources/testharness.js"></script>
<script src="../../../../resources/testharnessreport.js"></script>
<script src="../../../../html/canvas/resources/canvas-tests.js"></script>
<link rel="stylesheet" href="../../../../html/canvas/resources/canvas-tests.css">
<style>
@font-face {
font-family: CanvasTest;
src: url("../../../../fonts/CanvasTest.ttf");
}
</style>
<body class="show_output">
<h1>2d.text.draw.align.center</h1>
<p class="desc">textAlign center is the center of the em squares (not the bounding box)</p>
<span style="font-family: CanvasTest; position: absolute; visibility: hidden">A</span>
<p class="output">Actual output:</p>
<canvas id="c" class="output" width="100" height="50"><p class="fallback">FAIL (fallback content)</p></canvas>
<p class="output expectedtext">Expected output:<p><img src="../../../../images/green-100x50.png" class="output expected" id="expected" alt="">
<ul id="d"></ul>
<script>
promise_test(async t => {
var canvas = document.getElementById('c');
var ctx = canvas.getContext('2d');
await document.fonts.ready;
ctx.font = '50px CanvasTest';
ctx.fillStyle = '#f00';
ctx.fillRect(0, 0, 100, 50);
ctx.fillStyle = '#0f0';
ctx.textAlign = 'center';
ctx.fillText('DD', 50, 37.5);
_assertPixelApprox(canvas, 5,5, 0,255,0,255, 2);
_assertPixelApprox(canvas, 95,5, 0,255,0,255, 2);
_assertPixelApprox(canvas, 25,25, 0,255,0,255, 2);
_assertPixelApprox(canvas, 75,25, 0,255,0,255, 2);
_assertPixelApprox(canvas, 5,45, 0,255,0,255, 2);
_assertPixelApprox(canvas, 95,45, 0,255,0,255, 2);
}, "textAlign center is the center of the em squares (not the bounding box)");
</script>

View file

@ -0,0 +1,48 @@
<!DOCTYPE html>
<!-- DO NOT EDIT! This test has been generated by /html/canvas/tools/gentest.py. -->
<meta charset="UTF-8">
<title>Canvas test: 2d.text.draw.align.end.ltr</title>
<script src="../../../../resources/testharness.js"></script>
<script src="../../../../resources/testharnessreport.js"></script>
<script src="../../../../html/canvas/resources/canvas-tests.js"></script>
<link rel="stylesheet" href="../../../../html/canvas/resources/canvas-tests.css">
<style>
@font-face {
font-family: CanvasTest;
src: url("../../../../fonts/CanvasTest.ttf");
}
</style>
<body class="show_output">
<h1>2d.text.draw.align.end.ltr</h1>
<p class="desc">textAlign end with ltr is the right edge</p>
<span style="font-family: CanvasTest; position: absolute; visibility: hidden">A</span>
<p class="output">Actual output:</p>
<canvas id="c" class="output" width="100" height="50"dir="ltr"><p class="fallback">FAIL (fallback content)</p></canvas>
<p class="output expectedtext">Expected output:<p><img src="../../../../images/green-100x50.png" class="output expected" id="expected" alt="">
<ul id="d"></ul>
<script>
promise_test(async t => {
var canvas = document.getElementById('c');
var ctx = canvas.getContext('2d');
await document.fonts.ready;
ctx.font = '50px CanvasTest';
ctx.fillStyle = '#f00';
ctx.fillRect(0, 0, 100, 50);
ctx.fillStyle = '#0f0';
ctx.textAlign = 'end';
ctx.fillText('DD', 100, 37.5);
_assertPixelApprox(canvas, 5,5, 0,255,0,255, 2);
_assertPixelApprox(canvas, 95,5, 0,255,0,255, 2);
_assertPixelApprox(canvas, 25,25, 0,255,0,255, 2);
_assertPixelApprox(canvas, 75,25, 0,255,0,255, 2);
_assertPixelApprox(canvas, 5,45, 0,255,0,255, 2);
_assertPixelApprox(canvas, 95,45, 0,255,0,255, 2);
}, "textAlign end with ltr is the right edge");
</script>

View file

@ -0,0 +1,48 @@
<!DOCTYPE html>
<!-- DO NOT EDIT! This test has been generated by /html/canvas/tools/gentest.py. -->
<meta charset="UTF-8">
<title>Canvas test: 2d.text.draw.align.right</title>
<script src="../../../../resources/testharness.js"></script>
<script src="../../../../resources/testharnessreport.js"></script>
<script src="../../../../html/canvas/resources/canvas-tests.js"></script>
<link rel="stylesheet" href="../../../../html/canvas/resources/canvas-tests.css">
<style>
@font-face {
font-family: CanvasTest;
src: url("../../../../fonts/CanvasTest.ttf");
}
</style>
<body class="show_output">
<h1>2d.text.draw.align.right</h1>
<p class="desc">textAlign right is the right of the last em square (not the bounding box)</p>
<span style="font-family: CanvasTest; position: absolute; visibility: hidden">A</span>
<p class="output">Actual output:</p>
<canvas id="c" class="output" width="100" height="50"><p class="fallback">FAIL (fallback content)</p></canvas>
<p class="output expectedtext">Expected output:<p><img src="../../../../images/green-100x50.png" class="output expected" id="expected" alt="">
<ul id="d"></ul>
<script>
promise_test(async t => {
var canvas = document.getElementById('c');
var ctx = canvas.getContext('2d');
await document.fonts.ready;
ctx.font = '50px CanvasTest';
ctx.fillStyle = '#f00';
ctx.fillRect(0, 0, 100, 50);
ctx.fillStyle = '#0f0';
ctx.textAlign = 'right';
ctx.fillText('DD', 100, 37.5);
_assertPixelApprox(canvas, 5,5, 0,255,0,255, 2);
_assertPixelApprox(canvas, 95,5, 0,255,0,255, 2);
_assertPixelApprox(canvas, 25,25, 0,255,0,255, 2);
_assertPixelApprox(canvas, 75,25, 0,255,0,255, 2);
_assertPixelApprox(canvas, 5,45, 0,255,0,255, 2);
_assertPixelApprox(canvas, 95,45, 0,255,0,255, 2);
}, "textAlign right is the right of the last em square (not the bounding box)");
</script>

View file

@ -0,0 +1,45 @@
<!DOCTYPE html>
<!-- DO NOT EDIT! This test has been generated by /html/canvas/tools/gentest.py. -->
<meta charset="UTF-8">
<title>Canvas test: 2d.text.draw.fill.maxWidth.bound</title>
<script src="../../../../resources/testharness.js"></script>
<script src="../../../../resources/testharnessreport.js"></script>
<script src="../../../../html/canvas/resources/canvas-tests.js"></script>
<link rel="stylesheet" href="../../../../html/canvas/resources/canvas-tests.css">
<style>
@font-face {
font-family: CanvasTest;
src: url("../../../../fonts/CanvasTest.ttf");
}
</style>
<body class="show_output">
<h1>2d.text.draw.fill.maxWidth.bound</h1>
<p class="desc">fillText handles maxWidth based on line size, not bounding box size</p>
<span style="font-family: CanvasTest; position: absolute; visibility: hidden">A</span>
<p class="output">Actual output:</p>
<canvas id="c" class="output" width="100" height="50"><p class="fallback">FAIL (fallback content)</p></canvas>
<p class="output expectedtext">Expected output:<p><img src="../../../../images/green-100x50.png" class="output expected" id="expected" alt="">
<ul id="d"></ul>
<script>
promise_test(async t => {
var canvas = document.getElementById('c');
var ctx = canvas.getContext('2d');
await document.fonts.ready;
ctx.font = '50px CanvasTest';
ctx.fillStyle = '#f00';
ctx.fillRect(0, 0, 100, 50);
ctx.fillStyle = '#0f0';
ctx.fillText('DD', 0, 37.5, 100);
_assertPixelApprox(canvas, 5,5, 0,255,0,255, 2);
_assertPixelApprox(canvas, 95,5, 0,255,0,255, 2);
_assertPixelApprox(canvas, 25,25, 0,255,0,255, 2);
_assertPixelApprox(canvas, 75,25, 0,255,0,255, 2);
}, "fillText handles maxWidth based on line size, not bounding box size");
</script>

View file

@ -0,0 +1,44 @@
<!DOCTYPE html>
<!-- DO NOT EDIT! This test has been generated by /html/canvas/tools/gentest.py. -->
<meta charset="UTF-8">
<title>Canvas test: 2d.text.draw.space.collapse.end</title>
<script src="../../../../resources/testharness.js"></script>
<script src="../../../../resources/testharnessreport.js"></script>
<script src="../../../../html/canvas/resources/canvas-tests.js"></script>
<link rel="stylesheet" href="../../../../html/canvas/resources/canvas-tests.css">
<style>
@font-face {
font-family: CanvasTest;
src: url("../../../../fonts/CanvasTest.ttf");
}
</style>
<body class="show_output">
<h1>2d.text.draw.space.collapse.end</h1>
<p class="desc">Space characters at the end of a line are NOT collapsed</p>
<span style="font-family: CanvasTest; position: absolute; visibility: hidden">A</span>
<p class="output">Actual output:</p>
<canvas id="c" class="output" width="100" height="50"><p class="fallback">FAIL (fallback content)</p></canvas>
<p class="output expectedtext">Expected output:<p><img src="../../../../images/green-100x50.png" class="output expected" id="expected" alt="">
<ul id="d"></ul>
<script>
promise_test(async t => {
var canvas = document.getElementById('c');
var ctx = canvas.getContext('2d');
await document.fonts.ready;
ctx.font = '50px CanvasTest';
ctx.fillStyle = '#f00';
ctx.fillRect(0, 0, 100, 50);
ctx.fillStyle = '#0f0';
ctx.textAlign = 'right';
ctx.fillText('EE ', 100, 37.5);
_assertPixelApprox(canvas, 25,25, 0,255,0,255, 2);
_assertPixelApprox(canvas, 75,25, 255,0,0,255, 2);
}, "Space characters at the end of a line are NOT collapsed");
</script>