/* * 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. */ package com.facebook.react.views.text import android.os.Build import android.text.Layout import android.text.TextUtils import android.text.TextUtils.TruncateAt import android.util.LayoutDirection import android.view.Gravity import com.facebook.common.logging.FLog import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableMap import com.facebook.react.common.ReactConstants import com.facebook.react.common.mapbuffer.MapBuffer import com.facebook.react.uimanager.PixelUtil.toPixelFromDIP import com.facebook.react.uimanager.PixelUtil.toPixelFromSP import com.facebook.react.uimanager.ReactAccessibilityDelegate import com.facebook.react.uimanager.ReactAccessibilityDelegate.AccessibilityRole import com.facebook.react.uimanager.ReactStylesDiffMap import com.facebook.react.uimanager.ViewProps import com.facebook.react.views.text.ReactTypefaceUtils.parseFontStyle import com.facebook.react.views.text.ReactTypefaceUtils.parseFontVariant import com.facebook.react.views.text.ReactTypefaceUtils.parseFontWeight import kotlin.math.ceil // TODO: T63643819 refactor naming of TextAttributeProps to make explicit that this represents // TextAttributes and not TextProps. As part of this refactor extract methods that don't belong to // TextAttributeProps (e.g. TextAlign) public class TextAttributeProps private constructor() { public var lineHeight: Float = Float.NaN private set(value) { lineHeightInput = value field = if (value == ReactConstants.UNSET.toFloat()) { Float.NaN } else { if (allowFontScaling) toPixelFromSP(value) else toPixelFromDIP(value) } } public var isColorSet: Boolean = false private set public var allowFontScaling: Boolean = true private set(value) { if (value != field) { field = value setFontSize(fontSizeInput) lineHeight = lineHeightInput } } public var maxFontSizeMultiplier: Float = Float.NaN private set(value) { if (value != field) { field = value setFontSize(fontSizeInput) lineHeight = lineHeightInput } } public var isBackgroundColorSet: Boolean = false private set public var opacity: Float = Float.NaN private set public var numberOfLines: Int = ReactConstants.UNSET private set public var fontSize: Int = ReactConstants.UNSET private set private var fontSizeInput: Float = ReactConstants.UNSET.toFloat() private var lineHeightInput: Float = ReactConstants.UNSET.toFloat() private var letterSpacingInput: Float = Float.NaN // `ReactConstants.UNSET` is -1, same as `LayoutDirection.UNDEFINED` (which is a hidden symbol) public var layoutDirection: Int = ReactConstants.UNSET private set internal var textTransform: TextTransform = TextTransform.NONE public var isUnderlineTextDecorationSet: Boolean = false private set public var isLineThroughTextDecorationSet: Boolean = false private set private var includeFontPadding: Boolean = true public var accessibilityRole: AccessibilityRole? = null private set public var role: ReactAccessibilityDelegate.Role? = null private set public var fontStyle: Int = ReactConstants.UNSET private set public var fontWeight: Int = ReactConstants.UNSET private set /** * NB: If a font family is used that does not have a style in a certain Android version (ie. * monospace bold pre Android 5.0), that style (ie. bold) will not be inherited by nested Text * nodes. To retain that style, you have to add it to those nodes explicitly. * * Example, Android 4.4: *
   * Bold Text
   * Bold Text
   * Bold Text
   *
   * Not Bold Text
   * Not Bold Text
   * Not Bold Text
   *
   * Not Bold Text
   * Bold Text
   * Bold Text
   * 
