From ea0b7b7df44cb829f0be127ae045ba3ed6a96081 Mon Sep 17 00:00:00 2001 From: Andrew Kunkel Date: Wed, 22 Jul 2026 15:59:24 -0700 Subject: [PATCH 1/2] Fix for iOS text truncation issues. --- .../Libraries/Text/Text/RCTTextView.mm | 93 ++++++++++++++++++- .../textlayoutmanager/RCTTextLayoutManager.mm | 93 ++++++++++++++++++- 2 files changed, 180 insertions(+), 6 deletions(-) diff --git a/packages/react-native/Libraries/Text/Text/RCTTextView.mm b/packages/react-native/Libraries/Text/Text/RCTTextView.mm index b007efffadcd..768bc946722c 100644 --- a/packages/react-native/Libraries/Text/Text/RCTTextView.mm +++ b/packages/react-native/Libraries/Text/Text/RCTTextView.mm @@ -25,6 +25,87 @@ @interface RCTTextView () @end +// Core Text (used for the two-pass stroke rendering below) performs its own word wrapping and +// ignores the NSTextContainer's `maximumNumberOfLines` and truncating line break mode. If we hand +// it the full attributed string, an overflowing last word gets wrapped to a line that does not fit +// the (clamped) frame and disappears entirely - with no ellipsis. The non-stroke path avoids this +// because it draws the glyphs NSLayoutManager already laid out and truncated. This helper +// reconstructs that same visible, truncated text (visible characters + an ellipsis) so the stroke +// passes render identically to the non-stroke path. +static NSAttributedString *RCTTruncatedAttributedStringForStroke( + NSTextStorage *textStorage, + NSLayoutManager *layoutManager, + NSTextContainer *textContainer) +{ + // No line limit means nothing is truncated and the frame was measured to fit every wrapped line, + // so Core Text can safely lay out the full string. + if (textContainer.maximumNumberOfLines == 0) { + return textStorage; + } + + [layoutManager ensureLayoutForTextContainer:textContainer]; + NSRange fullGlyphRange = [layoutManager glyphRangeForTextContainer:textContainer]; + if (fullGlyphRange.length == 0) { + return textStorage; + } + + NSMutableAttributedString *truncated = [NSMutableAttributedString new]; + [layoutManager + enumerateLineFragmentsForGlyphRange:fullGlyphRange + usingBlock:^( + CGRect rect, + CGRect usedRect, + NSTextContainer *_Nonnull _, + NSRange lineGlyphRange, + BOOL *_Nonnull stop) { + NSRange lineCharRange = + [layoutManager characterRangeForGlyphRange:lineGlyphRange + actualGlyphRange:NULL]; + NSRange truncatedGlyphRange = [layoutManager + truncatedGlyphRangeInLineFragmentForGlyphAtIndex:lineGlyphRange.location]; + + if (truncatedGlyphRange.location != NSNotFound) { + // This line is truncated. Keep the characters before the + // truncation point and append an ellipsis carrying the attributes + // of the last visible character (matching what TextKit renders). + NSRange truncatedCharRange = + [layoutManager characterRangeForGlyphRange:truncatedGlyphRange + actualGlyphRange:NULL]; + NSInteger visibleLength = truncatedCharRange.location - lineCharRange.location; + if (visibleLength > 0) { + NSRange visibleRange = NSMakeRange(lineCharRange.location, visibleLength); + [truncated + appendAttributedString:[textStorage attributedSubstringFromRange:visibleRange]]; + NSDictionary *ellipsisAttributes = + [textStorage attributesAtIndex:NSMaxRange(visibleRange) - 1 effectiveRange:NULL]; + [truncated appendAttributedString:[[NSAttributedString alloc] + initWithString:@"\u2026" + attributes:ellipsisAttributes]]; + } + *stop = YES; + return; + } + + // Fully visible line. Append it verbatim; if it was soft-wrapped + // (does not already end in a newline) insert one so Core Text + // reproduces the same line break. + NSAttributedString *lineString = + [textStorage attributedSubstringFromRange:lineCharRange]; + [truncated appendAttributedString:lineString]; + NSString *lineText = lineString.string; + if (lineText.length > 0 && [lineText characterAtIndex:lineText.length - 1] != '\n' && + NSMaxRange(lineCharRange) < textStorage.length) { + NSDictionary *newlineAttributes = + [textStorage attributesAtIndex:NSMaxRange(lineCharRange) - 1 effectiveRange:NULL]; + [truncated appendAttributedString:[[NSAttributedString alloc] + initWithString:@"\n" + attributes:newlineAttributes]]; + } + }]; + + return truncated.length > 0 ? truncated : textStorage; +} + @implementation RCTTextView { CAShapeLayer *_highlightLayer; UILongPressGestureRecognizer *_longPressGestureRecognizer; @@ -158,14 +239,20 @@ - (void)drawRect:(CGRect)rect CGFloat strokeInset = strokeWidth / 2; + // Core Text ignores the container's line limit / truncation, so feed it the same visible, + // truncated text NSLayoutManager laid out; otherwise an overflowing last word would vanish. + NSAttributedString *visibleText = + RCTTruncatedAttributedStringForStroke(_textStorage, layoutManager, textContainer); + NSRange visibleRange = NSMakeRange(0, visibleText.length); + // PASS 1: Draw stroke outline CGContextSaveGState(context); CGContextSetTextDrawingMode(context, kCGTextStroke); - NSMutableAttributedString *strokeText = [_textStorage mutableCopy]; + NSMutableAttributedString *strokeText = [visibleText mutableCopy]; [strokeText addAttribute:NSForegroundColorAttributeName value:strokeColor - range:characterRange]; + range:visibleRange]; CGContextSetTextMatrix(context, CGAffineTransformIdentity); CGContextTranslateCTM(context, _contentFrame.origin.x + strokeInset, self.bounds.size.height - _contentFrame.origin.y + strokeInset); @@ -189,7 +276,7 @@ - (void)drawRect:(CGRect)rect CGContextTranslateCTM(context, _contentFrame.origin.x + strokeInset, self.bounds.size.height - _contentFrame.origin.y + strokeInset); CGContextScaleCTM(context, 1.0, -1.0); - framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)_textStorage); + framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)visibleText); path = CGPathCreateMutable(); CGPathAddRect(path, NULL, CGRectMake(0, 0, _contentFrame.size.width, _contentFrame.size.height)); frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, NULL); diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm index 9e6c0b4449d5..1efc042db1e8 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm @@ -56,6 +56,87 @@ static CGFloat getStrokeWidth(NSAttributedString *attributedString) return strokeWidth; } +// Core Text (used for the two-pass stroke rendering below) performs its own word wrapping and +// ignores the NSTextContainer's `maximumNumberOfLines` and truncating line break mode. If we hand +// it the full attributed string, an overflowing last word gets wrapped to a line that does not fit +// the (clamped) frame and disappears entirely - with no ellipsis. The non-stroke path avoids this +// because it draws the glyphs NSLayoutManager already laid out and truncated. This helper +// reconstructs that same visible, truncated text (visible characters + an ellipsis) so the stroke +// passes render identically to the non-stroke path. +static NSAttributedString *RCTTruncatedAttributedStringForStroke( + NSTextStorage *textStorage, + NSLayoutManager *layoutManager, + NSTextContainer *textContainer) +{ + // No line limit means nothing is truncated and the frame was measured to fit every wrapped line, + // so Core Text can safely lay out the full string. + if (textContainer.maximumNumberOfLines == 0) { + return textStorage; + } + + [layoutManager ensureLayoutForTextContainer:textContainer]; + NSRange fullGlyphRange = [layoutManager glyphRangeForTextContainer:textContainer]; + if (fullGlyphRange.length == 0) { + return textStorage; + } + + NSMutableAttributedString *truncated = [NSMutableAttributedString new]; + [layoutManager + enumerateLineFragmentsForGlyphRange:fullGlyphRange + usingBlock:^( + CGRect rect, + CGRect usedRect, + NSTextContainer *_Nonnull _, + NSRange lineGlyphRange, + BOOL *_Nonnull stop) { + NSRange lineCharRange = + [layoutManager characterRangeForGlyphRange:lineGlyphRange + actualGlyphRange:NULL]; + NSRange truncatedGlyphRange = [layoutManager + truncatedGlyphRangeInLineFragmentForGlyphAtIndex:lineGlyphRange.location]; + + if (truncatedGlyphRange.location != NSNotFound) { + // This line is truncated. Keep the characters before the + // truncation point and append an ellipsis carrying the attributes + // of the last visible character (matching what TextKit renders). + NSRange truncatedCharRange = + [layoutManager characterRangeForGlyphRange:truncatedGlyphRange + actualGlyphRange:NULL]; + NSInteger visibleLength = truncatedCharRange.location - lineCharRange.location; + if (visibleLength > 0) { + NSRange visibleRange = NSMakeRange(lineCharRange.location, visibleLength); + [truncated + appendAttributedString:[textStorage attributedSubstringFromRange:visibleRange]]; + NSDictionary *ellipsisAttributes = + [textStorage attributesAtIndex:NSMaxRange(visibleRange) - 1 effectiveRange:NULL]; + [truncated appendAttributedString:[[NSAttributedString alloc] + initWithString:@"\u2026" + attributes:ellipsisAttributes]]; + } + *stop = YES; + return; + } + + // Fully visible line. Append it verbatim; if it was soft-wrapped + // (does not already end in a newline) insert one so Core Text + // reproduces the same line break. + NSAttributedString *lineString = + [textStorage attributedSubstringFromRange:lineCharRange]; + [truncated appendAttributedString:lineString]; + NSString *lineText = lineString.string; + if (lineText.length > 0 && [lineText characterAtIndex:lineText.length - 1] != '\n' && + NSMaxRange(lineCharRange) < textStorage.length) { + NSDictionary *newlineAttributes = + [textStorage attributesAtIndex:NSMaxRange(lineCharRange) - 1 effectiveRange:NULL]; + [truncated appendAttributedString:[[NSAttributedString alloc] + initWithString:@"\n" + attributes:newlineAttributes]]; + } + }]; + + return truncated.length > 0 ? truncated : textStorage; +} + - (TextMeasurement)measureNSAttributedString:(NSAttributedString *)attributedString paragraphAttributes:(ParagraphAttributes)paragraphAttributes layoutContext:(TextLayoutContext)layoutContext @@ -143,12 +224,18 @@ - (void)drawAttributedString:(AttributedString)attributedString CGFloat strokeShiftY = (slackY <= 0) ? 0 : (slackY >= strokeWidth ? strokeWidth / 2.0 : slackY / 2.0); + // Core Text ignores the container's line limit / truncation, so feed it the same visible, + // truncated text NSLayoutManager laid out; otherwise an overflowing last word would vanish. + NSAttributedString *visibleText = + RCTTruncatedAttributedStringForStroke(textStorage, layoutManager, textContainer); + NSRange visibleRange = NSMakeRange(0, visibleText.length); + // PASS 1: Draw stroke outline CGContextSaveGState(context); CGContextSetTextDrawingMode(context, kCGTextStroke); - NSMutableAttributedString *strokeText = [textStorage mutableCopy]; - [strokeText addAttribute:NSForegroundColorAttributeName value:strokeColor range:characterRange]; + NSMutableAttributedString *strokeText = [visibleText mutableCopy]; + [strokeText addAttribute:NSForegroundColorAttributeName value:strokeColor range:visibleRange]; CGContextSetTextMatrix(context, CGAffineTransformIdentity); CGContextTranslateCTM( @@ -174,7 +261,7 @@ - (void)drawAttributedString:(AttributedString)attributedString context, frame.origin.x + strokeShiftX, viewBounds.size.height - frame.origin.y + strokeShiftY); CGContextScaleCTM(context, 1.0, -1.0); - framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)textStorage); + framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)visibleText); path = CGPathCreateMutable(); CGPathAddRect(path, NULL, CGRectMake(0, 0, frame.size.width, frame.size.height)); ctFrame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, NULL); From cbda6fb6910df19ad53d894af29cca64a471f7dc Mon Sep 17 00:00:00 2001 From: Andrew Kunkel Date: Thu, 23 Jul 2026 12:29:21 -0700 Subject: [PATCH 2/2] Dedupe stroke truncation helper into shared RCTTextStroke Extract the duplicated RCTTruncatedAttributedStringForStroke helper out of RCTTextView.mm and RCTTextLayoutManager.mm into a single shared unit in React-Core (React/Base/RCTTextStroke.{h,mm}), which both the legacy (React-RCTText) and Fabric (React-FabricComponents) text pods depend on. Also document that the helper only supports tail truncation (the mode used by stroke effects); clip returns the string unchanged and head/middle would need mode-specific ellipsis placement. Co-authored-by: Cursor --- .../Libraries/Text/Text/RCTTextView.mm | 82 +------------------ .../react-native/React/Base/RCTTextStroke.h | 33 ++++++++ .../react-native/React/Base/RCTTextStroke.mm | 82 +++++++++++++++++++ .../textlayoutmanager/RCTTextLayoutManager.mm | 82 +------------------ 4 files changed, 117 insertions(+), 162 deletions(-) create mode 100644 packages/react-native/React/Base/RCTTextStroke.h create mode 100644 packages/react-native/React/Base/RCTTextStroke.mm diff --git a/packages/react-native/Libraries/Text/Text/RCTTextView.mm b/packages/react-native/Libraries/Text/Text/RCTTextView.mm index 768bc946722c..1d139eae9ea8 100644 --- a/packages/react-native/Libraries/Text/Text/RCTTextView.mm +++ b/packages/react-native/Libraries/Text/Text/RCTTextView.mm @@ -12,6 +12,7 @@ #import #import +#import #import #import @@ -25,87 +26,6 @@ @interface RCTTextView () @end -// Core Text (used for the two-pass stroke rendering below) performs its own word wrapping and -// ignores the NSTextContainer's `maximumNumberOfLines` and truncating line break mode. If we hand -// it the full attributed string, an overflowing last word gets wrapped to a line that does not fit -// the (clamped) frame and disappears entirely - with no ellipsis. The non-stroke path avoids this -// because it draws the glyphs NSLayoutManager already laid out and truncated. This helper -// reconstructs that same visible, truncated text (visible characters + an ellipsis) so the stroke -// passes render identically to the non-stroke path. -static NSAttributedString *RCTTruncatedAttributedStringForStroke( - NSTextStorage *textStorage, - NSLayoutManager *layoutManager, - NSTextContainer *textContainer) -{ - // No line limit means nothing is truncated and the frame was measured to fit every wrapped line, - // so Core Text can safely lay out the full string. - if (textContainer.maximumNumberOfLines == 0) { - return textStorage; - } - - [layoutManager ensureLayoutForTextContainer:textContainer]; - NSRange fullGlyphRange = [layoutManager glyphRangeForTextContainer:textContainer]; - if (fullGlyphRange.length == 0) { - return textStorage; - } - - NSMutableAttributedString *truncated = [NSMutableAttributedString new]; - [layoutManager - enumerateLineFragmentsForGlyphRange:fullGlyphRange - usingBlock:^( - CGRect rect, - CGRect usedRect, - NSTextContainer *_Nonnull _, - NSRange lineGlyphRange, - BOOL *_Nonnull stop) { - NSRange lineCharRange = - [layoutManager characterRangeForGlyphRange:lineGlyphRange - actualGlyphRange:NULL]; - NSRange truncatedGlyphRange = [layoutManager - truncatedGlyphRangeInLineFragmentForGlyphAtIndex:lineGlyphRange.location]; - - if (truncatedGlyphRange.location != NSNotFound) { - // This line is truncated. Keep the characters before the - // truncation point and append an ellipsis carrying the attributes - // of the last visible character (matching what TextKit renders). - NSRange truncatedCharRange = - [layoutManager characterRangeForGlyphRange:truncatedGlyphRange - actualGlyphRange:NULL]; - NSInteger visibleLength = truncatedCharRange.location - lineCharRange.location; - if (visibleLength > 0) { - NSRange visibleRange = NSMakeRange(lineCharRange.location, visibleLength); - [truncated - appendAttributedString:[textStorage attributedSubstringFromRange:visibleRange]]; - NSDictionary *ellipsisAttributes = - [textStorage attributesAtIndex:NSMaxRange(visibleRange) - 1 effectiveRange:NULL]; - [truncated appendAttributedString:[[NSAttributedString alloc] - initWithString:@"\u2026" - attributes:ellipsisAttributes]]; - } - *stop = YES; - return; - } - - // Fully visible line. Append it verbatim; if it was soft-wrapped - // (does not already end in a newline) insert one so Core Text - // reproduces the same line break. - NSAttributedString *lineString = - [textStorage attributedSubstringFromRange:lineCharRange]; - [truncated appendAttributedString:lineString]; - NSString *lineText = lineString.string; - if (lineText.length > 0 && [lineText characterAtIndex:lineText.length - 1] != '\n' && - NSMaxRange(lineCharRange) < textStorage.length) { - NSDictionary *newlineAttributes = - [textStorage attributesAtIndex:NSMaxRange(lineCharRange) - 1 effectiveRange:NULL]; - [truncated appendAttributedString:[[NSAttributedString alloc] - initWithString:@"\n" - attributes:newlineAttributes]]; - } - }]; - - return truncated.length > 0 ? truncated : textStorage; -} - @implementation RCTTextView { CAShapeLayer *_highlightLayer; UILongPressGestureRecognizer *_longPressGestureRecognizer; diff --git a/packages/react-native/React/Base/RCTTextStroke.h b/packages/react-native/React/Base/RCTTextStroke.h new file mode 100644 index 000000000000..f6ca0952452c --- /dev/null +++ b/packages/react-native/React/Base/RCTTextStroke.h @@ -0,0 +1,33 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + * Core Text (used for the two-pass stroke rendering of text with a stroke effect) performs its own + * word wrapping and ignores the NSTextContainer's `maximumNumberOfLines` and truncating line break + * mode. If we hand it the full attributed string, an overflowing last word gets wrapped to a line + * that does not fit the (clamped) frame and disappears entirely - with no ellipsis. The non-stroke + * path avoids this because it draws the glyphs NSLayoutManager already laid out and truncated. This + * helper reconstructs that same visible, truncated text (visible characters + an ellipsis) so the + * stroke passes render identically to the non-stroke path. + * + * NOTE: This helper is written for TAIL truncation (`NSLineBreakByTruncatingTail`), which is the + * only mode used by stroke effects. `truncatedGlyphRangeInLineFragmentForGlyphAtIndex:` only + * reports a range for truncating line break modes, so for non-truncating modes (e.g. clipping) the + * full string is returned unchanged. Head/middle truncation would report a range but place the + * ellipsis at the start/middle; the reconstruction here always appends it at the end, so those + * modes would need mode-specific handling before they could be used with a stroke effect. + */ +NSAttributedString *RCTTruncatedAttributedStringForStroke( + NSTextStorage *textStorage, + NSLayoutManager *layoutManager, + NSTextContainer *textContainer); + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native/React/Base/RCTTextStroke.mm b/packages/react-native/React/Base/RCTTextStroke.mm new file mode 100644 index 000000000000..0f9d1df02b3c --- /dev/null +++ b/packages/react-native/React/Base/RCTTextStroke.mm @@ -0,0 +1,82 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +NSAttributedString *RCTTruncatedAttributedStringForStroke( + NSTextStorage *textStorage, + NSLayoutManager *layoutManager, + NSTextContainer *textContainer) +{ + // No line limit means nothing is truncated and the frame was measured to fit every wrapped line, + // so Core Text can safely lay out the full string. + if (textContainer.maximumNumberOfLines == 0) { + return textStorage; + } + + [layoutManager ensureLayoutForTextContainer:textContainer]; + NSRange fullGlyphRange = [layoutManager glyphRangeForTextContainer:textContainer]; + if (fullGlyphRange.length == 0) { + return textStorage; + } + + NSMutableAttributedString *truncated = [NSMutableAttributedString new]; + [layoutManager + enumerateLineFragmentsForGlyphRange:fullGlyphRange + usingBlock:^( + CGRect rect, + CGRect usedRect, + NSTextContainer *_Nonnull _, + NSRange lineGlyphRange, + BOOL *_Nonnull stop) { + NSRange lineCharRange = + [layoutManager characterRangeForGlyphRange:lineGlyphRange + actualGlyphRange:NULL]; + NSRange truncatedGlyphRange = [layoutManager + truncatedGlyphRangeInLineFragmentForGlyphAtIndex:lineGlyphRange.location]; + + if (truncatedGlyphRange.location != NSNotFound) { + // This line is truncated. Keep the characters before the + // truncation point and append an ellipsis carrying the attributes + // of the last visible character (matching what TextKit renders). + NSRange truncatedCharRange = + [layoutManager characterRangeForGlyphRange:truncatedGlyphRange + actualGlyphRange:NULL]; + NSInteger visibleLength = truncatedCharRange.location - lineCharRange.location; + if (visibleLength > 0) { + NSRange visibleRange = NSMakeRange(lineCharRange.location, visibleLength); + [truncated + appendAttributedString:[textStorage attributedSubstringFromRange:visibleRange]]; + NSDictionary *ellipsisAttributes = + [textStorage attributesAtIndex:NSMaxRange(visibleRange) - 1 effectiveRange:NULL]; + [truncated appendAttributedString:[[NSAttributedString alloc] + initWithString:@"\u2026" + attributes:ellipsisAttributes]]; + } + *stop = YES; + return; + } + + // Fully visible line. Append it verbatim; if it was soft-wrapped + // (does not already end in a newline) insert one so Core Text + // reproduces the same line break. + NSAttributedString *lineString = + [textStorage attributedSubstringFromRange:lineCharRange]; + [truncated appendAttributedString:lineString]; + NSString *lineText = lineString.string; + if (lineText.length > 0 && [lineText characterAtIndex:lineText.length - 1] != '\n' && + NSMaxRange(lineCharRange) < textStorage.length) { + NSDictionary *newlineAttributes = + [textStorage attributesAtIndex:NSMaxRange(lineCharRange) - 1 effectiveRange:NULL]; + [truncated appendAttributedString:[[NSAttributedString alloc] + initWithString:@"\n" + attributes:newlineAttributes]]; + } + }]; + + return truncated.length > 0 ? truncated : textStorage; +} diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm index 1efc042db1e8..82b136456363 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm @@ -11,6 +11,7 @@ #import #import +#import #import #import #import @@ -56,87 +57,6 @@ static CGFloat getStrokeWidth(NSAttributedString *attributedString) return strokeWidth; } -// Core Text (used for the two-pass stroke rendering below) performs its own word wrapping and -// ignores the NSTextContainer's `maximumNumberOfLines` and truncating line break mode. If we hand -// it the full attributed string, an overflowing last word gets wrapped to a line that does not fit -// the (clamped) frame and disappears entirely - with no ellipsis. The non-stroke path avoids this -// because it draws the glyphs NSLayoutManager already laid out and truncated. This helper -// reconstructs that same visible, truncated text (visible characters + an ellipsis) so the stroke -// passes render identically to the non-stroke path. -static NSAttributedString *RCTTruncatedAttributedStringForStroke( - NSTextStorage *textStorage, - NSLayoutManager *layoutManager, - NSTextContainer *textContainer) -{ - // No line limit means nothing is truncated and the frame was measured to fit every wrapped line, - // so Core Text can safely lay out the full string. - if (textContainer.maximumNumberOfLines == 0) { - return textStorage; - } - - [layoutManager ensureLayoutForTextContainer:textContainer]; - NSRange fullGlyphRange = [layoutManager glyphRangeForTextContainer:textContainer]; - if (fullGlyphRange.length == 0) { - return textStorage; - } - - NSMutableAttributedString *truncated = [NSMutableAttributedString new]; - [layoutManager - enumerateLineFragmentsForGlyphRange:fullGlyphRange - usingBlock:^( - CGRect rect, - CGRect usedRect, - NSTextContainer *_Nonnull _, - NSRange lineGlyphRange, - BOOL *_Nonnull stop) { - NSRange lineCharRange = - [layoutManager characterRangeForGlyphRange:lineGlyphRange - actualGlyphRange:NULL]; - NSRange truncatedGlyphRange = [layoutManager - truncatedGlyphRangeInLineFragmentForGlyphAtIndex:lineGlyphRange.location]; - - if (truncatedGlyphRange.location != NSNotFound) { - // This line is truncated. Keep the characters before the - // truncation point and append an ellipsis carrying the attributes - // of the last visible character (matching what TextKit renders). - NSRange truncatedCharRange = - [layoutManager characterRangeForGlyphRange:truncatedGlyphRange - actualGlyphRange:NULL]; - NSInteger visibleLength = truncatedCharRange.location - lineCharRange.location; - if (visibleLength > 0) { - NSRange visibleRange = NSMakeRange(lineCharRange.location, visibleLength); - [truncated - appendAttributedString:[textStorage attributedSubstringFromRange:visibleRange]]; - NSDictionary *ellipsisAttributes = - [textStorage attributesAtIndex:NSMaxRange(visibleRange) - 1 effectiveRange:NULL]; - [truncated appendAttributedString:[[NSAttributedString alloc] - initWithString:@"\u2026" - attributes:ellipsisAttributes]]; - } - *stop = YES; - return; - } - - // Fully visible line. Append it verbatim; if it was soft-wrapped - // (does not already end in a newline) insert one so Core Text - // reproduces the same line break. - NSAttributedString *lineString = - [textStorage attributedSubstringFromRange:lineCharRange]; - [truncated appendAttributedString:lineString]; - NSString *lineText = lineString.string; - if (lineText.length > 0 && [lineText characterAtIndex:lineText.length - 1] != '\n' && - NSMaxRange(lineCharRange) < textStorage.length) { - NSDictionary *newlineAttributes = - [textStorage attributesAtIndex:NSMaxRange(lineCharRange) - 1 effectiveRange:NULL]; - [truncated appendAttributedString:[[NSAttributedString alloc] - initWithString:@"\n" - attributes:newlineAttributes]]; - } - }]; - - return truncated.length > 0 ? truncated : textStorage; -} - - (TextMeasurement)measureNSAttributedString:(NSAttributedString *)attributedString paragraphAttributes:(ParagraphAttributes)paragraphAttributes layoutContext:(TextLayoutContext)layoutContext