Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.

Commit dc2505a

Browse files
Handle broken graphemes
1 parent cbf8657 commit dc2505a

File tree

6 files changed

+301
-119
lines changed

6 files changed

+301
-119
lines changed

lib/web_ui/lib/src/engine/text/canvas_paragraph.dart

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -214,15 +214,20 @@ class CanvasParagraph implements ui.Paragraph {
214214
if (lineNumber == null) {
215215
return null;
216216
}
217-
final List<LayoutFragment> lineFragments = lines[lineNumber].fragments;
218-
for (final LayoutFragment fragment in lineFragments) {
219-
if (fragment.overlapsWith(codeUnitOffset, codeUnitOffset + 1)) {
220-
final ui.TextRange? range = fragment.getCharacterRangeAt(codeUnitOffset);
221-
assert(range != null);
222-
if (range != null) {
223-
final ui.TextBox box = fragment.toTextBox(start: range.start, end: range.end);
224-
return ui.GlyphInfo(box.toRect(), range, box.direction);
225-
}
217+
final ParagraphLine line = lines[lineNumber];
218+
final ui.TextRange? range = line.getCharacterRangeAt(codeUnitOffset);
219+
if (range == null) {
220+
return null;
221+
}
222+
assert(line.overlapsWith(range.start, range.end));
223+
for (final LayoutFragment fragment in line.fragments) {
224+
if (fragment.overlapsWith(range.start, range.end)) {
225+
// If the grapheme cluster is split into multiple fragments (which really
226+
// shouldn't happen but currently if they are in different TextSpans they
227+
// don't combine), use the layout box of the first base character as its
228+
// layout box has a better chance to be not that far-off.
229+
final ui.TextBox textBox = fragment.toTextBox(start: range.start, end: range.end);
230+
return ui.GlyphInfo(textBox.toRect(), range, textBox.direction);
226231
}
227232
}
228233
assert(false, 'This should not be reachable.');
@@ -273,11 +278,18 @@ class CanvasParagraph implements ui.Paragraph {
273278
int? getLineNumberAt(int codeUnitOffset) => _findLine(codeUnitOffset, 0, lines.length);
274279

275280
int? _findLine(int codeUnitOffset, int startLine, int endLine) {
276-
if (endLine <= startLine || codeUnitOffset < lines[startLine].startIndex || lines[endLine - 1].endIndex <= codeUnitOffset) {
281+
assert(endLine <= lines.length);
282+
final bool isOutOfBounds = endLine <= startLine
283+
|| codeUnitOffset < lines[startLine].startIndex
284+
|| (endLine < numberOfLines && lines[endLine].startIndex <= codeUnitOffset);
285+
if (isOutOfBounds) {
277286
return null;
278287
}
288+
279289
if (endLine == startLine + 1) {
280-
return startLine;
290+
assert(lines[startLine].startIndex <= codeUnitOffset);
291+
assert(endLine == numberOfLines || codeUnitOffset < lines[endLine].startIndex);
292+
return codeUnitOffset >= lines[startLine].visibleEndIndex ? null : startLine;
281293
}
282294
// endLine >= startLine + 2 thus we have
283295
// startLine + 1 <= midIndex <= endLine - 1

lib/web_ui/lib/src/engine/text/layout_fragmenter.dart

Lines changed: 58 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,8 @@
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
44

5-
import 'dart:js_interop';
65
import 'dart:math' as math;
76

8-
import 'package:ui/src/engine/dom.dart';
97
import 'package:ui/ui.dart' as ui;
108

119
import '../util.dart';
@@ -116,7 +114,7 @@ abstract class _CombinedFragment extends TextFragment {
116114

117115
final LineBreakType type;
118116

119-
ui.TextDirection? get textDirection => _textDirection;
117+
ui.TextDirection get textDirection => _textDirection!;
120118
ui.TextDirection? _textDirection;
121119

122120
final FragmentFlow fragmentFlow;
@@ -207,7 +205,7 @@ class LayoutFragment extends _CombinedFragment with _FragmentMetrics, _FragmentP
207205
start,
208206
index,
209207
LineBreakType.prohibited,
210-
textDirection,
208+
_textDirection,
211209
fragmentFlow,
212210
span,
213211
trailingNewlines: trailingNewlines - secondTrailingNewlines,
@@ -217,7 +215,7 @@ class LayoutFragment extends _CombinedFragment with _FragmentMetrics, _FragmentP
217215
index,
218216
end,
219217
type,
220-
textDirection,
218+
_textDirection,
221219
fragmentFlow,
222220
span,
223221
trailingNewlines: secondTrailingNewlines,
@@ -367,7 +365,7 @@ mixin _FragmentBox on _CombinedFragment, _FragmentMetrics, _FragmentPosition {
367365
top,
368366
line.left + right,
369367
bottom,
370-
textDirection!,
368+
textDirection,
371369
);
372370

373371
/// Whether or not the trailing spaces of this fragment are part of trailing
@@ -384,20 +382,20 @@ mixin _FragmentBox on _CombinedFragment, _FragmentMetrics, _FragmentPosition {
384382
ui.TextBox toPaintingTextBox() {
385383
if (_isPartOfTrailingSpacesInLine) {
386384
// For painting, we exclude the width of trailing spaces from the box.
387-
return textDirection! == ui.TextDirection.ltr
385+
return textDirection == ui.TextDirection.ltr
388386
? ui.TextBox.fromLTRBD(
389387
line.left + left,
390388
top,
391389
line.left + right - widthOfTrailingSpaces,
392390
bottom,
393-
textDirection!,
391+
textDirection,
394392
)
395393
: ui.TextBox.fromLTRBD(
396394
line.left + left + widthOfTrailingSpaces,
397395
top,
398396
line.left + right,
399397
bottom,
400-
textDirection!,
398+
textDirection,
401399
);
402400
}
403401
return _textBoxIncludingTrailingSpaces;
@@ -449,7 +447,7 @@ mixin _FragmentBox on _CombinedFragment, _FragmentMetrics, _FragmentPosition {
449447
}
450448

451449
final double left, right;
452-
if (textDirection! == ui.TextDirection.ltr) {
450+
if (textDirection == ui.TextDirection.ltr) {
453451
// Example: let's say the text is "Loremipsum" and we want to get the box
454452
// for "rem". In this case, `before` is the width of "Lo", and `after`
455453
// is the width of "ipsum".
@@ -490,7 +488,7 @@ mixin _FragmentBox on _CombinedFragment, _FragmentMetrics, _FragmentPosition {
490488
top,
491489
line.left + right,
492490
bottom,
493-
textDirection!,
491+
textDirection,
494492
);
495493
}
496494

@@ -594,57 +592,49 @@ mixin _FragmentBox on _CombinedFragment, _FragmentMetrics, _FragmentPosition {
594592
return x;
595593
}
596594

597-
// This is the fallback graphme breaker that is only used if Intl.Segmenter()
598-
// is not supported so _fromDomSegmenter can't be called. This implementation
599-
// breaks the text into UTF-16 codepoints instead of graphme clusters.
600-
List<int> _fallbackGraphemeStartIterable(String fragmentText) {
601-
final List<int> graphemeStarts = <int>[];
602-
bool precededByHighSurrogate = false;
603-
for (int i = 0; i < fragmentText.length; i++) {
604-
final int maskedCodeUnit = fragmentText.codeUnitAt(i) & 0xFC00;
605-
// Only skip `i` if it points to a low surrogate in a valid surrogate pair.
606-
if (maskedCodeUnit != 0xDC00 || !precededByHighSurrogate) {
607-
graphemeStarts.add(start + i);
608-
}
609-
precededByHighSurrogate = maskedCodeUnit == 0xD800;
610-
}
611-
return graphemeStarts;
612-
}
613-
614-
// This will be called at most once to lazily populate _graphemeStarts.
615-
List<int> _fromDomSegmenter(String fragmentText) {
616-
final DomSegmenter domSegmenter = DomSegmenter(
617-
<JSAny?>[].toJS,
618-
<String, String>{'granularity': 'grapheme'}.toJSAnyDeep,
619-
);
620-
final List<int> graphemeStarts = <int>[];
621-
final Iterator<DomSegment> segments = domSegmenter.segment(fragmentText).iterator();
622-
while (segments.moveNext()) {
623-
graphemeStarts.add(segments.current.index + start);
595+
// [start, end).map((index) => line.graphemeStarts[index]) gives an ascending
596+
// list of UTF16 offsets of graphemes that start in this fragment.
597+
//
598+
// Returns null if this fragment contains no grapheme starts.
599+
late final (int, int)? graphemeStartIndexRange = _getBreaksRange();
600+
(int, int)? _getBreaksRange() {
601+
if (end == start) {
602+
return null;
624603
}
625-
assert(graphemeStarts.isEmpty || graphemeStarts.first == start);
626-
return graphemeStarts;
604+
final List<int> lineGraphemeBreaks = line.graphemeStarts;
605+
assert(end > start);
606+
assert(line.graphemeStarts.isNotEmpty);
607+
final int startIndex = line.graphemeStartIndexBefore(start, 0, lineGraphemeBreaks.length);
608+
final int endIndex = end == start + 1
609+
? startIndex + 1
610+
: line.graphemeStartIndexBefore(end - 1, startIndex, lineGraphemeBreaks.length) + 1;
611+
final int firstGraphemeStart = lineGraphemeBreaks[startIndex];
612+
return firstGraphemeStart > start
613+
? (endIndex == startIndex + 1 ? null : (startIndex + 1, endIndex))
614+
: (startIndex, endIndex);
627615
}
628616

629-
// This List contains an ascending sequence of UTF16 offsets that points to
630-
// grapheme starts within the fragment. Each UTF16 offset is relative to the
631-
// start of the paragraph, instead of the start of the fragment.
632-
late final List<int> _graphemeStarts = _breakFragmentText(
633-
_spanometer.paragraph.plainText.substring(start, end),
634-
);
635-
List<int> _breakFragmentText(String text) {
636-
final List<int> graphemeStarts = domIntl.Segmenter == null ? _fallbackGraphemeStartIterable(text) : _fromDomSegmenter(text);
637-
// Add the end index of the fragment to the list if the text is not empty.
638-
if (graphemeStarts.isNotEmpty) {
639-
graphemeStarts.add(end);
640-
}
641-
return graphemeStarts;
617+
/// Whether the first codepoints of this fragment is not a valid grapheme start,
618+
/// and belongs in the the previous fragment.
619+
///
620+
/// This is the result of a known bug: in rare circumstances, a grapheme is
621+
/// split into different fragments. To workaround this we ignore the trailing
622+
/// part of the grapheme during hit-testing, by adjusting the leading offset of
623+
/// a fragment to the leading edge of the first grapheme start in that fragment.
624+
//
625+
// TODO(LongCatIsLooong): Grapheme clusters should not be separately even
626+
// when they are in different runs. Also document the recommendation to use
627+
// U+25CC or U+00A0 for showing nonspacing marks in isolation.
628+
bool get hasLeadingBrokenGrapheme {
629+
final int? graphemeStartIndexRangeStart = graphemeStartIndexRange?.$1;
630+
return graphemeStartIndexRangeStart == null || line.graphemeStarts[graphemeStartIndexRangeStart] != start;
642631
}
643632

644-
// Returns the GlyphInfo within the range [_graphemeStarts[startIndex], _graphemeStarts[endIndex]),
645-
// that's visually closeset to the given horizontal offset `x` (in the paragraph's coordinates).
633+
/// Returns the GlyphInfo within the range [line.graphemeStarts[startIndex], line.graphemeStarts[endIndex]),
634+
/// that's visually closeset to the given horizontal offset `x` (in the paragraph's coordinates).
646635
ui.GlyphInfo _getClosestCharacterInRange(double x, int startIndex, int endIndex) {
647-
final ui.TextRange fullRange = ui.TextRange(start: _graphemeStarts[startIndex], end: _graphemeStarts[endIndex]);
636+
final List<int> graphemeStartIndices = line.graphemeStarts;
637+
final ui.TextRange fullRange = ui.TextRange(start: graphemeStartIndices[startIndex], end: graphemeStartIndices[endIndex]);
648638
final ui.TextBox fullBox = toTextBox(start: fullRange.start, end: fullRange.end);
649639
if (startIndex + 1 == endIndex) {
650640
return ui.GlyphInfo(fullBox.toRect(), fullRange, fullBox.direction);
@@ -678,53 +668,29 @@ mixin _FragmentBox on _CombinedFragment, _FragmentMetrics, _FragmentPosition {
678668
// there can only be one writing direction in the fragment.
679669
final ui.TextRange range = switch ((fullBox.direction, x <= left)) {
680670
(ui.TextDirection.ltr, true) || (ui.TextDirection.rtl, false) => ui.TextRange(
681-
start: _graphemeStarts[startIndex],
682-
end: _graphemeStarts[startIndex + 1],
671+
start: graphemeStartIndices[startIndex],
672+
end: graphemeStartIndices[startIndex + 1],
683673
),
684674
(ui.TextDirection.ltr, false) || (ui.TextDirection.rtl, true) => ui.TextRange(
685-
start: _graphemeStarts[endIndex - 1],
686-
end: _graphemeStarts[endIndex],
675+
start: graphemeStartIndices[endIndex - 1],
676+
end: graphemeStartIndices[endIndex],
687677
),
688678
};
689679
assert(!range.isCollapsed);
690680
final ui.TextBox box = toTextBox(start: range.start, end: range.end);
691681
return ui.GlyphInfo(box.toRect(), range, box.direction);
692682
}
693683

694-
/// Returns the UTF-16 range of the character that encloses the code unit at
695-
/// the given offset.
696-
ui.TextRange? getCharacterRangeAt(int codeUnitOffset) {
697-
if (end == start) {
698-
return null;
699-
}
700-
final List<int> graphemeStarts = _graphemeStarts;
701-
assert(graphemeStarts.length >= 2);
702-
703-
int previousStart = graphemeStarts.first;
704-
if (previousStart > codeUnitOffset) {
705-
return null;
706-
}
707-
for (int i = 1; i < graphemeStarts.length; i += 1) {
708-
final int newStart = graphemeStarts[i];
709-
if (newStart > codeUnitOffset) {
710-
return ui.TextRange(start: previousStart, end: newStart);
711-
}
712-
previousStart = newStart;
713-
}
714-
return null;
715-
}
716-
717684
/// Returns the GlyphInfo of the character in the fragment that is closest to
718685
/// the given offset x.
719-
ui.GlyphInfo? getClosestCharacterBox(double x) {
720-
if (end == start) {
721-
return null;
722-
}
723-
if (end - start == 1 || _graphemeStarts.length <= 2) {
724-
final ui.TextBox box = toTextBox(start: start, end: end);
725-
return ui.GlyphInfo(box.toRect(), ui.TextRange(start: start, end: end), box.direction);
726-
}
727-
return _getClosestCharacterInRange(x, 0, _graphemeStarts.length - 1);
686+
ui.GlyphInfo getClosestCharacterBox(double x) {
687+
assert(end > start);
688+
assert(graphemeStartIndexRange != null);
689+
// The force ! is safe here because this method is only called by
690+
// LayoutService.getClosestGlyphInfo which checks this fragment has at least
691+
// one grapheme start before calling this method.
692+
final (int rangeStart, int rangeEnd) = graphemeStartIndexRange!;
693+
return _getClosestCharacterInRange(x, rangeStart, rangeEnd);
728694
}
729695
}
730696

lib/web_ui/lib/src/engine/text/layout_service.dart

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,7 @@ class TextLayoutService {
296296
sandwichStart = null;
297297

298298
if (i < line.fragments.length){
299-
previousDirection = line.fragments[i].textDirection!;
299+
previousDirection = line.fragments[i].textDirection;
300300
}
301301
}
302302
}
@@ -420,9 +420,42 @@ class TextLayoutService {
420420
}
421421

422422
ui.GlyphInfo? getClosestGlyphInfo(ui.Offset offset) {
423-
return _findLineForY(offset.dy)
424-
?.closestFragmentAtOffset(offset.dx)
425-
?.getClosestCharacterBox(offset.dx);
423+
final LayoutFragment? fragment = _findLineForY(offset.dy)
424+
?.closestFragmentAtOffset(offset.dx);
425+
if (fragment == null) {
426+
return null;
427+
}
428+
final double dx = offset.dx;
429+
final bool closestGraphemeStartInFragment = !fragment.hasLeadingBrokenGrapheme
430+
|| dx <= fragment.line.left
431+
|| fragment.line.left + fragment.line.width <= dx
432+
|| switch (fragment.textDirection) {
433+
// If dx is closer to the trailing edge, no need to check other fragments.
434+
ui.TextDirection.ltr => dx >= (fragment.left + fragment.right) / 2,
435+
ui.TextDirection.rtl => dx <= (fragment.left + fragment.right) / 2,
436+
};
437+
final ui.GlyphInfo candidate1 = fragment.getClosestCharacterBox(dx);
438+
if (closestGraphemeStartInFragment) {
439+
return candidate1;
440+
}
441+
final bool searchLeft = switch (fragment.textDirection) {
442+
ui.TextDirection.ltr => true,
443+
ui.TextDirection.rtl => false,
444+
};
445+
final ui.GlyphInfo? candidate2 = fragment.line.closestFragmentTo(fragment, searchLeft)?.getClosestCharacterBox(dx);
446+
if (candidate2 == null) {
447+
return candidate1;
448+
}
449+
450+
final double distance1 = math.min(
451+
(candidate1.graphemeClusterLayoutBounds.left - dx).abs(),
452+
(candidate1.graphemeClusterLayoutBounds.right - dx).abs(),
453+
);
454+
final double distance2 = math.min(
455+
(candidate2.graphemeClusterLayoutBounds.left - dx).abs(),
456+
(candidate2.graphemeClusterLayoutBounds.right - dx).abs(),
457+
);
458+
return distance2 > distance1 ? candidate1 : candidate2;
426459
}
427460

428461
ParagraphLine? _findLineForY(double y) {
@@ -862,6 +895,7 @@ class LineBuilder {
862895
descent: descent,
863896
fragments: _fragments,
864897
textDirection: _paragraphDirection,
898+
paragraph: paragraph,
865899
);
866900

867901
for (final LayoutFragment fragment in _fragments) {

lib/web_ui/lib/src/engine/text/paint_service.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ class TextPaintService {
6767
}
6868

6969
_prepareCanvasForFragment(canvas, fragment);
70-
final double fragmentX = fragment.textDirection! == ui.TextDirection.ltr
70+
final double fragmentX = fragment.textDirection == ui.TextDirection.ltr
7171
? fragment.left
7272
: fragment.right;
7373

@@ -96,7 +96,7 @@ class TextPaintService {
9696
}
9797
}
9898

99-
canvas.setCssFont(style.cssFontString, fragment.textDirection!);
99+
canvas.setCssFont(style.cssFontString, fragment.textDirection);
100100
canvas.setUpPaint(paint.paintData, null);
101101
}
102102
}

0 commit comments

Comments
 (0)