* */ public var fontFamily: String? = null private set /** @see android.graphics.Paint.setFontFeatureSettings */ public var fontFeatureSettings: String? = null private set @Deprecated("Use lineHeight instead", ReplaceWith("lineHeight")) public val effectiveLineHeight: Float get() = lineHeight private fun setNumberOfLines(numberOfLines: Int) { this.numberOfLines = if (numberOfLines == 0) ReactConstants.UNSET else numberOfLines } public var letterSpacing: Float get() { val letterSpacingPixels = if (allowFontScaling) toPixelFromSP(letterSpacingInput) else toPixelFromDIP(letterSpacingInput) require(fontSize > 0) { "FontSize should be a positive value. Current value: $fontSize" } // `letterSpacingPixels` and `fontSize` are both in pixels, // yielding an accurate em value. return letterSpacingPixels / fontSize } private set(letterSpacing) { letterSpacingInput = letterSpacing } public val effectiveLetterSpacing: Float get() = letterSpacing private fun setFontSize(fontSize: Float) { var fontSizeLocal = fontSize fontSizeInput = fontSizeLocal if (fontSizeLocal != ReactConstants.UNSET.toFloat()) { fontSizeLocal = if (allowFontScaling) ceil(toPixelFromSP(fontSize, maxFontSizeMultiplier).toDouble()).toFloat() else ceil(toPixelFromDIP(fontSize).toDouble()).toFloat() } this.fontSize = fontSizeLocal.toInt() } public var color: Int? = null private set(value) { isColorSet = (value != null) if (value != null) { field = value } } public var backgroundColor: Int? = 0 private set(color) { // TODO: Don't apply background color to anchor TextView since it will be applied on the // View directly // if (!isVirtualAnchor()) { isBackgroundColorSet = (color != null) if (color != null) { field = color } // } } private fun setFontVariant(fontVariant: ReadableArray?) { fontFeatureSettings = parseFontVariant(fontVariant) } private fun setFontVariant(fontVariant: MapBuffer?) { if (fontVariant == null || fontVariant.count == 0) { fontFeatureSettings = null return } val features: MutableList = ArrayList() val iterator = fontVariant.iterator() while (iterator.hasNext()) { val entry = iterator.next() val value = entry.stringValue @Suppress("SENSELESS_COMPARISON") if (value != null) { when (value) { "small-caps" -> features.add("'smcp'") "oldstyle-nums" -> features.add("'onum'") "lining-nums" -> features.add("'lnum'") "tabular-nums" -> features.add("'tnum'") "proportional-nums" -> features.add("'pnum'") "stylistic-one" -> features.add("'ss01'") "stylistic-two" -> features.add("'ss02'") "stylistic-three" -> features.add("'ss03'") "stylistic-four" -> features.add("'ss04'") "stylistic-five" -> features.add("'ss05'") "stylistic-six" -> features.add("'ss06'") "stylistic-seven" -> features.add("'ss07'") "stylistic-eight" -> features.add("'ss08'") "stylistic-nine" -> features.add("'ss09'") "stylistic-ten" -> features.add("'ss10'") "stylistic-eleven" -> features.add("'ss11'") "stylistic-twelve" -> features.add("'ss12'") "stylistic-thirteen" -> features.add("'ss13'") "stylistic-fourteen" -> features.add("'ss14'") "stylistic-fifteen" -> features.add("'ss15'") "stylistic-sixteen" -> features.add("'ss16'") "stylistic-seventeen" -> features.add("'ss17'") "stylistic-eighteen" -> features.add("'ss18'") "stylistic-nineteen" -> features.add("'ss19'") "stylistic-twenty" -> features.add("'ss20'") } } } fontFeatureSettings = TextUtils.join(", ", features) } private fun setFontWeight(fontWeightString: String?) { fontWeight = parseFontWeight(fontWeightString) } private fun setFontStyle(fontStyleString: String?) { fontStyle = parseFontStyle(fontStyleString) } private fun setTextDecorationLine(textDecorationLineString: String?) { isUnderlineTextDecorationSet = false isLineThroughTextDecorationSet = false if (textDecorationLineString != null) { for (textDecorationLineSubString in textDecorationLineString .split("-".toRegex()) .dropLastWhile { it.isEmpty() } .toTypedArray()) { if ("underline" == textDecorationLineSubString) { isUnderlineTextDecorationSet = true } else if ("strikethrough" == textDecorationLineSubString) { isLineThroughTextDecorationSet = true } } } } private fun setTextShadowOffset(offsetMap: ReadableMap?) { textShadowOffsetDx = 0f textShadowOffsetDy = 0f if (offsetMap != null) { if ( offsetMap.hasKey(PROP_SHADOW_OFFSET_WIDTH) && !offsetMap.isNull(PROP_SHADOW_OFFSET_WIDTH) ) { textShadowOffsetDx = toPixelFromDIP(offsetMap.getDouble(PROP_SHADOW_OFFSET_WIDTH)) } if ( offsetMap.hasKey(PROP_SHADOW_OFFSET_HEIGHT) && !offsetMap.isNull(PROP_SHADOW_OFFSET_HEIGHT) ) { textShadowOffsetDy = toPixelFromDIP(offsetMap.getDouble(PROP_SHADOW_OFFSET_HEIGHT)) } } } public var textShadowOffsetDx: Float = 0f private set(value) { field = toPixelFromDIP(value) } public var textShadowOffsetDy: Float = 0f private set(value) { field = toPixelFromDIP(value) } private fun setLayoutDirection(layoutDirection: String?) { this.layoutDirection = getLayoutDirection(layoutDirection) } public var textShadowRadius: Float = 0f private set(value) { if (value != field) { field = value } } public var textShadowColor: Int = DEFAULT_TEXT_SHADOW_COLOR private set(value) { if (value != field) { field = value } } private fun setTextTransform(textTransform: String?) { this.textTransform = when (textTransform) { null, "none" -> TextTransform.NONE "uppercase" -> TextTransform.UPPERCASE "lowercase" -> TextTransform.LOWERCASE "capitalize" -> TextTransform.CAPITALIZE else -> { FLog.w(ReactConstants.TAG, "Invalid textTransform: $textTransform") TextTransform.NONE } } } private fun setAccessibilityRole(accessibilityRole: String?) { this.accessibilityRole = if (accessibilityRole == null) null else AccessibilityRole.fromValue(accessibilityRole) } private fun setRole(role: String?) { if (role == null) { this.role = null } else { this.role = ReactAccessibilityDelegate.Role.fromValue(role) } } private fun setRole(role: ReactAccessibilityDelegate.Role) { this.role = role } public companion object { // constants for Text Attributes serialization public const val TA_KEY_FOREGROUND_COLOR: Int = 0 public const val TA_KEY_BACKGROUND_COLOR: Int = 1 public const val TA_KEY_OPACITY: Int = 2 public const val TA_KEY_FONT_FAMILY: Int = 3 public const val TA_KEY_FONT_SIZE: Int = 4 public const val TA_KEY_FONT_SIZE_MULTIPLIER: Int = 5 public const val TA_KEY_FONT_WEIGHT: Int = 6 public const val TA_KEY_FONT_STYLE: Int = 7 public const val TA_KEY_FONT_VARIANT: Int = 8 public const val TA_KEY_ALLOW_FONT_SCALING: Int = 9 public const val TA_KEY_LETTER_SPACING: Int = 10 public const val TA_KEY_LINE_HEIGHT: Int = 11 public const val TA_KEY_ALIGNMENT: Int = 12 public const val TA_KEY_BEST_WRITING_DIRECTION: Int = 13 public const val TA_KEY_TEXT_DECORATION_COLOR: Int = 14 public const val TA_KEY_TEXT_DECORATION_LINE: Int = 15 public const val TA_KEY_TEXT_DECORATION_STYLE: Int = 16 public const val TA_KEY_TEXT_SHADOW_RADIUS: Int = 18 public const val TA_KEY_TEXT_SHADOW_COLOR: Int = 19 public const val TA_KEY_TEXT_SHADOW_OFFSET_DX: Int = 20 public const val TA_KEY_TEXT_SHADOW_OFFSET_DY: Int = 21 public const val TA_KEY_IS_HIGHLIGHTED: Int = 22 public const val TA_KEY_LAYOUT_DIRECTION: Int = 23 public const val TA_KEY_ACCESSIBILITY_ROLE: Int = 24 public const val TA_KEY_LINE_BREAK_STRATEGY: Int = 25 public const val TA_KEY_ROLE: Int = 26 public const val TA_KEY_TEXT_TRANSFORM: Int = 27 public const val TA_KEY_MAX_FONT_SIZE_MULTIPLIER: Int = 29 public const val UNSET: Int = -1 private const val PROP_SHADOW_OFFSET = "textShadowOffset" private const val PROP_SHADOW_OFFSET_WIDTH = "width" private const val PROP_SHADOW_OFFSET_HEIGHT = "height" private const val PROP_SHADOW_RADIUS = "textShadowRadius" private const val PROP_SHADOW_COLOR = "textShadowColor" private const val PROP_TEXT_TRANSFORM = "textTransform" private const val DEFAULT_TEXT_SHADOW_COLOR = 0x55000000 private val DEFAULT_JUSTIFICATION_MODE = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) 0 else Layout.JUSTIFICATION_MODE_NONE private const val DEFAULT_BREAK_STRATEGY = Layout.BREAK_STRATEGY_HIGH_QUALITY private const val DEFAULT_HYPHENATION_FREQUENCY = Layout.HYPHENATION_FREQUENCY_NONE /** Build a TextAttributeProps using data from the [MapBuffer] received as a parameter. */ public fun fromMapBuffer(props: MapBuffer): TextAttributeProps { val result = TextAttributeProps() // TODO T83483191: Review constants that are not being set! val iterator = props.iterator() while (iterator.hasNext()) { val entry = iterator.next() when (entry.key) { TA_KEY_FOREGROUND_COLOR -> result.color = entry.intValue TA_KEY_BACKGROUND_COLOR -> result.backgroundColor = entry.intValue TA_KEY_OPACITY -> result.opacity = entry.doubleValue.toFloat() TA_KEY_FONT_FAMILY -> result.fontFamily = entry.stringValue TA_KEY_FONT_SIZE -> result.setFontSize(entry.doubleValue.toFloat()) TA_KEY_FONT_SIZE_MULTIPLIER -> {} TA_KEY_FONT_WEIGHT -> result.setFontWeight(entry.stringValue) TA_KEY_FONT_STYLE -> result.setFontStyle(entry.stringValue) TA_KEY_FONT_VARIANT -> result.setFontVariant(entry.mapBufferValue) TA_KEY_ALLOW_FONT_SCALING -> result.allowFontScaling = entry.booleanValue TA_KEY_LETTER_SPACING -> result.letterSpacing = entry.doubleValue.toFloat() TA_KEY_LINE_HEIGHT -> result.lineHeight = entry.doubleValue.toFloat() TA_KEY_ALIGNMENT -> {} TA_KEY_BEST_WRITING_DIRECTION -> {} TA_KEY_TEXT_DECORATION_COLOR -> {} TA_KEY_TEXT_DECORATION_LINE -> result.setTextDecorationLine(entry.stringValue) TA_KEY_TEXT_DECORATION_STYLE -> {} TA_KEY_TEXT_SHADOW_RADIUS -> result.textShadowRadius = entry.doubleValue.toFloat() TA_KEY_TEXT_SHADOW_COLOR -> result.textShadowColor = entry.intValue TA_KEY_TEXT_SHADOW_OFFSET_DX -> result.textShadowOffsetDx = entry.doubleValue.toFloat() TA_KEY_TEXT_SHADOW_OFFSET_DY -> result.textShadowOffsetDy = entry.doubleValue.toFloat() TA_KEY_IS_HIGHLIGHTED -> {} TA_KEY_LAYOUT_DIRECTION -> result.setLayoutDirection(entry.stringValue) TA_KEY_ACCESSIBILITY_ROLE -> result.setAccessibilityRole(entry.stringValue) TA_KEY_ROLE -> result.setRole(ReactAccessibilityDelegate.Role.entries[entry.intValue]) TA_KEY_TEXT_TRANSFORM -> result.setTextTransform(entry.stringValue) TA_KEY_MAX_FONT_SIZE_MULTIPLIER -> result.maxFontSizeMultiplier = entry.doubleValue.toFloat() } } // TODO T83483191: Review why the following props are not serialized: // setNumberOfLines // setColor // setIncludeFontPadding return result } public fun fromReadableMap(props: ReactStylesDiffMap): TextAttributeProps { val result = TextAttributeProps() result.setNumberOfLines(getIntProp(props, ViewProps.NUMBER_OF_LINES, ReactConstants.UNSET)) result.lineHeight = getFloatProp(props, ViewProps.LINE_HEIGHT, ReactConstants.UNSET.toFloat()) result.letterSpacing = getFloatProp(props, ViewProps.LETTER_SPACING, Float.NaN) result.allowFontScaling = getBooleanProp(props, ViewProps.ALLOW_FONT_SCALING, true) result.maxFontSizeMultiplier = getFloatProp(props, ViewProps.MAX_FONT_SIZE_MULTIPLIER, Float.NaN) result.setFontSize(getFloatProp(props, ViewProps.FONT_SIZE, ReactConstants.UNSET.toFloat())) result.color = if (props.hasKey(ViewProps.COLOR)) props.getInt(ViewProps.COLOR, 0) else null result.color = if (props.hasKey(ViewProps.FOREGROUND_COLOR)) props.getInt(ViewProps.FOREGROUND_COLOR, 0) else null result.backgroundColor = if (props.hasKey(ViewProps.BACKGROUND_COLOR)) props.getInt(ViewProps.BACKGROUND_COLOR, 0) else null result.opacity = getFloatProp(props, ViewProps.OPACITY, Float.NaN) result.fontFamily = getStringProp(props, ViewProps.FONT_FAMILY) result.setFontWeight(getStringProp(props, ViewProps.FONT_WEIGHT)) result.setFontStyle(getStringProp(props, ViewProps.FONT_STYLE)) result.setFontVariant(getArrayProp(props, ViewProps.FONT_VARIANT)) result.includeFontPadding = getBooleanProp(props, ViewProps.INCLUDE_FONT_PADDING, true) result.setTextDecorationLine(getStringProp(props, ViewProps.TEXT_DECORATION_LINE)) result.setTextShadowOffset( if (props.hasKey(PROP_SHADOW_OFFSET)) props.getMap(PROP_SHADOW_OFFSET) else null ) result.textShadowRadius = getFloatProp(props, PROP_SHADOW_RADIUS, 1f) result.textShadowColor = getIntProp(props, PROP_SHADOW_COLOR, DEFAULT_TEXT_SHADOW_COLOR) result.setTextTransform(getStringProp(props, PROP_TEXT_TRANSFORM)) result.setLayoutDirection(getStringProp(props, ViewProps.LAYOUT_DIRECTION)) result.setAccessibilityRole(getStringProp(props, ViewProps.ACCESSIBILITY_ROLE)) result.setRole(getStringProp(props, ViewProps.ROLE)) return result } public fun getTextAlignment(props: ReactStylesDiffMap, isRTL: Boolean, defaultValue: Int): Int { if (!props.hasKey(ViewProps.TEXT_ALIGN)) { return defaultValue } return when (val textAlignPropValue = props.getString(ViewProps.TEXT_ALIGN)) { "justify" -> Gravity.LEFT null, "auto" -> Gravity.NO_GRAVITY "left" -> if (isRTL) Gravity.RIGHT else Gravity.LEFT "right" -> if (isRTL) Gravity.LEFT else Gravity.RIGHT "center" -> Gravity.CENTER_HORIZONTAL else -> { FLog.w(ReactConstants.TAG, "Invalid textAlign: $textAlignPropValue") Gravity.NO_GRAVITY } } } public fun getJustificationMode(props: ReactStylesDiffMap, defaultValue: Int): Int { if (!props.hasKey(ViewProps.TEXT_ALIGN)) { return defaultValue } val textAlignPropValue = props.getString(ViewProps.TEXT_ALIGN) if ("justify" == textAlignPropValue && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { return Layout.JUSTIFICATION_MODE_INTER_WORD } return DEFAULT_JUSTIFICATION_MODE } private fun getBooleanProp( props: ReactStylesDiffMap, name: String, defaultValue: Boolean, ): Boolean = if (props.hasKey(name)) props.getBoolean(name, defaultValue) else defaultValue private fun getStringProp(props: ReactStylesDiffMap, name: String): String? = if (props.hasKey(name)) props.getString(name) else null private fun getIntProp(props: ReactStylesDiffMap, name: String, defaultValue: Int): Int = if (props.hasKey(name)) props.getInt(name, defaultValue) else defaultValue private fun getFloatProp(props: ReactStylesDiffMap, name: String, defaultValue: Float): Float = if (props.hasKey(name)) props.getFloat(name, defaultValue) else defaultValue private fun getArrayProp(props: ReactStylesDiffMap, name: String): ReadableArray? = if (props.hasKey(name)) props.getArray(name) else null public fun getLayoutDirection(layoutDirection: String?): Int { return when (layoutDirection) { null, "undefined" -> ReactConstants.UNSET "rtl" -> LayoutDirection.RTL "ltr" -> LayoutDirection.LTR else -> { FLog.w(ReactConstants.TAG, "Invalid layoutDirection: $layoutDirection") ReactConstants.UNSET } } } public fun getTextBreakStrategy(textBreakStrategy: String?): Int = when (textBreakStrategy) { null -> DEFAULT_BREAK_STRATEGY "simple" -> Layout.BREAK_STRATEGY_SIMPLE "balanced" -> Layout.BREAK_STRATEGY_BALANCED else -> Layout.BREAK_STRATEGY_HIGH_QUALITY } public fun getHyphenationFrequency(hyphenationFrequency: String?): Int = when (hyphenationFrequency) { null -> DEFAULT_HYPHENATION_FREQUENCY "none" -> Layout.HYPHENATION_FREQUENCY_NONE "normal" -> Layout.HYPHENATION_FREQUENCY_NORMAL else -> Layout.HYPHENATION_FREQUENCY_FULL } public fun getEllipsizeMode(ellipsizeMode: String?): TruncateAt? = when (ellipsizeMode) { "head" -> TruncateAt.START "middle" -> TruncateAt.MIDDLE "tail" -> TruncateAt.END "clip" -> null else -> null } } }