diff --git a/packages/react-native/Libraries/Text/Text/RCTTextView.mm b/packages/react-native/Libraries/Text/Text/RCTTextView.mm index b007efffadcd..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 @@ -158,14 +159,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 +196,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/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 9e6c0b4449d5..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 @@ -143,12 +144,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 +181,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);