diff --git a/packages/react-native/Libraries/Text/Text/RCTTextShadowView.mm b/packages/react-native/Libraries/Text/Text/RCTTextShadowView.mm index 0b34a8629bd257..23ab38142bc113 100644 --- a/packages/react-native/Libraries/Text/Text/RCTTextShadowView.mm +++ b/packages/react-native/Libraries/Text/Text/RCTTextShadowView.mm @@ -398,35 +398,20 @@ - (void)layoutSubviewsWithContext:(RCTLayoutContext)layoutContext - (CGFloat)lastBaselineForSize:(CGSize)size { - NSAttributedString *attributedText = [self textStorageAndLayoutManagerThatFitsSize:size exclusiveOwnership:NO]; + NSTextStorage *textStorage = [self textStorageAndLayoutManagerThatFitsSize:size exclusiveOwnership:NO]; __block CGFloat maximumDescender = 0.0; - [attributedText enumerateAttribute:NSFontAttributeName - inRange:NSMakeRange(0, attributedText.length) - options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired - usingBlock:^(UIFont *font, NSRange range, __unused BOOL *stop) { - if (maximumDescender > font.descender) { - maximumDescender = font.descender; - } - }]; - - // Account for stroke width in baseline calculation - __block CGFloat strokeWidth = 0; - [attributedText enumerateAttribute:@"RCTTextStrokeWidth" - inRange:NSMakeRange(0, attributedText.length) - options:0 - usingBlock:^(id value, NSRange range, BOOL *stop) { - if (value && [value isKindOfClass:[NSNumber class]]) { - CGFloat width = [value floatValue]; - if (width > 0) { - strokeWidth = MAX(strokeWidth, width); - *stop = YES; - } - } - }]; + [textStorage enumerateAttribute:NSFontAttributeName + inRange:NSMakeRange(0, textStorage.length) + options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired + usingBlock:^(UIFont *font, NSRange range, __unused BOOL *stop) { + if (maximumDescender > font.descender) { + maximumDescender = font.descender; + } + }]; - return size.height + maximumDescender + strokeWidth; + return size.height + maximumDescender; } static YGSize RCTTextShadowViewMeasure( @@ -456,27 +441,6 @@ static YGSize RCTTextShadowViewMeasure( size.width -= letterSpacing; } - // Account for text stroke width (similar to Android implementation) - // Check if text has custom stroke attribute and add extra space - __block CGFloat strokeWidth = 0; - [textStorage enumerateAttribute:@"RCTTextStrokeWidth" - inRange:NSMakeRange(0, textStorage.length) - options:0 - usingBlock:^(id value, NSRange range, BOOL *stop) { - if (value && [value isKindOfClass:[NSNumber class]]) { - CGFloat width = [value floatValue]; - if (width > 0) { - strokeWidth = MAX(strokeWidth, width); - *stop = YES; - } - } - }]; - - if (strokeWidth > 0) { - size.width += strokeWidth; - size.height += strokeWidth; - } - size = (CGSize){ MIN(RCTCeilPixelValue(size.width), maximumSize.width), MIN(RCTCeilPixelValue(size.height), maximumSize.height)}; diff --git a/packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.h b/packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.h new file mode 100644 index 00000000000000..f133e9e3911e1a --- /dev/null +++ b/packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.h @@ -0,0 +1,8 @@ +#import + +@interface RCTTextStrokeRenderer : NSObject + ++ (BOOL)drawStrokedText:(NSTextStorage *)textStorage + contentFrame:(CGRect)contentFrame; + +@end diff --git a/packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.mm b/packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.mm new file mode 100644 index 00000000000000..da98d579bc3e70 --- /dev/null +++ b/packages/react-native/Libraries/Text/Text/RCTTextStrokeRenderer.mm @@ -0,0 +1,88 @@ +#import "RCTTextStrokeRenderer.h" + +@implementation RCTTextStrokeRenderer + ++ (BOOL)drawStrokedText:(NSTextStorage *)textStorage + contentFrame:(CGRect)contentFrame +{ + CGContextRef context = UIGraphicsGetCurrentContext(); + if (!textStorage || textStorage.length == 0 || !context) { + return NO; + } + + NSLayoutManager *layoutManager = textStorage.layoutManagers.firstObject; + NSTextContainer *textContainer = layoutManager.textContainers.firstObject; + if (!layoutManager || !textContainer) { + return NO; + } + + NSRange glyphRange = [layoutManager glyphRangeForTextContainer:textContainer]; + NSRange characterRange = [layoutManager characterRangeForGlyphRange:glyphRange actualGlyphRange:NULL]; + + __block CGFloat strokeWidth = 0; + __block UIColor *strokeColor = nil; + + [textStorage enumerateAttribute:@"RCTTextStrokeWidth" + inRange:characterRange + options:0 + usingBlock:^(NSNumber *value, NSRange range, BOOL *stop) { + if (value) { + CGFloat width = value.floatValue; + if (width > 0) { + strokeWidth = width; + strokeColor = [textStorage attribute:@"RCTTextStrokeColor" + atIndex:range.location + effectiveRange:nil]; + *stop = YES; + } + } + }]; + + if (strokeWidth <= 0 || !strokeColor) { + return NO; + } + + // Save original foreground colors + NSMutableArray *originalColors = [NSMutableArray array]; + [textStorage enumerateAttribute:NSForegroundColorAttributeName + inRange:characterRange + options:0 + usingBlock:^(UIColor *color, NSRange range, BOOL *stop) { + [originalColors addObject:@{ + @"color": color ?: [UIColor blackColor], + @"range": [NSValue valueWithRange:range] + }]; + }]; + + // PASS 1: Draw stroke - temporarily set foreground to stroke color + [textStorage beginEditing]; + [textStorage addAttribute:NSForegroundColorAttributeName value:strokeColor range:characterRange]; + [textStorage endEditing]; + + CGContextSaveGState(context); + CGContextSetTextDrawingMode(context, kCGTextStroke); + CGContextSetLineWidth(context, strokeWidth); + CGContextSetLineJoin(context, kCGLineJoinRound); + CGContextSetLineCap(context, kCGLineCapRound); + [layoutManager drawGlyphsForGlyphRange:glyphRange atPoint:contentFrame.origin]; + CGContextRestoreGState(context); + + // Restore original foreground colors + [textStorage beginEditing]; + for (NSDictionary *colorInfo in originalColors) { + UIColor *color = colorInfo[@"color"]; + NSRange range = [colorInfo[@"range"] rangeValue]; + [textStorage addAttribute:NSForegroundColorAttributeName value:color range:range]; + } + [textStorage endEditing]; + + // PASS 2: Draw fill on top using NSLayoutManager + CGContextSaveGState(context); + CGContextSetTextDrawingMode(context, kCGTextFill); + [layoutManager drawGlyphsForGlyphRange:glyphRange atPoint:contentFrame.origin]; + CGContextRestoreGState(context); + + return YES; +} + +@end diff --git a/packages/react-native/Libraries/Text/Text/RCTTextView.mm b/packages/react-native/Libraries/Text/Text/RCTTextView.mm index 0d92ec3dee0ff1..a334c1c30cd896 100644 --- a/packages/react-native/Libraries/Text/Text/RCTTextView.mm +++ b/packages/react-native/Libraries/Text/Text/RCTTextView.mm @@ -14,6 +14,7 @@ #import #import +#import "RCTTextStrokeRenderer.h" #import @@ -108,9 +109,9 @@ - (void)drawRect:(CGRect)rect NSLayoutManager *layoutManager = _textStorage.layoutManagers.firstObject; NSTextContainer *textContainer = layoutManager.textContainers.firstObject; + CGContextRef context = UIGraphicsGetCurrentContext(); #if TARGET_OS_MACCATALYST - CGContextRef context = UIGraphicsGetCurrentContext(); CGContextSaveGState(context); // NSLayoutManager tries to draw text with sub-pixel anti-aliasing by default on // macOS, but rendering SPAA onto a transparent background produces poor results. @@ -121,86 +122,15 @@ - (void)drawRect:(CGRect)rect NSRange glyphRange = [layoutManager glyphRangeForTextContainer:textContainer]; [layoutManager drawBackgroundForGlyphRange:glyphRange atPoint:_contentFrame.origin]; - // Check if text has custom stroke attribute - NSRange characterRange = [layoutManager characterRangeForGlyphRange:glyphRange actualGlyphRange:NULL]; - __block BOOL hasStroke = NO; - __block CGFloat strokeWidth = 0; - __block UIColor *strokeColor = nil; - - [_textStorage enumerateAttribute:@"RCTTextStrokeWidth" - inRange:characterRange - options:0 - usingBlock:^(id value, NSRange range, BOOL *stop) { - if (value && [value isKindOfClass:[NSNumber class]]) { - CGFloat width = [value floatValue]; - if (width > 0) { - hasStroke = YES; - strokeWidth = width; - strokeColor = [_textStorage attribute:@"RCTTextStrokeColor" atIndex:range.location effectiveRange:NULL]; - - if (strokeColor) { - CGFloat r, g, b, a; - [strokeColor getRed:&r green:&g blue:&b alpha:&a]; - } - *stop = YES; - } - } - }]; - - if (hasStroke && strokeColor) { - CGContextRef context = UIGraphicsGetCurrentContext(); - - CGContextSetLineWidth(context, strokeWidth); - CGContextSetLineJoin(context, kCGLineJoinRound); - CGContextSetLineCap(context, kCGLineCapRound); - - CGFloat strokeInset = strokeWidth / 2; - - // PASS 1: Draw stroke outline - CGContextSaveGState(context); - CGContextSetTextDrawingMode(context, kCGTextStroke); - - NSMutableAttributedString *strokeText = [_textStorage mutableCopy]; - [strokeText addAttribute:NSForegroundColorAttributeName - value:strokeColor - range:characterRange]; - - CGContextSetTextMatrix(context, CGAffineTransformIdentity); - CGContextTranslateCTM(context, _contentFrame.origin.x + strokeInset, self.bounds.size.height - _contentFrame.origin.y + strokeInset); - CGContextScaleCTM(context, 1.0, -1.0); - - CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)strokeText); - CGMutablePathRef path = CGPathCreateMutable(); - CGPathAddRect(path, NULL, CGRectMake(0, 0, _contentFrame.size.width, _contentFrame.size.height)); - CTFrameRef frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, NULL); - CTFrameDraw(frame, context); - CFRelease(frame); - CFRelease(path); - CFRelease(framesetter); - CGContextRestoreGState(context); - - // PASS 2: Draw fill on top - CGContextSaveGState(context); - CGContextSetTextDrawingMode(context, kCGTextFill); - - CGContextSetTextMatrix(context, CGAffineTransformIdentity); - CGContextTranslateCTM(context, _contentFrame.origin.x + strokeInset, self.bounds.size.height - _contentFrame.origin.y + strokeInset); - CGContextScaleCTM(context, 1.0, -1.0); - - 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); - CTFrameDraw(frame, context); - CFRelease(frame); - CFRelease(path); - CFRelease(framesetter); - CGContextRestoreGState(context); + // Use stroke renderer if stroke attributes are present, otherwise use standard glyph drawing + BOOL didDrawStroke = [RCTTextStrokeRenderer drawStrokedText:_textStorage + contentFrame:_contentFrame]; - } else { + if (!didDrawStroke) { [layoutManager drawGlyphsForGlyphRange:glyphRange atPoint:_contentFrame.origin]; } + NSRange characterRange = [layoutManager characterRangeForGlyphRange:glyphRange actualGlyphRange:NULL]; __block UIBezierPath *highlightPath = nil; [_textStorage enumerateAttribute:RCTTextAttributesIsHighlightedAttributeName diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt index c0a9d3e147aab5..4b2e0e44657037 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt @@ -27,8 +27,8 @@ import com.facebook.proguard.annotations.DoNotStrip import com.facebook.react.uimanager.BackgroundStyleApplicator import com.facebook.react.uimanager.ReactCompoundView import com.facebook.react.uimanager.style.Overflow -import com.facebook.react.views.text.internal.span.DiscordShadowStyleSpan import com.facebook.react.views.text.internal.span.ReactTagSpan +import com.facebook.react.views.text.internal.span.StrokeStyleSpan import kotlin.collections.ArrayList import kotlin.math.roundToInt @@ -43,6 +43,7 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re private var clickableSpans: List = emptyList() private var selection: TextSelection? = null + private var strokeWidth: Float = 0f var preparedLayout: PreparedLayout? = null set(value) { @@ -59,6 +60,17 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re clickableSpans = value?.layout?.text?.let { filterClickableSpans(it) } ?: emptyList() + // Check if text has stroke spans and disable clipping to prevent stroke from being clipped + val stroke = (value?.layout?.text as? Spanned)?.let { StrokeStyleSpan.getStrokeSpan(it) } + strokeWidth = stroke?.strokeWidth ?: 0f + clipToPadding = stroke == null + + val textPreview = value?.layout?.text?.toString()?.take(20) ?: "null" + android.util.Log.d("TextDebug", "=== PreparedLayout setter ===") + android.util.Log.d("TextDebug", "Text: $textPreview") + android.util.Log.d("TextDebug", "StrokeSpan found: ${stroke != null}") + android.util.Log.d("TextDebug", "StrokeWidth set to: $strokeWidth") + field = value invalidate() } @@ -91,29 +103,37 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re clickableSpans = emptyList() selection = null preparedLayout = null + strokeWidth = 0f } fun recycleView(): Unit { initView() BackgroundStyleApplicator.reset(this) overflow = Overflow.HIDDEN + clipToPadding = true } override fun onDraw(canvas: Canvas) { val layout = preparedLayout?.layout + val hasStroke = strokeWidth > 0f - // Get shadow adjustment from custom span if configured - val spanned = layout?.text as? Spanned - val shadowAdj = DiscordShadowStyleSpan.getShadowAdjustment(spanned) + android.util.Log.d("TextDebug", "=== PreparedLayout onDraw ===") + android.util.Log.d("TextDebug", "View: ${width}x${height}") + android.util.Log.d("TextDebug", "Clip: ${canvas.clipBounds}") + android.util.Log.d("TextDebug", "StrokeWidth: $strokeWidth") + android.util.Log.d("TextDebug", "Padding L/T/R/B: $paddingLeft/$paddingTop/$paddingRight/$paddingBottom") - if (overflow != Overflow.VISIBLE && !shadowAdj.hasShadow) { + // Skip clipping when stroke spans are present to prevent stroke from being clipped + if (overflow != Overflow.VISIBLE && !hasStroke) { BackgroundStyleApplicator.clipToPaddingBox(this, canvas) } super.onDraw(canvas) + // paddingLeft/paddingTop: standard offset for text content to respect view padding + // When stroke is present, LeadingMarginSpan handles the left margin inside the Layout canvas.translate( - paddingLeft.toFloat() + shadowAdj.leftOffset, + paddingLeft.toFloat(), paddingTop.toFloat() + (preparedLayout?.verticalOffset ?: 0f)) if (layout != null) { @@ -122,6 +142,10 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re selectionColor ?: DefaultStyleValuesUtil.getDefaultTextColorHighlight(context)) } + // Draw shadow pass first (underneath everything) + //DiscordShadowStyleSpan.drawShadowPassWithLayout(layout, canvas, 0f, 0f) + + // Draw text (stroke is handled by StrokeStyleSpan via CharacterStyle) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { Api34Utils.draw(layout, canvas, selection?.path, selectionPaint) } else { @@ -130,6 +154,10 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re } } + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + } + override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { // No-op } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java index f97dbed7a34ffd..65626c670c778b 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java @@ -13,7 +13,9 @@ import android.text.Layout; import android.text.Spannable; import android.text.SpannableStringBuilder; +import android.text.Spanned; import android.text.TextUtils; +import android.text.style.LeadingMarginSpan; import android.view.Gravity; import androidx.annotation.Nullable; import com.facebook.common.logging.FLog; @@ -48,7 +50,7 @@ import com.facebook.react.views.text.internal.span.ReactTagSpan; import com.facebook.react.views.text.internal.span.ReactUnderlineSpan; import com.facebook.react.views.text.internal.span.SetSpanOperation; -import com.facebook.react.views.text.internal.span.DiscordShadowStyleSpan; +import com.facebook.react.views.text.internal.span.ShadowStyleSpan; import com.facebook.react.views.text.internal.span.StrokeStyleSpan; import com.facebook.react.views.text.internal.span.TextInlineImageSpan; import com.facebook.react.views.text.internal.span.TextInlineViewPlaceholderSpan; @@ -171,7 +173,11 @@ private static void buildSpannedFromShadowNode( } int end = sb.length(); if (end >= start) { - if (textShadowNode.mIsColorSet) { + // Skip ReactForegroundColorSpan when stroke is present - StrokeStyleSpan handles both stroke and fill colors + boolean hasStroke = !Float.isNaN(textShadowNode.mTextStrokeWidth) + && textShadowNode.mTextStrokeWidth > 0 + && textShadowNode.mIsTextStrokeColorSet; + if (textShadowNode.mIsColorSet && !hasStroke) { ops.add(new SetSpanOperation(start, end, new ReactForegroundColorSpan(textShadowNode.mColor))); } if (textShadowNode.mGradientColors != null && textShadowNode.mGradientColors.length >= 2) { @@ -234,30 +240,37 @@ private static void buildSpannedFromShadowNode( new SetSpanOperation( start, end, - new DiscordShadowStyleSpan( + new ShadowStyleSpan( textShadowNode.mTextShadowOffsetDx, textShadowNode.mTextShadowOffsetDy, textShadowNode.mTextShadowRadius, textShadowNode.mTextShadowColor))); } + float effectiveLineHeight = textAttributes.getEffectiveLineHeight(); + if (!Float.isNaN(effectiveLineHeight) + && (parentTextAttributes == null + || parentTextAttributes.getEffectiveLineHeight() != effectiveLineHeight)) { + ops.add(new SetSpanOperation(start, end, new CustomLineHeightSpan(effectiveLineHeight))); + } + ops.add(new SetSpanOperation(start, end, new ReactTagSpan(textShadowNode.getReactTag()))); + + // Add StrokeStyleSpan LAST so it has lowest priority index, which means highest priority, + // and gets applied LAST during drawing, ensuring our color overwrites any ForegroundColorSpan if (!Float.isNaN(textShadowNode.mTextStrokeWidth) && textShadowNode.mTextStrokeWidth > 0 && textShadowNode.mIsTextStrokeColorSet) { + String debugText = sb.subSequence(start, end).toString(); + android.util.Log.d("TextDebug", "=== ReactBaseTextShadowNode adding StrokeStyleSpan (LAST) ==="); + android.util.Log.d("TextDebug", "strokeWidth: " + textShadowNode.mTextStrokeWidth + ", start: " + start + ", end: " + end + ", text: '" + debugText + "', strokeColor: " + textShadowNode.mTextStrokeColor + " (hex: " + Integer.toHexString(textShadowNode.mTextStrokeColor) + ")"); ops.add( new SetSpanOperation( start, end, new StrokeStyleSpan( textShadowNode.mTextStrokeWidth, - textShadowNode.mTextStrokeColor))); + textShadowNode.mTextStrokeColor, + debugText))); } - float effectiveLineHeight = textAttributes.getEffectiveLineHeight(); - if (!Float.isNaN(effectiveLineHeight) - && (parentTextAttributes == null - || parentTextAttributes.getEffectiveLineHeight() != effectiveLineHeight)) { - ops.add(new SetSpanOperation(start, end, new CustomLineHeightSpan(effectiveLineHeight))); - } - ops.add(new SetSpanOperation(start, end, new ReactTagSpan(textShadowNode.getReactTag()))); } } @@ -335,6 +348,21 @@ protected Spannable spannedFromShadowNode( textShadowNode.mTextAttributes.setHeightOfTallestInlineViewOrImage( heightOfTallestInlineViewOrImage); + // Add leading margin if stroke is present to give room for the stroke on the left side + // Note: JS side should add padding equal to strokeWidth to prevent early text wrapping + StrokeStyleSpan strokeSpan = StrokeStyleSpan.getStrokeSpan(sb); + if (strokeSpan != null) { + int strokeMargin = (int) Math.ceil(strokeSpan.getStrokeWidth()); + android.util.Log.d("TextDebug", "=== ReactBaseTextShadowNode adding LeadingMarginSpan ==="); + android.util.Log.d("TextDebug", "strokeWidth: " + strokeSpan.getStrokeWidth() + ", strokeMargin: " + strokeMargin); + android.util.Log.d("TextDebug", "spannable length: " + sb.length()); + sb.setSpan( + new LeadingMarginSpan.Standard(strokeMargin, strokeMargin), + 0, + sb.length(), + Spanned.SPAN_INCLUSIVE_INCLUSIVE); + } + if (mReactTextViewManagerCallback != null) { mReactTextViewManagerCallback.onPostProcessSpannable(sb); } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java index 05ab157865940d..03f5bfc680acb3 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java @@ -10,6 +10,7 @@ import android.content.Context; import android.graphics.Canvas; import android.graphics.Rect; +import android.util.Log; import android.graphics.drawable.Drawable; import android.os.Build; import android.text.Layout; @@ -52,8 +53,8 @@ import com.facebook.react.uimanager.style.BorderStyle; import com.facebook.react.uimanager.style.LogicalEdge; import com.facebook.react.uimanager.style.Overflow; -import com.facebook.react.views.text.internal.span.DiscordShadowStyleSpan; import com.facebook.react.views.text.internal.span.ReactTagSpan; +import com.facebook.react.views.text.internal.span.StrokeStyleSpan; import com.facebook.react.views.text.internal.span.TextInlineImageSpan; import com.facebook.react.views.text.internal.span.TextInlineViewPlaceholderSpan; import com.facebook.yoga.YogaMeasureMode; @@ -78,6 +79,7 @@ public class ReactTextView extends AppCompatTextView implements ReactCompoundVie private boolean mTextIsSelectable; private boolean mShouldAdjustSpannableFontSize; private Overflow mOverflow = Overflow.VISIBLE; + private float mStrokeWidth = 0f; private @Nullable Spannable mSpanned; @@ -103,6 +105,7 @@ private void initView() { mLetterSpacing = 0.f; mOverflow = Overflow.VISIBLE; mSpanned = null; + mStrokeWidth = 0f; } /* package */ void recycleView() { @@ -340,6 +343,14 @@ protected void onLayout( protected void onDraw(Canvas canvas) { try (SystraceSection s = new SystraceSection("ReactTextView.onDraw")) { Spannable spanned = getSpanned(); + + // Log paint state before drawing + android.text.TextPaint paint = getPaint(); + Log.d("TextDebug", "=== ReactTextView onDraw START ==="); + Log.d("TextDebug", "Text: " + (spanned != null ? spanned.toString().substring(0, Math.min(20, spanned.length())) : "null")); + Log.d("TextDebug", "StrokeWidth: " + mStrokeWidth); + Log.d("TextDebug", "Paint BEFORE draw: color=" + paint.getColor() + " (hex=" + Integer.toHexString(paint.getColor()) + "), style=" + paint.getStyle() + ", strokeWidth=" + paint.getStrokeWidth()); + if (mAdjustsFontSizeToFit && spanned != null && mShouldAdjustSpannableFontSize) { mShouldAdjustSpannableFontSize = false; TextLayoutManager.adjustSpannableFontToFit( @@ -361,19 +372,26 @@ protected void onDraw(Canvas canvas) { setText(spanned); } - // Get shadow adjustment from custom span if configured - DiscordShadowStyleSpan.ShadowAdjustment shadowAdj = - DiscordShadowStyleSpan.getShadowAdjustment(spanned); - - canvas.save(); - canvas.translate(shadowAdj.getLeftOffset(), 0); + boolean hasStroke = mStrokeWidth > 0f; - if (mOverflow != Overflow.VISIBLE && !shadowAdj.getHasShadow()) { + if (mOverflow != Overflow.VISIBLE && !hasStroke) { + canvas.save(); BackgroundStyleApplicator.clipToPaddingBox(this, canvas); } + // Draw shadow pass first (underneath everything) + //DiscordShadowStyleSpan.drawShadowPass(this, canvas); + + // Draw text - when stroke is present, LeadingMarginSpan handles the left margin inside the Layout super.onDraw(canvas); - canvas.restore(); + + // Log paint state after drawing + Log.d("TextDebug", "Paint AFTER draw: color=" + paint.getColor() + " (hex=" + Integer.toHexString(paint.getColor()) + "), style=" + paint.getStyle() + ", strokeWidth=" + paint.getStrokeWidth()); + Log.d("TextDebug", "=== ReactTextView onDraw END ==="); + + if (mOverflow != Overflow.VISIBLE && !hasStroke) { + canvas.restore(); + } } } @@ -717,6 +735,15 @@ public void setBorderStyle(@Nullable String style) { public void setSpanned(Spannable spanned) { mSpanned = spanned; mShouldAdjustSpannableFontSize = true; + + // Check for stroke span and store stroke width + StrokeStyleSpan strokeSpan = StrokeStyleSpan.getStrokeSpan(spanned); + mStrokeWidth = strokeSpan != null ? strokeSpan.getStrokeWidth() : 0f; + + Log.d("TextDebug", "=== setSpanned ==="); + Log.d("TextDebug", "Text: " + (spanned != null ? spanned.toString().substring(0, Math.min(20, spanned.length())) : "null")); + Log.d("TextDebug", "StrokeSpan found: " + (strokeSpan != null)); + Log.d("TextDebug", "StrokeWidth set to: " + mStrokeWidth); } public @Nullable Spannable getSpanned() { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt index 735dd1f87488eb..7fbd9efa98fd5a 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt @@ -18,6 +18,7 @@ import android.text.SpannableString import android.text.SpannableStringBuilder import android.text.Spanned import android.text.StaticLayout +import android.text.style.LeadingMarginSpan import android.text.TextDirectionHeuristics import android.text.TextPaint import android.text.TextUtils @@ -47,8 +48,8 @@ import com.facebook.react.views.text.internal.span.ReactOpacitySpan import com.facebook.react.views.text.internal.span.ReactStrikethroughSpan import com.facebook.react.views.text.internal.span.ReactTagSpan import com.facebook.react.views.text.internal.span.ReactTextPaintHolderSpan -import com.facebook.react.views.text.internal.span.DiscordShadowStyleSpan import com.facebook.react.views.text.internal.span.ReactUnderlineSpan +import com.facebook.react.views.text.internal.span.ShadowStyleSpan import com.facebook.react.views.text.internal.span.SetSpanOperation import com.facebook.react.views.text.internal.span.StrokeStyleSpan import com.facebook.react.views.text.internal.span.TextInlineViewPlaceholderSpan @@ -253,7 +254,11 @@ internal object TextLayoutManager { if (roleIsLink) { ops.add(SetSpanOperation(start, end, ReactClickableSpan(reactTag))) } - if (textAttributes.mIsColorSet) { + // Skip ReactForegroundColorSpan when stroke is present - StrokeStyleSpan handles both stroke and fill colors + val hasStroke = !textAttributes.textStrokeWidth.isNaN() && + textAttributes.textStrokeWidth > 0 && + textAttributes.isTextStrokeColorSet + if (textAttributes.mIsColorSet && !hasStroke) { ops.add(SetSpanOperation(start, end, ReactForegroundColorSpan(textAttributes.mColor))) } if (textAttributes.gradientColors != null && textAttributes.gradientColors!!.size >= 2) { @@ -306,7 +311,7 @@ internal object TextLayoutManager { SetSpanOperation( start, end, - DiscordShadowStyleSpan( + ShadowStyleSpan( textAttributes.mTextShadowOffsetDx, textAttributes.mTextShadowOffsetDy, textAttributes.mTextShadowRadius, @@ -411,7 +416,11 @@ internal object TextLayoutManager { spannable.setSpan(ReactClickableSpan(fragment.reactTag), start, end, spanFlags) } - if (fragment.props.isColorSet) { + // Skip ReactForegroundColorSpan when stroke is present - StrokeStyleSpan handles both stroke and fill colors + val hasStroke = !fragment.props.textStrokeWidth.isNaN() && + fragment.props.textStrokeWidth > 0 && + fragment.props.isTextStrokeColorSet + if (fragment.props.isColorSet && !hasStroke) { spannable.setSpan(ReactForegroundColorSpan(fragment.props.color), start, end, spanFlags) } @@ -470,7 +479,7 @@ internal object TextLayoutManager { fragment.props.textShadowRadius != 0f) && Color.alpha(fragment.props.textShadowColor) != 0) { spannable.setSpan( - DiscordShadowStyleSpan( + ShadowStyleSpan( fragment.props.textShadowOffsetDx, fragment.props.textShadowOffsetDy, fragment.props.textShadowRadius, @@ -484,11 +493,17 @@ internal object TextLayoutManager { fragment.props.textStrokeWidth > 0 && fragment.props.isTextStrokeColorSet) { val strokeWidth = PixelUtil.toPixelFromDIP(fragment.props.textStrokeWidth.toDouble()).toFloat() + val debugText = spannable.subSequence(start, end).toString() + android.util.Log.d("TextDebug", "=== TextLayoutManager adding StrokeStyleSpan ===") + android.util.Log.d("TextDebug", "strokeWidth: $strokeWidth, start: $start, end: $end, text: '$debugText', strokeColor: ${fragment.props.textStrokeColor} (hex: ${Integer.toHexString(fragment.props.textStrokeColor)})") + // Use maximum priority so StrokeStyleSpan is applied LAST during drawing, + // ensuring our color overwrites any ForegroundColorSpan + val strokeSpanFlags = spanFlags or Spannable.SPAN_PRIORITY spannable.setSpan( - StrokeStyleSpan(strokeWidth, fragment.props.textStrokeColor), + StrokeStyleSpan(strokeWidth, fragment.props.textStrokeColor, debugText), start, end, - spanFlags) + strokeSpanFlags) } if (!fragment.props.effectiveLineHeight.isNaN()) { @@ -528,13 +543,13 @@ internal object TextLayoutManager { attributedString: MapBuffer, reactTextViewManagerCallback: ReactTextViewManagerCallback? ): Spannable { + val spannable: Spannable if (ReactNativeFeatureFlags.enableAndroidTextMeasurementOptimizations()) { - val spannable = + spannable = buildSpannableFromFragmentsOptimized( context, attributedString.getMapBuffer(AS_KEY_FRAGMENTS)) reactTextViewManagerCallback?.onPostProcessSpannable(spannable) - return spannable } else { val sb = SpannableStringBuilder() @@ -556,8 +571,33 @@ internal object TextLayoutManager { } reactTextViewManagerCallback?.onPostProcessSpannable(sb) - return sb + spannable = sb } + + // Add leading margin if stroke is present to give room for the stroke on the left side + // Note: JS side should add padding equal to strokeWidth to prevent early text wrapping + val strokeSpan = StrokeStyleSpan.getStrokeSpan(spannable) + if (strokeSpan != null) { + val strokeMargin = Math.ceil(strokeSpan.strokeWidth.toDouble()).toInt() + android.util.Log.d("TextDebug", "=== TextLayoutManager adding LeadingMarginSpan ===") + android.util.Log.d("TextDebug", "strokeWidth: ${strokeSpan.strokeWidth}, strokeMargin: $strokeMargin") + android.util.Log.d("TextDebug", "spannable length: ${spannable.length}") + (spannable as? SpannableStringBuilder)?.setSpan( + LeadingMarginSpan.Standard(strokeMargin, strokeMargin), + 0, + spannable.length, + Spanned.SPAN_INCLUSIVE_INCLUSIVE) + // For SpannableString (from optimized path), we need different handling + if (spannable is SpannableString) { + spannable.setSpan( + LeadingMarginSpan.Standard(strokeMargin, strokeMargin), + 0, + spannable.length, + Spanned.SPAN_INCLUSIVE_INCLUSIVE) + } + } + + return spannable } private fun createLayout( diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/DiscordShadowStyleSpan.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/DiscordShadowStyleSpan.kt index 76afe866758b15..a8f7fa52c804d1 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/DiscordShadowStyleSpan.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/DiscordShadowStyleSpan.kt @@ -8,150 +8,104 @@ package com.facebook.react.views.text.internal.span import android.graphics.Canvas -import android.graphics.Paint import android.text.Spanned -import android.text.style.ReplacementSpan -import kotlin.math.max +import android.text.TextPaint +import android.text.style.CharacterStyle +import android.widget.TextView /** - * A span that applies text shadow with proper bounds calculation. - * Extends ReplacementSpan to control measurement and drawing, ensuring shadows render correctly. - * This is Discord's custom implementation that contains all shadow logic. + * A span that applies text shadow via view-level rendering. + * Uses CharacterStyle to preserve ellipsis behavior - actual shadow rendering is done + * by the companion object's drawShadowPass method called from the view's onDraw. + * Shadow overflows the text bounds (like web behavior). */ public class DiscordShadowStyleSpan( - private val dx: Float, - private val dy: Float, - private val radius: Float, - private val color: Int -) : ReplacementSpan(), ReactSpan { - - // Getters for shadow properties (used by getShadowAdjustment) - public fun getShadowRadius(): Float = radius - public fun getShadowDx(): Float = dx - - override fun getSize( - paint: Paint, - text: CharSequence?, - start: Int, - end: Int, - fm: Paint.FontMetricsInt? - ): Int { - val width = paint.measureText(text, start, end) - - if (fm != null) { - paint.getFontMetricsInt(fm) - - val shadowTopNeeded = max(0f, radius - dy) - val shadowBottomNeeded = max(0f, radius + dy) - - val topExpansion = shadowTopNeeded.toInt() - val bottomExpansion = shadowBottomNeeded.toInt() - - // Adjust font metrics to account for shadow - fm.top -= topExpansion - fm.ascent -= topExpansion - fm.descent += bottomExpansion - fm.bottom += bottomExpansion + public val dx: Float, + public val dy: Float, + public val radius: Float, + public val color: Int +) : CharacterStyle(), ReactSpan { + + override fun updateDrawState(textPaint: TextPaint) { + // No-op: shadow rendering is handled at view level via drawShadowPass + } + + public companion object { + @JvmStatic + public fun getShadowSpan(spanned: Spanned?): DiscordShadowStyleSpan? { + if (spanned == null) return null + val spans = spanned.getSpans(0, spanned.length, DiscordShadowStyleSpan::class.java) + return spans.firstOrNull() } - val shadowLeftNeeded = max(0f, radius - dx) - val shadowRightNeeded = max(0f, radius + dx) + @JvmStatic + public fun hasShadow(spanned: Spanned?): Boolean { + return getShadowSpan(spanned) != null + } - // Subtract 1 pixel to prevent TextView ellipsization while keeping shadow mostly intact - return (width + shadowLeftNeeded + shadowRightNeeded).toInt() - 1 - } + @JvmStatic + public fun drawShadowPass(textView: TextView, canvas: Canvas): Boolean { + val text = textView.text + if (text !is Spanned) return false - override fun draw( - canvas: Canvas, - text: CharSequence?, - start: Int, - end: Int, - x: Float, - top: Int, - y: Int, - bottom: Int, - paint: Paint - ) { - if (text == null) return - - val textToDraw = text.subSequence(start, end).toString() - - // Offset text to keep shadow in positive coordinates - val shadowLeftNeeded = max(0f, radius - dx) - - // Store original shadow settings - val originalShadowRadius = paint.shadowLayerRadius - val originalShadowDx = paint.shadowLayerDx - val originalShadowDy = paint.shadowLayerDy - val originalShadowColor = paint.shadowLayerColor - - paint.setShadowLayer(radius, dx, dy, color) - - if (text is Spanned && paint is android.text.TextPaint) { - val spans = text.getSpans(start, end, android.text.style.CharacterStyle::class.java) - for (span in spans) { - if (span !is DiscordShadowStyleSpan) { - span.updateDrawState(paint) - } - } - } + val span = getShadowSpan(text) ?: return false + val layout = textView.layout ?: return false - // Offset text by shadowLeftNeeded to keep shadow in positive coordinates - // The view will compensate with canvas translation - canvas.drawText(textToDraw, x + shadowLeftNeeded, y.toFloat(), paint) + val paint = textView.paint - // Restore original shadow settings - if (originalShadowRadius > 0f) { - paint.setShadowLayer( - originalShadowRadius, originalShadowDx, originalShadowDy, originalShadowColor) - } else { - paint.clearShadowLayer() - } - } + val originalShadowRadius = paint.shadowLayerRadius + val originalShadowDx = paint.shadowLayerDx + val originalShadowDy = paint.shadowLayerDy + val originalShadowColor = paint.shadowLayerColor - /** - * Result class for shadow adjustment calculation. - * Contains the horizontal offset needed to compensate for shadow positioning - * and whether a shadow is present. - */ - public data class ShadowAdjustment( - val leftOffset: Float, - val hasShadow: Boolean - ) { - public companion object { - @JvmStatic - public val NONE: ShadowAdjustment = ShadowAdjustment(0f, false) - } - } + paint.setShadowLayer(span.radius, span.dx, span.dy, span.color) - public companion object { - /** - * Helper method for ReactTextView and PreparedLayoutTextView to get shadow adjustment values. - * Calculates the horizontal offset needed to compensate for shadow positioning - * when the span offsets text to keep shadows in positive coordinates. - * - * @param spanned The text to check for shadow spans, or null if no text - * @return ShadowAdjustment with negative leftOffset (ready to use in canvas.translate) - */ - @JvmStatic - public fun getShadowAdjustment(spanned: Spanned?): ShadowAdjustment { - if (spanned == null) { - return ShadowAdjustment.NONE - } + canvas.save() + canvas.translate(textView.paddingLeft.toFloat(), textView.paddingTop.toFloat()) + layout.draw(canvas) + canvas.restore() - val spans = spanned.getSpans(0, spanned.length, DiscordShadowStyleSpan::class.java) - if (spans.isEmpty()) { - return ShadowAdjustment.NONE + if (originalShadowRadius > 0f) { + paint.setShadowLayer(originalShadowRadius, originalShadowDx, originalShadowDy, originalShadowColor) + } else { + paint.clearShadowLayer() } - // Use the first shadow span to calculate offset - val span = spans[0] - val radius = span.getShadowRadius() - val dx = span.getShadowDx() - // Return negative offset so views can use it directly in canvas.translate - val shadowLeftOffset = -max(0f, radius - dx) + return true + } + + @JvmStatic + public fun drawShadowPassWithLayout( + layout: android.text.Layout, + canvas: Canvas, + offsetX: Float, + offsetY: Float + ): Boolean { + val text = layout.text + if (text !is Spanned) return false + + val span = getShadowSpan(text) ?: return false + val paint = layout.paint + + val originalShadowRadius = paint.shadowLayerRadius + val originalShadowDx = paint.shadowLayerDx + val originalShadowDy = paint.shadowLayerDy + val originalShadowColor = paint.shadowLayerColor + + paint.setShadowLayer(span.radius, span.dx, span.dy, span.color) + + canvas.save() + canvas.translate(offsetX, offsetY) + layout.draw(canvas) + canvas.restore() + + if (originalShadowRadius > 0f) { + paint.setShadowLayer(originalShadowRadius, originalShadowDx, originalShadowDy, originalShadowColor) + } else { + paint.clearShadowLayer() + } - return ShadowAdjustment(shadowLeftOffset, true) + return true } } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/ReactForegroundColorSpan.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/ReactForegroundColorSpan.kt index 9ff41f9d436708..364aeadf191611 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/ReactForegroundColorSpan.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/ReactForegroundColorSpan.kt @@ -7,7 +7,14 @@ package com.facebook.react.views.text.internal.span +import android.text.TextPaint import android.text.style.ForegroundColorSpan /** Wraps [ForegroundColorSpan] as a [ReactSpan]. */ -internal class ReactForegroundColorSpan(color: Int) : ForegroundColorSpan(color), ReactSpan +internal class ReactForegroundColorSpan(color: Int) : ForegroundColorSpan(color), ReactSpan { + override fun updateDrawState(textPaint: TextPaint) { + android.util.Log.d("TextDebug", "ReactForegroundColorSpan.updateDrawState: color=${foregroundColor} (hex=${Integer.toHexString(foregroundColor)}), BEFORE paintColor=${textPaint.color} (hex=${Integer.toHexString(textPaint.color)})") + super.updateDrawState(textPaint) + android.util.Log.d("TextDebug", "ReactForegroundColorSpan.updateDrawState: AFTER paintColor=${textPaint.color} (hex=${Integer.toHexString(textPaint.color)})") + } +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt index 574038a1728d41..e550140e5fbb91 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/StrokeStyleSpan.kt @@ -1,86 +1,57 @@ package com.facebook.react.views.text.internal.span -import android.graphics.Canvas import android.graphics.Paint -import android.text.style.ReplacementSpan +import android.text.Spanned +import android.text.TextPaint +import android.text.style.CharacterStyle /** - * A span that applies text stroke styling with two-pass rendering. - * First draws stroke, then draws fill on top to create outer stroke effect. + * A span that applies text stroke styling using FILL_AND_STROKE. + * + * This span renders text with both fill and stroke in the stroke color. + * To achieve an "outer stroke" effect, use two overlapping text views: + * 1. Bottom layer: Text with this span applied (renders stroke color) + * 2. Top layer: Normal text without this span (renders fill color) + * + * The top layer covers the fill portion, leaving only the outer stroke visible. */ public class StrokeStyleSpan( - private val strokeWidth: Float, - private val strokeColor: Int -) : ReplacementSpan(), ReactSpan { - - public override fun getSize( - paint: Paint, - text: CharSequence?, - start: Int, - end: Int, - fm: Paint.FontMetricsInt? - ): Int { - val width = paint.measureText(text, start, end) - - if (fm != null) { - paint.getFontMetricsInt(fm) - val halfStroke = (strokeWidth / 2).toInt() - fm.top -= halfStroke - fm.ascent -= halfStroke - fm.descent += halfStroke - fm.bottom += halfStroke - } - - return width.toInt() + public val strokeWidth: Float, + public val strokeColor: Int, + public val debugText: String = "" +) : CharacterStyle(), ReactSpan { + + // Store original paint values to detect if paint is being reused incorrectly + override fun updateDrawState(textPaint: TextPaint) { + val originalStyle = textPaint.style + val originalStrokeWidth = textPaint.strokeWidth + val originalColor = textPaint.color + + android.util.Log.d("TextDebug", "StrokeStyleSpan.updateDrawState: text='$debugText', strokeColor=$strokeColor (hex=${Integer.toHexString(strokeColor)}), strokeWidth=$strokeWidth") + android.util.Log.d("TextDebug", " BEFORE: paintColor=${originalColor} (hex=${Integer.toHexString(originalColor)}), paintStyle=$originalStyle, paintStrokeWidth=$originalStrokeWidth") + + // Reset paint to default state first to avoid inheriting stale values + textPaint.style = Paint.Style.FILL_AND_STROKE + textPaint.strokeWidth = strokeWidth + textPaint.strokeJoin = Paint.Join.ROUND + textPaint.strokeCap = Paint.Cap.ROUND + textPaint.shader = null + textPaint.color = strokeColor + + android.util.Log.d("TextDebug", " AFTER: paintColor=${textPaint.color} (hex=${Integer.toHexString(textPaint.color)})") } - public override fun draw( - canvas: Canvas, - text: CharSequence?, - start: Int, - end: Int, - x: Float, - top: Int, - y: Int, - bottom: Int, - paint: Paint - ) { - if (text == null) return - - val textToDraw = text.subSequence(start, end).toString() - val strokeInset = strokeWidth / 2 - - // Store original paint settings - val originalStyle = paint.style - val originalColor = paint.color - val originalStrokeWidth = paint.strokeWidth - val originalStrokeJoin = paint.strokeJoin - val originalStrokeCap = paint.strokeCap - - // First pass: Draw stroke only (solid color) - paint.style = Paint.Style.STROKE - paint.strokeWidth = strokeWidth - paint.strokeJoin = Paint.Join.ROUND - paint.strokeCap = Paint.Cap.ROUND - paint.color = strokeColor - canvas.drawText(textToDraw, x + strokeInset, y.toFloat(), paint) - - // Second pass: Draw fill on top - paint.style = Paint.Style.FILL - paint.color = originalColor - if (text is android.text.Spanned && paint is android.text.TextPaint) { - val spans = text.getSpans(start, end, android.text.style.CharacterStyle::class.java) - for (span in spans) { - span.updateDrawState(paint) - } + public companion object { + @JvmStatic + public fun getStrokeSpan(spanned: Spanned?): StrokeStyleSpan? { + if (spanned == null) return null + val spans = spanned.getSpans(0, spanned.length, StrokeStyleSpan::class.java) + return spans.firstOrNull() } - canvas.drawText(textToDraw, x + strokeInset, y.toFloat(), paint) - // Restore original paint settings - paint.style = originalStyle - paint.color = originalColor - paint.strokeWidth = originalStrokeWidth - paint.strokeJoin = originalStrokeJoin - paint.strokeCap = originalStrokeCap + @JvmStatic + public fun hasStroke(spanned: Spanned?): Boolean { + return getStrokeSpan(spanned) != null + } } }