LibWeb: Use glyph positions & widths instead of Harfbuzz for range rects

We use Harfbuzz to lay out glyphs at their right positions, but we
should not do so for substrings as we were doing for selection/range
rects: their positioning is dependent on their full context to properly
determine kerning and ligatures, for example.

Instead, use the stored glyph positions & widths directly to calculate
the range rect for a partial fragment selection. This makes the
selection rect visually stable - it no longer jitters when expanding the
selection character by character.
This commit is contained in:
Jelle Raaijmakers 2026-03-02 13:07:07 +01:00 committed by Andreas Kling
parent 4a596f1f49
commit e99ff65a58
6 changed files with 72 additions and 8 deletions

View file

@ -119,21 +119,32 @@ CSSPixelRect PaintableFragment::range_rect(Paintable::SelectionState selection_s
pixel_offset = 0;
pixel_width = rect.primary_size_for_orientation(orientation());
} else {
auto letter_spacing = layout_node().computed_values().letter_spacing().to_float();
pixel_offset = CSSPixels { Gfx::measure_text_width(text().substring_view(0, offsets->start), font, letter_spacing) };
float offset_accumulator = 0.f;
float width_accumulator = 0.f;
// When start equals end, this is a cursor position.
if (offsets->start == offsets->end) {
pixel_width = 1;
} else {
pixel_width = CSSPixels { Gfx::measure_text_width(text().substring_view(offsets->start, offsets->end - offsets->start), font, letter_spacing) };
if (m_glyph_run) {
size_t code_units_seen = 0;
for (auto const& glyph : m_glyph_run->glyphs()) {
if (code_units_seen < offsets->start)
offset_accumulator += glyph.glyph_width;
else if (code_units_seen < offsets->end)
width_accumulator += glyph.glyph_width;
code_units_seen += glyph.length_in_code_units;
}
}
pixel_offset = CSSPixels { offset_accumulator };
pixel_width = CSSPixels { width_accumulator };
}
// When start equals end, this is a cursor position.
if (offsets->start == offsets->end)
pixel_width = 1;
// Include an additional space at the end if we remembered that this fragment contained trailing whitespace. This
// shows the user that at least one whitespace character was present when selecting text, even though we don't store
// that whitespace in the glyph run or text fragment.
if (m_has_trailing_whitespace && offsets->include_trailing_whitespace && offsets->start != offsets->end)
if (offsets->start != offsets->end && m_has_trailing_whitespace && offsets->include_trailing_whitespace)
pixel_width += CSSPixels { font.glyph_width(' ') };
rect.translate_primary_offset_for_orientation(orientation(), pixel_offset);

Binary file not shown.

View file

@ -1,3 +1,4 @@
Lato-Bold.ttf: Copyright (c) 2010-2011 by tyPoland Lukasz Dziedzic (team@latofonts.com) with Reserved Font Name "Lato".
NotoEmoji.ttf: Copyright 2013 Google LLC.
NotoSansEthiopic.woff2: Copyright 2013 Google LLC.

View file

@ -2,6 +2,9 @@
; Cookies require HTTP(s) scheme.
Text/input/cookie-working.html
; CSS @font-face url() requires HTTP(s) scheme.
Text/input/selection-rect-consistency-with-kerning.html
; Bug in ladybird - crashes due to AD-HOC fetch implementation in SVGScriptElement (due to opaque file origin).
; Works in other browsers when loaded from file://.
Text/input/wpt-import/html/syntax/parsing/unclosed-svg-script.html

View file

@ -0,0 +1,5 @@
char widths: 22.703125 25.125 27.359375 13.015625 25.65625
char sum: 113.859375
full width: 113.859375
sum == full: true
T.right == a.left: true

View file

@ -0,0 +1,44 @@
<!DOCTYPE html>
<script src="include.js"></script>
<style>
@font-face {
font-family: LatoBold;
src: url("../../Assets/Lato-Bold.ttf");
}
p {
font: 48px LatoBold;
margin: 0;
}
</style>
<p>Table</p>
<script>
asyncTest(async done => {
await document.fonts.ready;
const text = document.querySelector("p").firstChild;
function rangeRect(start, end) {
const range = document.createRange();
range.setStart(text, start);
range.setEnd(text, end);
return range.getBoundingClientRect();
}
const full = rangeRect(0, 5);
const r01 = rangeRect(0, 1);
const r12 = rangeRect(1, 2);
const r23 = rangeRect(2, 3);
const r34 = rangeRect(3, 4);
const r45 = rangeRect(4, 5);
const charSum = r01.width + r12.width + r23.width + r34.width + r45.width;
println(`char widths: ${r01.width} ${r12.width} ${r23.width} ${r34.width} ${r45.width}`);
println(`char sum: ${charSum}`);
println(`full width: ${full.width}`);
println(`sum == full: ${charSum === full.width}`);
println(`T.right == a.left: ${r01.right === r12.left}`);
done();
});
</script>