From f86046cf72f54ce5a62435de1ff1ffe48b5cb172 Mon Sep 17 00:00:00 2001 From: Andrew Kunkel Date: Wed, 22 Jul 2026 18:47:57 -0700 Subject: [PATCH 1/4] 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 0d92ec3dee0f..d23c6df3dd36 100644 --- a/packages/react-native/Libraries/Text/Text/RCTTextView.mm +++ b/packages/react-native/Libraries/Text/Text/RCTTextView.mm @@ -23,6 +23,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; @@ -156,14 +237,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); @@ -187,7 +274,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 1b0ae8c85016..54d1af1ad5ab 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 49c1513c2f85467476eed94d954d9ef5eaa1d07c Mon Sep 17 00:00:00 2001 From: Andrew Kunkel Date: Thu, 23 Jul 2026 12:29:21 -0700 Subject: [PATCH 2/4] 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 d23c6df3dd36..32ad53b87a5f 100644 --- a/packages/react-native/Libraries/Text/Text/RCTTextView.mm +++ b/packages/react-native/Libraries/Text/Text/RCTTextView.mm @@ -10,6 +10,7 @@ #import #import +#import #import #import @@ -23,87 +24,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 54d1af1ad5ab..290f182ca270 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 From 88efe6b881da81a0b88ddb5a8f2f9d620faaf834 Mon Sep 17 00:00:00 2001 From: Andrew Kunkel Date: Fri, 24 Jul 2026 14:05:09 -0700 Subject: [PATCH 3/4] Simplifying logic and focusing on Fabric only. --- .../Libraries/Text/Text/RCTTextView.mm | 13 +-- .../react-native/React/Base/RCTTextStroke.h | 33 -------- .../react-native/React/Base/RCTTextStroke.mm | 82 ------------------- .../textlayoutmanager/RCTTextLayoutManager.mm | 72 +++++++++++++++- 4 files changed, 73 insertions(+), 127 deletions(-) delete mode 100644 packages/react-native/React/Base/RCTTextStroke.h delete 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 32ad53b87a5f..0d92ec3dee0f 100644 --- a/packages/react-native/Libraries/Text/Text/RCTTextView.mm +++ b/packages/react-native/Libraries/Text/Text/RCTTextView.mm @@ -10,7 +10,6 @@ #import #import -#import #import #import @@ -157,20 +156,14 @@ - (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 = [visibleText mutableCopy]; + NSMutableAttributedString *strokeText = [_textStorage mutableCopy]; [strokeText addAttribute:NSForegroundColorAttributeName value:strokeColor - range:visibleRange]; + range:characterRange]; CGContextSetTextMatrix(context, CGAffineTransformIdentity); CGContextTranslateCTM(context, _contentFrame.origin.x + strokeInset, self.bounds.size.height - _contentFrame.origin.y + strokeInset); @@ -194,7 +187,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)visibleText); + framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)_textStorage); 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/React/Base/RCTTextStroke.h b/packages/react-native/React/Base/RCTTextStroke.h deleted file mode 100644 index f6ca0952452c..000000000000 --- a/packages/react-native/React/Base/RCTTextStroke.h +++ /dev/null @@ -1,33 +0,0 @@ -/* - * 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 deleted file mode 100644 index 0f9d1df02b3c..000000000000 --- a/packages/react-native/React/Base/RCTTextStroke.mm +++ /dev/null @@ -1,82 +0,0 @@ -/* - * 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 290f182ca270..7d2361ee50d2 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,7 +11,6 @@ #import #import -#import #import #import #import @@ -57,6 +56,75 @@ 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 returns that same visible, + * truncated text (the visible prefix + an ellipsis) so the stroke passes match the non-stroke path. + * + * The string is only rebuilt when TextKit actually truncated something. Text that fits its line limit + * - including multi-line text that soft-wraps within the limit - is returned unchanged, so Core Text + * keeps doing its own wrapping exactly as it did before this workaround existed. + * + * NOTE: Written for TAIL truncation (`NSLineBreakByTruncatingTail`), the only mode used by stroke + * effects. Tail truncation always lands on the last laid-out line, which is why the visible text is a + * contiguous prefix. `truncatedGlyphRangeInLineFragmentForGlyphAtIndex:` only reports a range for + * truncating line break modes, so non-truncating modes (e.g. clipping) return the full string. Head + * and middle truncation would report a range but place the ellipsis at the start/middle, so they + * would need mode-specific handling before they could be used with a stroke effect. + */ +static NSAttributedString *getTruncatedAttributedStringForStroke( + 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; + } + + // Tail truncation always lands on the last laid-out line, so the truncated fragment can be found + // from the final glyph instead of walking every fragment. `truncatedGlyphRangeInLineFragment...` + // is documented in terms of a fragment rather than an arbitrary glyph, so resolve the fragment's + // own glyph range first and query with its first glyph. + NSRange lastLineGlyphRange; + [layoutManager lineFragmentRectForGlyphAtIndex:NSMaxRange(fullGlyphRange) - 1 effectiveRange:&lastLineGlyphRange]; + NSRange truncatedGlyphRange = + [layoutManager truncatedGlyphRangeInLineFragmentForGlyphAtIndex:lastLineGlyphRange.location]; + + // Nothing was truncated, so every wrapped line fits the frame and Core Text can lay out the full + // string itself. This is the path multi-line text that fits its line limit takes. + if (truncatedGlyphRange.location == NSNotFound) { + return textStorage; + } + + NSRange truncatedCharRange = [layoutManager characterRangeForGlyphRange:truncatedGlyphRange actualGlyphRange:NULL]; + if (truncatedCharRange.location == 0 || truncatedCharRange.location > textStorage.length) { + return textStorage; + } + + // Everything before the truncation point is visible, and TextKit sized that prefix to leave room + // for the ellipsis it draws over the truncated range - so prefix + ellipsis is exactly what the + // non-stroke path renders, and it re-wraps to the same lines under the same container width. + NSMutableAttributedString *truncated = + [[textStorage attributedSubstringFromRange:NSMakeRange(0, truncatedCharRange.location)] mutableCopy]; + NSDictionary *ellipsisAttributes = + [textStorage attributesAtIndex:truncated.length - 1 effectiveRange:NULL]; + [truncated appendAttributedString:[[NSAttributedString alloc] initWithString:@"\u2026" + attributes:ellipsisAttributes]]; + + return truncated; +} + - (TextMeasurement)measureNSAttributedString:(NSAttributedString *)attributedString paragraphAttributes:(ParagraphAttributes)paragraphAttributes layoutContext:(TextLayoutContext)layoutContext @@ -147,7 +215,7 @@ - (void)drawAttributedString:(AttributedString)attributedString // 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); + getTruncatedAttributedStringForStroke(textStorage, layoutManager, textContainer); NSRange visibleRange = NSMakeRange(0, visibleText.length); // PASS 1: Draw stroke outline From 6837fe7b0a9ffe8059d053c300ff0e319db75db3 Mon Sep 17 00:00:00 2001 From: Andrew Kunkel Date: Tue, 28 Jul 2026 12:13:41 -0700 Subject: [PATCH 4/4] Adding some small fixes suggested by Claude. --- .../textlayoutmanager/RCTTextLayoutManager.mm | 33 +++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) 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 7d2361ee50d2..27c54ba6102a 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 @@ -68,21 +68,20 @@ static CGFloat getStrokeWidth(NSAttributedString *attributedString) * - including multi-line text that soft-wraps within the limit - is returned unchanged, so Core Text * keeps doing its own wrapping exactly as it did before this workaround existed. * - * NOTE: Written for TAIL truncation (`NSLineBreakByTruncatingTail`), the only mode used by stroke - * effects. Tail truncation always lands on the last laid-out line, which is why the visible text is a - * contiguous prefix. `truncatedGlyphRangeInLineFragmentForGlyphAtIndex:` only reports a range for - * truncating line break modes, so non-truncating modes (e.g. clipping) return the full string. Head - * and middle truncation would report a range but place the ellipsis at the start/middle, so they - * would need mode-specific handling before they could be used with a stroke effect. + * Only TAIL truncation (`NSLineBreakByTruncatingTail`) is handled, and the check below enforces that. + * Tail truncation always lands on the last laid-out line, which is why the visible text is a + * contiguous prefix. Head and middle truncation also report a truncated range, but they keep the tail + * on screen, so treating their visible text as a prefix would drop text that is still visible. */ static NSAttributedString *getTruncatedAttributedStringForStroke( 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) { + // A non-tail mode is either not truncating at all (no line limit means clipping, and the frame was + // measured to fit every wrapped line) or is a mode this function cannot express as a prefix. Either + // way, Core Text lays out the full string as it did before this workaround existed. + if (textContainer.lineBreakMode != NSLineBreakByTruncatingTail) { return textStorage; } @@ -115,8 +114,22 @@ static CGFloat getStrokeWidth(NSAttributedString *attributedString) // Everything before the truncation point is visible, and TextKit sized that prefix to leave room // for the ellipsis it draws over the truncated range - so prefix + ellipsis is exactly what the // non-stroke path renders, and it re-wraps to the same lines under the same container width. + // + // Trailing whitespace is dropped first. TextKit lets it hang past the container edge for free, but + // with an ellipsis after it, it counts toward the line width and can push the ellipsis onto a line + // the frame cannot hold - reintroducing the very bug this function exists to fix. A trailing + // newline would do so unconditionally. + NSUInteger visibleLength = truncatedCharRange.location; + NSCharacterSet *whitespace = [NSCharacterSet whitespaceAndNewlineCharacterSet]; + while (visibleLength > 0 && [whitespace characterIsMember:[textStorage.string characterAtIndex:visibleLength - 1]]) { + visibleLength--; + } + if (visibleLength == 0) { + return textStorage; + } + NSMutableAttributedString *truncated = - [[textStorage attributedSubstringFromRange:NSMakeRange(0, truncatedCharRange.location)] mutableCopy]; + [[textStorage attributedSubstringFromRange:NSMakeRange(0, visibleLength)] mutableCopy]; NSDictionary *ellipsisAttributes = [textStorage attributesAtIndex:truncated.length - 1 effectiveRange:NULL]; [truncated appendAttributedString:[[NSAttributedString alloc] initWithString:@"\u2026"