Repository navigation
Conversation
|
Thanks for putting this together, @dbebawy! The overall design — modeling
the drawn hyphen as a SoftHyphenBlock on TextLine and rewinding to
_FallbackBreak in _LineBuilder — fits cleanly into WebParagraph and keeps
painting, decorations, and intrinsic width metrics simple.
Also, splitting the first commit ([web_paragraph] Map code units without a
cluster to a cluster) into its own PR is a great idea — we actually hit
that exact _TextClusterMapping.toTextRange assertion failure on mixed
zero-width/BiDi text recently, so landing that fix first will be very
helpful.
*Answers to your questions*
1. *BiDi placement ("-inter" for an LTR word in an RTL paragraph)*:
Keeping SkParagraph's current behavior in this PR so that WebParagraph,
CanvasKit, Skwasm, and native all match is the right call for now.
(Typographically per UAX #9 / CSS Text, an ellipsis is a line-level
indicator at the paragraph's end edge, whereas a soft hyphen belongs to the
broken run; if we change that later, we should update SkParagraph and
WebParagraph together.)
2. *letterSpacing on the hyphen*: Measuring and painting the hyphen with
the span's style (including letterSpacing) is consistent with how
WebParagraph measures EllipsisBlock and TextSpans via Canvas2D, so
keeping it as-is here is fine.
*Edge cases found while testing*
While running additional edge-case tests against this branch in Chrome, I
found 5 cases (3 in Commit 1 and 2 in Commit 2) where layout or queries
either fail an assertion or miscompute:
1.
*Commit 1 — Leading clusterless code units in
_TextClusterMapping.toClusterRange (layout.dart:1228-1234)*: When a
paragraph starts with a clusterless character (mapped to cluster 0,
whose .start > 0), toClusterRange(0, 1) returned ClusterRange(0, 1)
instead of an empty ClusterRange(0, 0) because _clusters[0].end <= 0 is
false and endClusterIndex was not adjusted when _clusters[endClusterIndex
- 1].start >= end. For example, on '\u200Fabc' in an LTR paragraph
(BidiRegion(0,
1, level: 1) followed by BidiRegion(1, 4, level: 0)), both regions
included cluster 0 ('a'), so 'a' was added to line.visualBlocks twice (
computeLineMetrics().single.width became 40 instead of 30, and
getBoxesForRange(1,
4) returned 2 boxes instead of 1). Checking if
(_clusters[endClusterIndex - 1].start >= end) { endClusterIndex -= 1; }
fixes this.
2.
*Commit 1 — Empty ClusterRange in extractBidiRuns and addLine
(layout.dart:225-230, 474-481)*:
- When an interior BidiRegion has only clusterless code units (e.g.
'a\u200F\nb' in LTR, where \u200F forms BidiRegion(1, 2, level:
1)), _mapping.toClusterRange(1,
2) returns an empty ClusterRange(1, 1). Because
ClusterRange.overlapsWith returns true for [1, 1) inside [0, 2),
addLine hit Assertion failed at layout.dart:420. Skipping empty
clusterRanges in extractBidiRuns avoids creating empty BidiRuns.
- Similarly, in addLine (layout.dart:474-481), if a standalone styled
TextSpan contains only \u00AD or \u200B, bidiLineSpanRange is
empty (ClusterRange(c,
c)). Skipping if (bidiLineSpanRange.isEmpty) continue; avoids
indexing allClusters[physicalClusterRange.end - 1] (which throws
RangeError:
-1 in RTL when c == 0) and avoids adding a zero-width TextBlock.
3.
*Commit 1 — getLineNumberAt assertion failure when a line starts with a
clusterless character (layout.dart:323-328)*: For '\u00ADabcd\ne' at
offset 0 or 'a\nb\n\u00ADc\nd' at offset 4 (right after \n),
contentTextRange
= _mapping.toTextRange(contentClusterRange) starts at the first visible
cluster (1 or 5). That left offset 0 or 4 in a gap outside every
line's [allLineTextRange.start,
hardLineBreakRange.end), causing getLineNumberAt(0) / getLineNumberAt(4)
to fail assert(startLine == endLine) at paragraph.dart:1093. Starting
allTextRange at 0 for the first line (and at
lines.last.hardLineBreakRange.end for subsequent lines) keeps line
ranges contiguous.
4.
*Commit 2 — Soft hyphen immediately after a space (wrapper.dart:322-348,
431-438)*: In 'a bbbb \u00ADccc' laid out at width 65 (with 10px glyphs
in FlutterTest), when markSoftLineBreak runs before 'c', the preceding
cluster was ' ', so hasPendingText is false and hasWhitespaces is true.
Breaking there is just breaking after the space (no hyphen is drawn because
_endingSoftHyphen checks hasWhitespaces). However, markSoftLineBreak
still checked canFit(softHyphen.width) (which includes _widthWhitespaces),
set _isBreakTooWide = true, and rewound the line to 'a |bbbb |ccc'
instead of 'a bbbb |ccc'. Also, consumePendingText updated
_minIntrinsicWidth with _widthPendingText + hyphenWidth before checking if
(!hasPendingText) return;. Ignoring softHyphen in markSoftLineBreak when
!hasPendingText (and moving the _minIntrinsicWidth update below if
(!hasPendingText) return;) fixes both.
5.
*Commit 2 — TextAffinity.upstream when hit-testing SoftHyphenBlock
(layout.dart:946, 1002)*: getPositionForOffset on SoftHyphenBlock
returned ui.TextPosition(offset: block.textRange.end) with default
TextAffinity.downstream. Since block.textRange.end is the first code
unit on the *next* line (5 in 'abcd\u00ADe'), getLineBoundary(pos)
returned line 1 ([5, 6)) instead of line 0 ([0, 5)), and
getClosestGlyphInfoForOffset(const
ui.Offset(45, 5)) returned 'e' on line 1 instead of the drawn hyphen at
offset 4. Returning ui.TextPosition(offset: block.textRange.end,
affinity: ui.TextAffinity.upstream) matches SkParagraph and normal LTR
end-of-line positions.
…------------------------------
*Companion Draft PR*
To make inspecting and reproducing these cases easy, I uploaded a companion
Draft PR stacked directly on top of your branch:
*#195377
<#195377>* (diff of just the 2
companion commits on top of 8f0ff93
<8f0ff93...269f171>
)
- *Commit 1 (e50f01a)
<https://github.com/flutter/flutter/pull/195377/commits/e50f01a9c850c68ff29fc72606b2e480a15e1545>*
adds the 9 unit tests in paragraph_edge_cases_test.dart and
paragraph_soft_hyphen_test.dart reproducing the issues above (fails with +73
-7 on your current HEAD).
- *Commit 2 (269f171)
<https://github.com/flutter/flutter/pull/195377/commits/269f171b2c44096b0868d369326f93cfdbc599fe>*
has the corresponding fix in layout.dart and wrapper.dart (all 80 tests
pass: +80 -0).
Feel free to cherry-pick both commits, fold the changes into this PR, or
take only the parts you want — whichever you prefer! (I will close the
draft PR once this PR is updated.)
Julia
On Mon, Oct 26, 2026 at 5:31 PM David Bebawy ***@***.***> wrote:
Draws a hyphen at the end of a line that breaks at a soft hyphen (U+00AD)
in WebParagraph, so Hyphens works there the way it does on native,
CanvasKit and Skwasm.
@Rusino <https://github.com/Rusino>, I know you have several WebParagraph
PRs open in the same files (#195122
<#195122>, #194980
<#194980>, #193774
<#193774>), so I am opening this
as a draft for feedback. I am happy to wait for those and rebase, or to
change the approach.
Approach
I tried to match SkParagraph's current behavior, including the soft hyphen
fixes that are now in Flutter:
- The hyphen is drawn only where the line breaks at the soft hyphen:
not at the end of the text, before a space or newline, or on a line with an
ellipsis. With Hyphens.hidden nothing is drawn, and the soft hyphen is
still a break opportunity.
- A soft hyphen break only fits if the hyphen fits too. If it does
not, the line breaks at the last earlier break that fits, and the text
after that break is scanned again for the next line. If there is no earlier
break, the hyphen overflows.
- minIntrinsicWidth and longestLine include the hyphen.
maxIntrinsicWidth and LineMetrics.width do not.
- The hyphen takes the soft hyphen's style, so it gets the same color,
background and decorations. It is shaped in the direction of the text it
ends, and placed at the end of the line in the paragraph's direction, like
the ellipsis.
- Selection boxes, getGlyphInfoAt and hit testing for the soft hyphen
use the drawn hyphen. Hit testing the hyphen gives the position after the
soft hyphen.
Chrome gives the soft hyphen no text cluster, so the wrapper checks the
code unit just before each soft line break. The hyphen is a
SoftHyphenBlock, a TextBlock that owns its own clusters and covers the
soft hyphen's text range, so painting, backgrounds and decorations work
without changes.
The first commit fixes something I ran into along the way. Because Chrome
gives characters like U+00AD and U+200B no cluster, their code units were
mapped to cluster 0. Laying out a paragraph that ends with one fails an
assertion in _TextClusterMapping.toTextRange, and a span that starts with
one also covered the end of the span before it. That happens on master
today, with or without the soft hyphen change. I can split it into its own
PR if that is easier.
Whether a line has an ellipsis comes from TextLayout.ellipsisClusters,
which is not cleared between layouts on master, so a hyphen can be left out
after an earlier, narrower layout added an ellipsis. #195122
<#195122> resets it in wrapText,
which fixes that here too.
The hyphen string is "-" for now. Once ParagraphStyle.hyphenCharacter (
#195345 <#195345>, for #189617
<#189617>) lands,
WebParagraphStyle.softHyphenString can return it.
Testing
test/webparagraph/paragraph_soft_hyphen_test.dart has 30 tests that use
the FlutterTest font, most of them ported from SkParagraph's soft hyphen
tests, plus one golden. paragraph_edge_cases_test.dart covers the first
commit.
I ran the chrome-dart2js-webparagraph-ui suite locally on macOS with
Chrome 145 and the prebuilt artifacts (felt test --gcs-prod). Everything
passes except "Paragraph with different locales for the same language" and
"paint overflows" in paragraph_test.dart, which fail the same way on
master on my machine.
Questions
- Like SkParagraph, an LTR word in an RTL paragraph shows as "-inter",
with the hyphen at the left end of the line. Browsers put it next to the
text instead. I kept SkParagraph's behavior so web and native match, but I
am not sure that is what you want here.
- The hyphen is measured and painted with the run's style, so it gets
the run's letter spacing. SkParagraph does not apply letter spacing to it.
Is that difference acceptable, or should the hyphen be measured without it?
Other differences from SkParagraph that I left alone: the soft hyphen
itself has no cluster here, so it gets no letter spacing in the line width,
and getGlyphInfoAt returns null for a soft hyphen in the middle of a
line, where SkParagraph returns a zero-width box. Selection boxes are not
merged, so selecting "inter-" gives two boxes here and one in SkParagraph.
Things on master that I noticed but did not change: a paragraph whose only
text is a soft hyphen or zero width space throws in layout, ellipsize
runs on every line that overflows rather than only the last one, and
minIntrinsicWidth depends on the layout width for words that are too long
for the line.
Fixes #193506 <#193506>.
Pre-launch Checklist
- I read the Contributor Guide
<https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview>
and followed the process outlined there for submitting PRs.
- I read the AI contribution guidelines
<https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#ai-contribution-guidelines>
and understand my responsibilities, or I am not using AI tools.
- I read the Tree Hygiene
<https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md>
wiki page, which explains my responsibilities.
- I read and followed the Flutter Style Guide
<https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md>,
including Features we expect every widget to implement
<https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement>
.
- I signed the CLA <https://cla.developers.google.com/>.
- I listed at least one issue that this PR fixes in the description
above.
- I updated/added relevant in-code documentation (doc comments with ///
).
- If this PR introduces a new feature or capability, I created and
linked a website documentation issue or PR in flutter/website
<https://github.com/flutter/website> (or verified none is needed).
- I added new tests to check the change I am making, or this PR is
test-exempt
<https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests>
.
- I followed the breaking change policy
<https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes>
and added Data Driven Fixes
<https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md>
where supported.
- All existing and new tests are passing.
------------------------------
You can view, comment on, or merge this pull request online at:
#195366
Commit Summary
- 3b21127
<3b21127>
[web_paragraph] Map code units without a cluster to a cluster
- 8f0ff93
<8f0ff93>
[web_paragraph] Render soft hyphens
File Changes
(5 files <https://github.com/flutter/flutter/pull/195366/files>)
- *M*
engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart
<https://github.com/flutter/flutter/pull/195366/files#diff-4bc930bc80876ec0b9cac2998945fbe9e8a76d344b2207911f0375ff75022573>
(209)
- *M*
engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paragraph.dart
<https://github.com/flutter/flutter/pull/195366/files#diff-398c78ae30d0373e21407f4c152e705e5f75c286eb35fd6d91a9487cf720c0c1>
(9)
- *M*
engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/wrapper.dart
<https://github.com/flutter/flutter/pull/195366/files#diff-7ab4af58afde9df0cd7b812044f0971dc3f3e557c47d8aa1658301e51a93ddb2>
(141)
- *M*
engine/src/flutter/lib/web_ui/test/webparagraph/paragraph_edge_cases_test.dart
<https://github.com/flutter/flutter/pull/195366/files#diff-715c79ae78cb98204a70b31b421237b586bdbe179ea1316ea719fb5ba9ec8c09>
(37)
- *A*
engine/src/flutter/lib/web_ui/test/webparagraph/paragraph_soft_hyphen_test.dart
<https://github.com/flutter/flutter/pull/195366/files#diff-e9b7200ad170b77586cd50791051a03a3bea2e52d97979b17116a36c45e35dff>
(407)
Patch Links:
- https://github.com/flutter/flutter/pull/195366.patch
- https://github.com/flutter/flutter/pull/195366.diff
—
Reply to this email directly, view it on GitHub
<#195366?email_source=notifications&email_token=ACFWYHKJ7CTAUJTMUFUSI5T5V67D7A5CNFSNUABEM5UWIORPF5TWS5BNNB2WEL2QOVWGYUTFOF2WK43UF42TAMRUHE2TOMJWGGTHEZLBONXW5J3NMVXHI2LPN2SWK5TFNZ2KYZTPN52GK4S7MNWGSY3L>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/ACFWYHI73X2UGEFQFNPI3Y35V67D7AVCNFSNUABEKJSXA33TNF2G64TZHMZTCNZZGI4DENB3JFZXG5LFHM3DANBYGUZTINJWGKQXMAQ>
.
Triage notifications, keep track of coding agent tasks and review pull
requests on the go with GitHub Mobile for iOS
<https://github.com/notifications/mobile/ios/ACFWYHNCQQUNSC2HB46MJQT5V67D7A5CNFSNUABEM5UWIORPF5TWS5BNNB2WEL2QOVWGYUTFOF2WK43UF42TAMRUHE2TOMJWGGTHEZLBONXW5J3NMVXHI2LPN2SWK5TFNZ2KUZTPN52GK4S7NFXXG>
and Android
<https://github.com/notifications/mobile/android/ACFWYHP6H5IASGVY2WYE3DT5V67D7A5CNFSNUABEM5UWIORPF5TWS5BNNB2WEL2QOVWGYUTFOF2WK43UF42TAMRUHE2TOMJWGGTHEZLBONXW5J3NMVXHI2LPN2SWK5TFNZ2K4ZTPN52GK4S7MFXGI4TPNFSA>.
Download it today!
You are receiving this because you were mentioned.Message ID:
***@***.***>
|
8f0ff93 to
6bbb470
Compare
|
Thank you for the review, and for writing the fixes and tests. That made them easy to take. I folded in all five, with you as co-author on both commits. Fixes 1 to 3 went into the first commit and fixes 4 and 5 into the soft hyphen commit, and the code is the same as #195377. The one change is that the U+200F test moved to I also opened #195384 with just the first commit, so it can land first. This PR is stacked on it for now. Locally, the webparagraph suite passes for both, except the two On the affinity in fix 5: from reading skparagraph, I think |
|
Thanks for folding those in and splitting out #195384! I verified the
updated diff and closed the companion draft PR (#195377).
On the affinity in fix 5: from reading skparagraph, I think
TextLine::getGlyphPositionAtCoordinate returns downstream for a hit on the
hyphen or the ellipsis, and the engine passes that through, so native may
land on the next line there today. I have not checked it on a device. Would
it be worth changing skparagraph to upstream there as well?
Yes — great catch! Looking at TextLine::getGlyphPositionAtCoordinate (
TextLine.cpp:1574-1586) and ParagraphImpl::getClosestUTF16GlyphInfoAt (
ParagraphImpl.cpp:1481-1486), returning kDownstream in the if
(run->isEllipsis()) branch actually breaks two things in SkParagraph today:
1.
*For fHyphen ("inter\u00ADnational")*:
- getGlyphPositionAtCoordinate on the drawn hyphen returns { 6,
kDownstream } (this->fText.end).
- In Flutter (Paragraph.getLineBoundary in lib/ui/text.dart:3363-3367),
position.affinity == TextAffinity.downstream && position.offset ==
line.end && line.end == nextLine.start causes getLineBoundary to jump
to the next line ([6, 14)) instead of staying on the hyphenated line ([0,
6)).
- In ParagraphImpl::getClosestUTF16GlyphInfoAt (ParagraphImpl.cpp:1484),
utf16Offset = res.position + (res.affinity == Affinity::kDownstream ?
0 : -1) evaluates to 6 ('n' on line 1) instead of 5 (U+00AD on line
0, whose GlyphInfo box is the drawn hyphen after your CL 1396916).
2.
*For fEllipsis ("abcdef" truncated to "abc...")*:
- TextLine::createEllipsis (TextLine.cpp:659-661) truncates fText.end
and fTextIncludingNewlines.end to the end of the last visible cluster
before the ellipsis (3).
- Because getGlyphPositionAtCoordinate on the ellipsis returns { 3,
kDownstream }, getClosestUTF16GlyphInfoAt looks up utf16Offset = 3,
and getLineNumberAt(3) (ParagraphImpl.cpp:1361) returns -1
(fLines[endLine].textWithNewlines().end
<= 3), so getClosestGlyphInfoForOffset on an ellipsis returns null!
Changing TextLine.cpp:1582 to utf16Index > 0 ? kUpstream : kDownstream
(matching normal end-of-line hit-testing at TextLine.cpp:1630) fixes both
fHyphen and fEllipsis.
If you'd like to put up a Skia CL for that, I'd be happy to review it — or
let me know if you'd prefer me to take care of it.
Julia
…On Mon, Oct 26, 2026 at 9:10 PM David Bebawy ***@***.***> wrote:
*dbebawy* left a comment (flutter/flutter#195366)
<#195366 (comment)>
Thank you for the review, and for writing the fixes and tests. That made
them easy to take.
I folded in all five, with you as co-author on both commits. Fixes 1 to 3
went into the first commit and fixes 4 and 5 into the soft hyphen commit,
and the code is the same as #195377
<#195377>. The one change is that
the U+200F test moved to paragraph_edge_cases_test.dart with the first
commit. It compares against the width of "abc" there, since that file does
not load FlutterTest.
I also opened #195384 <#195384>
with just the first commit, so it can land first. This PR is stacked on it
for now.
Locally, the webparagraph suite passes for both, except the two
paragraph_test.dart tests that also fail on master on my machine. Seven
of the edge case tests fail on master.
On the affinity in fix 5: from reading skparagraph, I think
TextLine::getGlyphPositionAtCoordinate returns downstream for a hit on
the hyphen or the ellipsis, and the engine passes that through, so native
may land on the next line there today. I have not checked it on a device.
Would it be worth changing skparagraph to upstream there as well?
—
Reply to this email directly, view it on GitHub
<#195366?email_source=notifications&email_token=ACFWYHPCKE4GD3PR3XH5OP35V7YYDA5CNFSNUABFM5UWIORPF5TWS5BNNB2WEL2JONZXKZKDN5WW2ZLOOQXTMNBTGEZDANJRG4ZKM4TFMFZW63VHNVSW45DJN5XKKZLWMVXHJLDGN5XXIZLSL5RWY2LDNM#issuecomment-6431205172>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/ACFWYHJQYTCVMCB6CMNLC3T5V7YYDAVCNFSNUABEKJSXA33TNF2G64TZHMZTCNZZGI4DENB3JFZXG5LFHM3DANBYGUZTINJWGKQXMAQ>
.
Triage notifications, keep track of coding agent tasks and review pull
requests on the go with GitHub Mobile for iOS
<https://github.com/notifications/mobile/ios/ACFWYHO5PJEGSAO3IPHNZ4L5V7YYDA5CNFSNUABFM5UWIORPF5TWS5BNNB2WEL2JONZXKZKDN5WW2ZLOOQXTMNBTGEZDANJRG4ZKM4TFMFZW63VHNVSW45DJN5XKKZLWMVXHJKTGN5XXIZLSL5UW64Y>
and Android
<https://github.com/notifications/mobile/android/ACFWYHMRL4BS2UXBRE2L2ZT5V7YYDA5CNFSNUABFM5UWIORPF5TWS5BNNB2WEL2JONZXKZKDN5WW2ZLOOQXTMNBTGEZDANJRG4ZKM4TFMFZW63VHNVSW45DJN5XKKZLWMVXHJLTGN5XXIZLSL5QW4ZDSN5UWI>.
Download it today!
You are receiving this because you were mentioned.Message ID:
***@***.***>
|
6bbb470 to
88b5e70
Compare
|
Thank you for checking, and for finding the ellipsis case too. I put up https://skia-review.googlesource.com/c/skia/+/1406081, which makes a hit on the hyphen or the ellipsis return upstream, with tests for both, and added you as a reviewer. I also rebased this PR onto #195384, which now includes your commits from #195386. |
88b5e70 to
bc11571
Compare
TextLine::getGlyphPositionAtCoordinate returns the end of the line's
text for a hit on the ellipsis or on a rendered soft hyphen, but with
downstream affinity. For a soft hyphen that position is also the start
of the next line, and for an ellipsis it's past the line's text, so:
- getClosestUTF16GlyphInfoAt returns the next line's first glyph for a
hit on the hyphen, and no glyph at all for a hit on the ellipsis.
- Flutter's Paragraph.getLineBoundary puts a hit on the hyphen on the
next line.
Return upstream instead, as for a hit past the end of any other line.
For "international" broken after the soft hyphen, a hit on the
hyphen now returns {6, upstream}, and its glyph is the soft hyphen
[5, 6) instead of the "n" [6, 7). For "abcdef" ellipsized to "abc...",
a hit on the ellipsis returns {3, upstream} and the glyph "c" [2, 3).
This came up in flutter/flutter#195366, where
WebParagraph now returns upstream here too.
[email protected], [email protected]
Bug: flutter/flutter#189234
Change-Id: If514d73dd39fd4a6cb17633d6818e496ade07ca8
Reviewed-on: https://skia-review.googlesource.com/c/skia/+/1406081
Commit-Queue: Julia Lavrova <[email protected]>
Reviewed-by: Julia Lavrova <[email protected]>
Reviewed-by: Alexis Cruz-Ayala <[email protected]>
bc11571 to
11db239
Compare
…r#195384) Chrome gives some invisible characters, such as a soft hyphen (U+00AD), a zero width space (U+200B) or a right-to-left mark (U+200F), no text cluster. WebParagraph left their code units mapped to cluster 0, so a text range that includes one can map to the wrong clusters. On master: * Laying out a paragraph that ends with a soft hyphen or zero width space fails the assertion in `_TextClusterMapping.toTextRange`. * Laying out a paragraph whose only text is one of these characters throws a `StateError`. * Wrapping text where a space is followed by one of these characters, such as `'ab \u200F\u200Fcd'`, fails an assertion in `TextWrapper.breakLines`. * `getLineNumberAt` fails an assertion for a line that starts with one of these characters. * A right-to-left mark at the start of an LTR paragraph makes the first letter count twice in the line width. * A styled span that contains only one of these characters adds an empty box to `getBoxesForRange`. This maps each of those code units to the cluster before it, or to the first cluster at the start of the text, so a range that starts or ends at one of them does not take a cluster next to it. Bidi regions and span slices with no clusters add no bidi run and no block to the line, and a line's text range starts where the previous line ended and ends where the next line starts. A paragraph with no clusters is laid out like empty text, and the line breaker also finds a soft line break that lands on one of these characters. This was the first commit of flutter#195366 (soft hyphens in WebParagraph, for flutter#193506). @Rusino found three of these cases while reviewing that PR, wrote the fixes and tests in flutter#195377, and suggested landing this part on its own first. Her two commits from flutter#195386 add the rest: paragraphs with only these characters, and line breaks next to them. ## Testing `paragraph_edge_cases_test.dart` has a new group for these characters. Eleven of its tests fail on master. The two tests for a soft line break before a direction mark after a zero width space pass on master with Chrome 145 on my machine, so they may depend on the Chrome version. I ran the `chrome-dart2js-webparagraph-ui` suite locally on macOS with `felt test --gcs-prod`. Everything passes except "Paragraph with different locales for the same language" and "paint overflows" in `paragraph_test.dart`, which fail the same way on master on my machine. ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [AI contribution guidelines] and understand my responsibilities, or I am not using AI tools. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [ ] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant in-code documentation (doc comments with `///`). - [x] If this PR introduces a new feature or capability, I created and linked a website documentation issue or PR in [flutter/website] (or verified none is needed). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [ ] All existing and new tests are passing. <!-- Links --> [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [AI contribution guidelines]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#ai-contribution-guidelines [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/website]: https://github.com/flutter/website [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md --------- Co-authored-by: Julia Lavrova <[email protected]>
There was a problem hiding this comment.
Code Review
This pull request implements support for soft hyphens (U+00AD) in the web paragraph layout engine, introducing line-breaking fallback logic, hyphen shaping, and query support. The feedback identifies a potential out-of-bounds error in softHyphenBefore when softHyphenOffset is negative, and suggests adding a bounds check to prevent a layout crash.
| final int softHyphenOffset = allClusters[clusterIndex].start - 1; | ||
| if (paragraph.text.codeUnitAt(softHyphenOffset) != 0x00AD) { | ||
| return null; | ||
| } |
There was a problem hiding this comment.
The softHyphenOffset can be negative (e.g., if allClusters[clusterIndex].start is 0), which will cause paragraph.text.codeUnitAt(softHyphenOffset) to throw a RangeError and crash the layout process. Adding a bounds check for softHyphenOffset ensures defensive robustness.
| final int softHyphenOffset = allClusters[clusterIndex].start - 1; | |
| if (paragraph.text.codeUnitAt(softHyphenOffset) != 0x00AD) { | |
| return null; | |
| } | |
| final int softHyphenOffset = allClusters[clusterIndex].start - 1; | |
| if (softHyphenOffset < 0 || softHyphenOffset >= paragraph.text.length) { | |
| return null; | |
| } | |
| if (paragraph.text.codeUnitAt(softHyphenOffset) != 0x00AD) { | |
| return null; | |
| } |
There was a problem hiding this comment.
I think this cannot happen here. allClusters is sorted by start and its clusters are not empty, and the check above returns null for clusterIndex <= 0, so the cluster starts at 1 or later. I am happy to add the check anyway if a reviewer prefers it.
There was a problem hiding this comment.
Agreed — 0 < clusterIndex < allClusters.length - 1 (before the artificial end-of-text cluster) and non-empty sorted clusters guarantee 1 <= allClusters[clusterIndex].start <= paragraph.text.length, so no extra bounds check is needed.
…r#195384) Chrome gives some invisible characters, such as a soft hyphen (U+00AD), a zero width space (U+200B) or a right-to-left mark (U+200F), no text cluster. WebParagraph left their code units mapped to cluster 0, so a text range that includes one can map to the wrong clusters. On master: * Laying out a paragraph that ends with a soft hyphen or zero width space fails the assertion in `_TextClusterMapping.toTextRange`. * Laying out a paragraph whose only text is one of these characters throws a `StateError`. * Wrapping text where a space is followed by one of these characters, such as `'ab \u200F\u200Fcd'`, fails an assertion in `TextWrapper.breakLines`. * `getLineNumberAt` fails an assertion for a line that starts with one of these characters. * A right-to-left mark at the start of an LTR paragraph makes the first letter count twice in the line width. * A styled span that contains only one of these characters adds an empty box to `getBoxesForRange`. This maps each of those code units to the cluster before it, or to the first cluster at the start of the text, so a range that starts or ends at one of them does not take a cluster next to it. Bidi regions and span slices with no clusters add no bidi run and no block to the line, and a line's text range starts where the previous line ended and ends where the next line starts. A paragraph with no clusters is laid out like empty text, and the line breaker also finds a soft line break that lands on one of these characters. This was the first commit of flutter#195366 (soft hyphens in WebParagraph, for flutter#193506). @Rusino found three of these cases while reviewing that PR, wrote the fixes and tests in flutter#195377, and suggested landing this part on its own first. Her two commits from flutter#195386 add the rest: paragraphs with only these characters, and line breaks next to them. ## Testing `paragraph_edge_cases_test.dart` has a new group for these characters. Eleven of its tests fail on master. The two tests for a soft line break before a direction mark after a zero width space pass on master with Chrome 145 on my machine, so they may depend on the Chrome version. I ran the `chrome-dart2js-webparagraph-ui` suite locally on macOS with `felt test --gcs-prod`. Everything passes except "Paragraph with different locales for the same language" and "paint overflows" in `paragraph_test.dart`, which fail the same way on master on my machine. ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [AI contribution guidelines] and understand my responsibilities, or I am not using AI tools. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [ ] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant in-code documentation (doc comments with `///`). - [x] If this PR introduces a new feature or capability, I created and linked a website documentation issue or PR in [flutter/website] (or verified none is needed). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [ ] All existing and new tests are passing. <!-- Links --> [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [AI contribution guidelines]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#ai-contribution-guidelines [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/website]: https://github.com/flutter/website [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md --------- Co-authored-by: Julia Lavrova <[email protected]>
Draws a hyphen at the end of a line that breaks at a soft hyphen (U+00AD), unless ParagraphStyle.hyphens is Hyphens.hidden. This matches SkParagraph, which CanvasKit, Skwasm and the native engine use: * The hyphen is drawn only where the line breaks at the soft hyphen: not at the end of the text, before a space or newline, or on a line with an ellipsis. * A soft hyphen break only fits if the hyphen fits too. Otherwise the line breaks at the last earlier break that fits, or the hyphen overflows if there is none. A soft hyphen right after a space breaks like the space, so it does not need room for a hyphen. * minIntrinsicWidth and longestLine include the hyphen; maxIntrinsicWidth and LineMetrics.width do not. * The hyphen takes the soft hyphen's style, is shaped in the direction of the text it ends, and goes at the end of the line in the paragraph's direction. * Selection boxes, glyph info and hit testing for the soft hyphen use the drawn hyphen. Hit testing the hyphen gives the position after the soft hyphen with upstream affinity, so it stays on the hyphen's line. Fixes flutter#193506. Co-authored-by: Julia Lavrova <[email protected]>
11db239 to
c3d96d6
Compare
Rusino
left a comment
There was a problem hiding this comment.
LGTM! Thanks for rebasing onto #195384 and folding in the post-space soft-hyphen and upstream hit-test affinity fixes (as well as landing Skia CL 1406081).
Verified on the WebParagraph test corpus (0 regressions; fixes 3 existing soft-hyphen tests in paragraph_builder_test.dart and text_painter_test.dart).
|
Golden file changes have been found for this pull request. Click here to view and triage (e.g. because this is an intentional change). If you are still iterating on this change and are not ready to resolve the images on the Flutter Gold dashboard, consider marking this PR as a draft pull request above. You will still be able to view image results on the dashboard, commenting will be silenced, and the check will not try to resolve itself until marked ready for review. For more guidance, visit Writing a golden file test for Reviewers: Read the Tree Hygiene page and make sure this patch meets those guidelines before LGTMing. |
|
@Rusino Thank you for the review! Gold has one new image, |
Adds `hyphenCharacter` to `dart:ui`'s `ParagraphStyle`, so apps can choose the string drawn at the end of a line that breaks at a soft hyphen (U+00AD) instead of always "-". If it is null, "-" is drawn as before. The empty string draws nothing, and the line still breaks there. The engine passes it to Skia's `ParagraphStyle::setSoftHyphenString`, which already reserves the string's width when breaking lines and includes it in selection boxes. Skwasm gets a `paragraphStyle_setSoftHyphenString` binding for the same call, and CanvasKit sets the `softHyphenString` property that rolled in with flutter#195339. This is the additive `dart:ui` part of flutter#189617. The name and shape follow the API question there, and I am happy to change them. The framework side (`TextStyle.getParagraphStyle`, `TextPainter`, `RenderParagraph`, `RichText` and `Text`) is ready as a follow-up PR, along with a breaking change page for flutter/website, since a new `getParagraphStyle` parameter breaks classes that override it. I will open both once this lands. WebParagraph stores the value but does not draw soft hyphens yet. flutter#195366 adds soft hyphens there for flutter#193506, and can use this value once both land. Part of flutter#189617. ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [AI contribution guidelines] and understand my responsibilities, or I am not using AI tools. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant in-code documentation (doc comments with `///`). - [ ] If this PR introduces a new feature or capability, I created and linked a website documentation issue or PR in [flutter/website] (or verified none is needed). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [ ] All existing and new tests are passing. <!-- Links --> [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [AI contribution guidelines]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#ai-contribution-guidelines [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/website]: https://github.com/flutter/website [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
Draws a hyphen at the end of a line that breaks at a soft hyphen (U+00AD) in WebParagraph, so
Hyphensworks there the way it does on native, CanvasKit and Skwasm.@Rusino, I know you still have WebParagraph PRs open in the same files (#195122, #193774). I am happy to wait for those and rebase, or to change the approach.
Approach
I tried to match SkParagraph's current behavior, including the soft hyphen fixes that are now in Flutter:
Hyphens.hiddennothing is drawn, and the soft hyphen is still a break opportunity.minIntrinsicWidthandlongestLineinclude the hyphen.maxIntrinsicWidthandLineMetrics.widthdo not.getGlyphInfoAtand hit testing for the soft hyphen use the drawn hyphen. Hit testing the hyphen gives the position after the soft hyphen, with upstream affinity so it stays on the line with the hyphen.Chrome gives the soft hyphen no text cluster, so the wrapper checks the code unit just before each soft line break. The hyphen is a
SoftHyphenBlock, aTextBlockthat owns its own clusters and covers the soft hyphen's text range, so painting, backgrounds and decorations work without changes.Characters that Chrome gives no text cluster, such as U+00AD and U+200B, are handled by #195384, which has landed. This PR is now rebased on it and has one commit.
@Rusino reviewed this and found five edge cases (#195377). Three were fixed in #195384, and the other two are folded in here: a soft hyphen right after a space, and the affinity when hit testing the hyphen.
Whether a line has an ellipsis comes from
TextLayout.ellipsisClusters, which is not cleared between layouts on master, so a hyphen can be left out after an earlier, narrower layout added an ellipsis. #195122 resets it inwrapText, which fixes that here too.The hyphen string is "-" for now. Once
ParagraphStyle.hyphenCharacter(#195345, for #189617) lands,WebParagraphStyle.softHyphenStringcan return it.Testing
test/webparagraph/paragraph_soft_hyphen_test.darthas 33 tests that use the FlutterTest font, most of them ported from SkParagraph's soft hyphen tests, plus one golden.The golden, with
Hyphens.manualon the left andHyphens.hiddenon the right:I ran the
chrome-dart2js-webparagraph-uisuite locally on macOS with Chrome 145 and the prebuilt artifacts (felt test --gcs-prod). Everything passes except "Paragraph with different locales for the same language" and "paint overflows" inparagraph_test.dart, which fail the same way on master on my machine.From the review
Other differences from SkParagraph that I left alone: the soft hyphen itself has no cluster here, so it gets no letter spacing in the line width, and
getGlyphInfoAtreturns null for a soft hyphen in the middle of a line, where SkParagraph returns a zero-width box. Selection boxes are not merged, so selecting "inter-" gives two boxes here and one in SkParagraph. Hit testing the hyphen now gives upstream affinity in SkParagraph too, since Skia CL 1406081 (in Flutter through #195711). SkParagraph also skips line breaking for a single run without spaces that fits the width, so on native,minIntrinsicWidthfor "international" measured at infinite width is the whole word. This PR splits at the soft hyphen there, so WebParagraph'sminIntrinsicWidthcan be smaller than native for one-word text.Things on master that I noticed but did not change:
ellipsizeruns on every line that overflows rather than only the last one, andminIntrinsicWidthdepends on the layout width for words that are too long for the line.Fixes #193506.
Pre-launch Checklist
///).