1 /*
<lambda>null2  * Copyright 2020 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package androidx.compose.ui.text.platform.extensions
18 
19 import android.graphics.Typeface
20 import android.os.Build
21 import android.text.Spannable
22 import android.text.Spanned
23 import android.text.style.AbsoluteSizeSpan
24 import android.text.style.BackgroundColorSpan
25 import android.text.style.ForegroundColorSpan
26 import android.text.style.LeadingMarginSpan
27 import android.text.style.LocaleSpan
28 import android.text.style.MetricAffectingSpan
29 import android.text.style.RelativeSizeSpan
30 import android.text.style.ScaleXSpan
31 import androidx.compose.ui.graphics.Brush
32 import androidx.compose.ui.graphics.Color
33 import androidx.compose.ui.graphics.ShaderBrush
34 import androidx.compose.ui.graphics.Shadow
35 import androidx.compose.ui.graphics.SolidColor
36 import androidx.compose.ui.graphics.drawscope.DrawStyle
37 import androidx.compose.ui.graphics.isSpecified
38 import androidx.compose.ui.graphics.toArgb
39 import androidx.compose.ui.text.AnnotatedString
40 import androidx.compose.ui.text.Bullet
41 import androidx.compose.ui.text.SpanStyle
42 import androidx.compose.ui.text.TextStyle
43 import androidx.compose.ui.text.android.InternalPlatformTextApi
44 import androidx.compose.ui.text.android.style.BaselineShiftSpan
45 import androidx.compose.ui.text.android.style.FontFeatureSpan
46 import androidx.compose.ui.text.android.style.LetterSpacingSpanEm
47 import androidx.compose.ui.text.android.style.LetterSpacingSpanPx
48 import androidx.compose.ui.text.android.style.LineHeightSpan
49 import androidx.compose.ui.text.android.style.LineHeightStyleSpan
50 import androidx.compose.ui.text.android.style.ShadowSpan
51 import androidx.compose.ui.text.android.style.SkewXSpan
52 import androidx.compose.ui.text.android.style.TextDecorationSpan
53 import androidx.compose.ui.text.android.style.TypefaceSpan
54 import androidx.compose.ui.text.font.FontFamily
55 import androidx.compose.ui.text.font.FontStyle
56 import androidx.compose.ui.text.font.FontSynthesis
57 import androidx.compose.ui.text.font.FontWeight
58 import androidx.compose.ui.text.intersect
59 import androidx.compose.ui.text.intl.Locale
60 import androidx.compose.ui.text.intl.LocaleList
61 import androidx.compose.ui.text.platform.style.CustomBulletSpan
62 import androidx.compose.ui.text.platform.style.DrawStyleSpan
63 import androidx.compose.ui.text.platform.style.ShaderBrushSpan
64 import androidx.compose.ui.text.style.BaselineShift
65 import androidx.compose.ui.text.style.LineHeightStyle
66 import androidx.compose.ui.text.style.TextDecoration
67 import androidx.compose.ui.text.style.TextGeometricTransform
68 import androidx.compose.ui.text.style.TextIndent
69 import androidx.compose.ui.unit.Density
70 import androidx.compose.ui.unit.TextUnit
71 import androidx.compose.ui.unit.TextUnitType
72 import androidx.compose.ui.unit.isUnspecified
73 import androidx.compose.ui.unit.sp
74 import androidx.compose.ui.util.fastFilteredMap
75 import androidx.compose.ui.util.fastForEach
76 import androidx.compose.ui.util.fastForEachIndexed
77 import kotlin.math.ceil
78 import kotlin.math.roundToInt
79 
80 internal fun Spannable.setSpan(span: Any, start: Int, end: Int) {
81     setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
82 }
83 
84 @Suppress("DEPRECATION")
setTextIndentnull85 internal fun Spannable.setTextIndent(
86     textIndent: TextIndent?,
87     contextFontSize: Float,
88     density: Density
89 ) {
90     textIndent?.let { indent ->
91         if (indent.firstLine == 0.sp && indent.restLine == 0.sp) return@let
92         if (indent.firstLine.isUnspecified || indent.restLine.isUnspecified) return@let
93         with(density) {
94             val firstLine =
95                 when (indent.firstLine.type) {
96                     TextUnitType.Sp -> indent.firstLine.toPx()
97                     TextUnitType.Em -> indent.firstLine.value * contextFontSize
98                     else -> 0f
99                 }
100             val restLine =
101                 when (indent.restLine.type) {
102                     TextUnitType.Sp -> indent.restLine.toPx()
103                     TextUnitType.Em -> indent.restLine.value * contextFontSize
104                     else -> 0f
105                 }
106             setSpan(
107                 LeadingMarginSpan.Standard(ceil(firstLine).toInt(), ceil(restLine).toInt()),
108                 0,
109                 length
110             )
111         }
112     }
113 }
114 
115 /**
116  * This implementation of the bullet span only draws the bullet. The actual indentation is added
117  * already inside the [setTextIndent] call since each bullet is a paragraph. The only exception is
118  * when there isn't enough space to put the bullet with its padding, in that case a minimum required
119  * space will be added.
120  *
121  * @param contextFontSize the font size that all text in this paragraph is drawn with
122  */
setBulletSpansnull123 internal fun Spannable.setBulletSpans(
124     annotations: List<AnnotatedString.Range<out AnnotatedString.Annotation>>,
125     contextFontSize: Float,
126     density: Density,
127     textIndent: TextIndent?
128 ) {
129     val textIndentPx =
130         textIndent?.let {
131             with(density) {
132                 when (textIndent.firstLine.type) {
133                     TextUnitType.Sp -> textIndent.firstLine.toPx()
134                     TextUnitType.Em -> textIndent.firstLine.value * contextFontSize
135                     else -> 0f
136                 }
137             }
138         } ?: 0f
139     annotations.fastForEach {
140         (it.item as? Bullet)?.let { bullet ->
141             val bulletSize = resolveBulletTextUnitToPx(bullet.size, contextFontSize, density)
142             val gapWidthPx = resolveBulletTextUnitToPx(bullet.padding, contextFontSize, density)
143             if (!bulletSize.isNaN() && !gapWidthPx.isNaN()) {
144                 setSpan(
145                     CustomBulletSpan(
146                         shape = bullet.shape,
147                         bulletWidthPx = bulletSize,
148                         bulletHeightPx = bulletSize,
149                         gapWidthPx = gapWidthPx,
150                         density = density,
151                         brush = bullet.brush,
152                         alpha = bullet.alpha,
153                         drawStyle = bullet.drawStyle,
154                         textIndentPx = textIndentPx
155                     ),
156                     it.start,
157                     it.end
158                 )
159             }
160         }
161     }
162 }
163 
164 /**
165  * Resolves bullet's size or gap size [size] to pixels. If unknown [TextUnitType] is used, returns
166  * [Float.NaN] that needs to be handled on caller site.
167  */
resolveBulletTextUnitToPxnull168 private fun resolveBulletTextUnitToPx(
169     size: TextUnit,
170     contextFontSize: Float,
171     density: Density
172 ): Float {
173     if (size == TextUnit.Unspecified) return contextFontSize
174     return when (size.type) {
175         TextUnitType.Sp -> {
176             // if non-linear font scaling is enabled, this is handled by toPx() conversion already
177             with(density) { size.toPx() }
178         }
179         TextUnitType.Em -> size.value * contextFontSize
180         else -> Float.NaN
181     }
182 }
183 
setLineHeightnull184 internal fun Spannable.setLineHeight(
185     lineHeight: TextUnit,
186     contextFontSize: Float,
187     density: Density,
188     lineHeightStyle: LineHeightStyle
189 ) {
190     val resolvedLineHeight = resolveLineHeightInPx(lineHeight, contextFontSize, density)
191     if (!resolvedLineHeight.isNaN()) {
192         // in order to handle empty lines (including empty text) better, change endIndex so that
193         // it won't apply trimLastLineBottom rule
194         val endIndex = if (isEmpty() || last() == '\n') length + 1 else length
195         setSpan(
196             span =
197                 LineHeightStyleSpan(
198                     lineHeight = resolvedLineHeight,
199                     startIndex = 0,
200                     endIndex = endIndex,
201                     trimFirstLineTop = lineHeightStyle.trim.isTrimFirstLineTop(),
202                     trimLastLineBottom = lineHeightStyle.trim.isTrimLastLineBottom(),
203                     topRatio = lineHeightStyle.alignment.topRatio,
204                     preserveMinimumHeight = lineHeightStyle.mode == LineHeightStyle.Mode.Minimum,
205                 ),
206             start = 0,
207             end = length
208         )
209     }
210 }
211 
212 @OptIn(InternalPlatformTextApi::class)
setLineHeightnull213 internal fun Spannable.setLineHeight(
214     lineHeight: TextUnit,
215     contextFontSize: Float,
216     density: Density
217 ) {
218     val resolvedLineHeight = resolveLineHeightInPx(lineHeight, contextFontSize, density)
219     if (!resolvedLineHeight.isNaN()) {
220         setSpan(span = LineHeightSpan(lineHeight = resolvedLineHeight), start = 0, end = length)
221     }
222 }
223 
resolveLineHeightInPxnull224 private fun resolveLineHeightInPx(
225     lineHeight: TextUnit,
226     contextFontSize: Float,
227     density: Density
228 ): Float {
229     return when (lineHeight.type) {
230         TextUnitType.Sp -> {
231             if (!isNonLinearFontScalingActive(density)) {
232                 // Non-linear font scaling is not being used, this SP is safe to use directly.
233                 with(density) { lineHeight.toPx() }
234             } else {
235                 // Determine the intended line height multiplier and use that, since non-linear font
236                 // scaling may compress the line height if it is much larger than the font size.
237                 // i.e. preserve the original proportions rather than the absolute converted value.
238                 val fontSizeSp = with(density) { contextFontSize.toSp() }
239                 val lineHeightMultiplier = lineHeight.value / fontSizeSp.value
240                 lineHeightMultiplier * contextFontSize
241             }
242         }
243         TextUnitType.Em -> lineHeight.value * contextFontSize
244         else -> Float.NaN
245     }
246 }
247 
248 // TODO(b/294384826): replace this with the actual platform method once available in core
isNonLinearFontScalingActivenull249 private fun isNonLinearFontScalingActive(density: Density) = density.fontScale > 1.05
250 
251 internal fun Spannable.setSpanStyles(
252     contextTextStyle: TextStyle,
253     annotations: List<AnnotatedString.Range<out AnnotatedString.Annotation>>,
254     density: Density,
255     resolveTypeface: (FontFamily?, FontWeight, FontStyle, FontSynthesis) -> Typeface,
256 ) {
257 
258     setFontAttributes(contextTextStyle, annotations, resolveTypeface)
259     var hasLetterSpacing = false
260     for (i in annotations.indices) {
261         val annotationRange = annotations[i]
262         if (annotationRange.item !is SpanStyle) continue
263         val start = annotationRange.start
264         val end = annotationRange.end
265 
266         if (start < 0 || start >= length || end <= start || end > length) continue
267 
268         setSpanStyle(annotationRange.item, start, end, density)
269 
270         if (annotationRange.item.needsLetterSpacingSpan) {
271             hasLetterSpacing = true
272         }
273     }
274 
275     if (hasLetterSpacing) {
276 
277         // LetterSpacingSpanPx/LetterSpacingSpanSP has lower priority than normal spans. Because
278         // letterSpacing relies on the fontSize on [Paint] to compute Px/Sp from Em. So it must be
279         // applied after all spans that changes the fontSize.
280 
281         for (i in annotations.indices) {
282             val spanStyleRange = annotations[i]
283             val style = spanStyleRange.item
284             if (style !is SpanStyle) continue
285             val start = spanStyleRange.start
286             val end = spanStyleRange.end
287             if (start < 0 || start >= length || end <= start || end > length) continue
288 
289             createLetterSpacingSpan(style.letterSpacing, density)?.let { setSpan(it, start, end) }
290         }
291     }
292 }
293 
setSpanStylenull294 private fun Spannable.setSpanStyle(style: SpanStyle, start: Int, end: Int, density: Density) {
295     // Be aware that SuperscriptSpan needs to be applied before all other spans which
296     // affect FontMetrics
297     setBaselineShift(style.baselineShift, start, end)
298 
299     setColor(style.color, start, end)
300 
301     setBrush(style.brush, style.alpha, start, end)
302 
303     setTextDecoration(style.textDecoration, start, end)
304 
305     setFontSize(style.fontSize, density, start, end)
306 
307     setFontFeatureSettings(style.fontFeatureSettings, start, end)
308 
309     setGeometricTransform(style.textGeometricTransform, start, end)
310 
311     setLocaleList(style.localeList, start, end)
312 
313     setBackground(style.background, start, end)
314 
315     setShadow(style.shadow, start, end)
316 
317     setDrawStyle(style.drawStyle, start, end)
318 }
319 
320 /**
321  * Set font related [SpanStyle]s to this [Spannable].
322  *
323  * Different from other styles, font related styles needs to be flattened first and then applied.
324  * This is required because on certain API levels the [FontWeight] is not supported by framework,
325  * and we have to resolve font settings and create typeface first and then set it directly on
326  * TextPaint.
327  *
328  * Notice that a [contextTextStyle] is also required when we flatten the font related styles. For
329  * example: the entire text has the TextStyle(fontFamily = Sans-serif) Hi Hello World [ bold ]
330  * FontWeight.Bold is set in range
331  * [0, 8). The resolved TypefaceSpan should be TypefaceSpan("Sans-serif", "bold") in range [0, 8). As demonstrated above, the fontFamily information is from [contextTextStyle].
332  *
333  * @param contextTextStyle the global [TextStyle] for the entire string.
334  * @param annotations the [annotations] to be applied, this function will first filter out the font
335  *   related [SpanStyle]s and then apply them to this [Spannable].
336  * @param resolveTypeface the lambda used to resolve font.
337  * @see flattenFontStylesAndApply
338  */
setFontAttributesnull339 private fun Spannable.setFontAttributes(
340     contextTextStyle: TextStyle,
341     annotations: List<AnnotatedString.Range<out AnnotatedString.Annotation>>,
342     resolveTypeface: (FontFamily?, FontWeight, FontStyle, FontSynthesis) -> Typeface,
343 ) {
344     @Suppress("UNCHECKED_CAST")
345     val fontRelatedSpanStyles =
346         annotations.fastFilteredMap({
347             it.item is SpanStyle && (it.item.hasFontAttributes() || it.item.fontSynthesis != null)
348         }) {
349             it as AnnotatedString.Range<SpanStyle>
350         }
351 
352     // Create a SpanStyle if contextTextStyle has font related attributes, otherwise use
353     // null to avoid unnecessary object creation.
354     val contextFontSpanStyle =
355         if (contextTextStyle.hasFontAttributes()) {
356             SpanStyle(
357                 fontFamily = contextTextStyle.fontFamily,
358                 fontWeight = contextTextStyle.fontWeight,
359                 fontStyle = contextTextStyle.fontStyle,
360                 fontSynthesis = contextTextStyle.fontSynthesis
361             )
362         } else {
363             null
364         }
365 
366     flattenFontStylesAndApply(contextFontSpanStyle, fontRelatedSpanStyles) { spanStyle, start, end
367         ->
368         setSpan(
369             TypefaceSpan(
370                 resolveTypeface(
371                     spanStyle.fontFamily,
372                     spanStyle.fontWeight ?: FontWeight.Normal,
373                     spanStyle.fontStyle ?: FontStyle.Normal,
374                     spanStyle.fontSynthesis ?: FontSynthesis.All
375                 )
376             ),
377             start,
378             end,
379             Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
380         )
381     }
382 }
383 
384 /**
385  * Flatten styles in the [spanStyles], so that overlapping styles are merged, and then apply the
386  * [block] on the merged [SpanStyle].
387  *
388  * @param contextFontSpanStyle the global [SpanStyle]. It act as if every [spanStyles] is applied on
389  *   top of it. This parameter is nullable. A null value is exactly the same as a default SpanStyle,
390  *   but avoids unnecessary object creation.
391  * @param spanStyles the input [SpanStyle] ranges to be flattened.
392  * @param block the function to be applied on the merged [SpanStyle].
393  */
flattenFontStylesAndApplynull394 internal fun flattenFontStylesAndApply(
395     contextFontSpanStyle: SpanStyle?,
396     spanStyles: List<AnnotatedString.Range<SpanStyle>>,
397     block: (SpanStyle, Int, Int) -> Unit
398 ) {
399     // quick way out for single SpanStyle or empty list.
400     if (spanStyles.size <= 1) {
401         if (spanStyles.isNotEmpty()) {
402             block(
403                 contextFontSpanStyle.merge(spanStyles[0].item),
404                 spanStyles[0].start,
405                 spanStyles[0].end
406             )
407         }
408         return
409     }
410 
411     // Sort all span start and end points.
412     // S1--S2--E1--S3--E3--E2
413     val spanCount = spanStyles.size
414     val transitionOffsets = IntArray(spanCount * 2)
415     spanStyles.fastForEachIndexed { idx, spanStyle ->
416         transitionOffsets[idx] = spanStyle.start
417         transitionOffsets[idx + spanCount] = spanStyle.end
418     }
419     transitionOffsets.sort()
420 
421     // S1--S2--E1--S3--E3--E2
422     // - Go through all minimum intervals
423     // - Find Spans that intersect with the given interval
424     // - Merge all spans in order, starting from contextFontSpanStyle
425     // - Apply the merged SpanStyle to the minimal interval
426     var lastTransitionOffsets = transitionOffsets.first()
427     for (transitionOffset in transitionOffsets) {
428         // There might be duplicated transition offsets, we skip them here.
429         if (transitionOffset == lastTransitionOffsets) {
430             continue
431         }
432 
433         // Check all spans that intersects with this transition range.
434         var mergedSpanStyle = contextFontSpanStyle
435         spanStyles.fastForEach { spanStyle ->
436             // Empty spans do not intersect with anything, skip them.
437             if (
438                 spanStyle.start != spanStyle.end &&
439                     intersect(
440                         lastTransitionOffsets,
441                         transitionOffset,
442                         spanStyle.start,
443                         spanStyle.end
444                     )
445             ) {
446                 mergedSpanStyle = mergedSpanStyle.merge(spanStyle.item)
447             }
448         }
449 
450         mergedSpanStyle?.let { block(it, lastTransitionOffsets, transitionOffset) }
451 
452         lastTransitionOffsets = transitionOffset
453     }
454 }
455 
456 @OptIn(InternalPlatformTextApi::class)
457 @Suppress("DEPRECATION")
createLetterSpacingSpannull458 private fun createLetterSpacingSpan(
459     letterSpacing: TextUnit,
460     density: Density
461 ): MetricAffectingSpan? {
462     return when (letterSpacing.type) {
463         TextUnitType.Sp -> with(density) { LetterSpacingSpanPx(letterSpacing.toPx()) }
464         TextUnitType.Em -> {
465             LetterSpacingSpanEm(letterSpacing.value)
466         }
467         else -> {
468             null
469         }
470     }
471 }
472 
473 private val SpanStyle.needsLetterSpacingSpan: Boolean
474     get() = letterSpacing.type == TextUnitType.Sp || letterSpacing.type == TextUnitType.Em
475 
476 @OptIn(InternalPlatformTextApi::class)
setShadownull477 private fun Spannable.setShadow(shadow: Shadow?, start: Int, end: Int) {
478     shadow?.let {
479         setSpan(
480             ShadowSpan(
481                 it.color.toArgb(),
482                 it.offset.x,
483                 it.offset.y,
484                 correctBlurRadius(it.blurRadius)
485             ),
486             start,
487             end
488         )
489     }
490 }
491 
492 @OptIn(InternalPlatformTextApi::class)
setDrawStylenull493 private fun Spannable.setDrawStyle(drawStyle: DrawStyle?, start: Int, end: Int) {
494     drawStyle?.let { setSpan(DrawStyleSpan(it), start, end) }
495 }
496 
setBackgroundnull497 internal fun Spannable.setBackground(color: Color, start: Int, end: Int) {
498     if (color.isSpecified) {
499         setSpan(BackgroundColorSpan(color.toArgb()), start, end)
500     }
501 }
502 
setLocaleListnull503 internal fun Spannable.setLocaleList(localeList: LocaleList?, start: Int, end: Int) {
504     localeList?.let {
505         setSpan(
506             if (Build.VERSION.SDK_INT >= 24) {
507                 LocaleListHelperMethods.localeSpan(it)
508             } else {
509                 val locale = if (it.isEmpty()) Locale.current else it[0]
510                 LocaleSpan(locale.platformLocale)
511             },
512             start,
513             end
514         )
515     }
516 }
517 
518 @OptIn(InternalPlatformTextApi::class)
setGeometricTransformnull519 private fun Spannable.setGeometricTransform(
520     textGeometricTransform: TextGeometricTransform?,
521     start: Int,
522     end: Int
523 ) {
524     textGeometricTransform?.let {
525         setSpan(ScaleXSpan(it.scaleX), start, end)
526         setSpan(SkewXSpan(it.skewX), start, end)
527     }
528 }
529 
530 @OptIn(InternalPlatformTextApi::class)
setFontFeatureSettingsnull531 private fun Spannable.setFontFeatureSettings(fontFeatureSettings: String?, start: Int, end: Int) {
532     fontFeatureSettings?.let { setSpan(FontFeatureSpan(it), start, end) }
533 }
534 
535 @Suppress("DEPRECATION")
setFontSizenull536 internal fun Spannable.setFontSize(fontSize: TextUnit, density: Density, start: Int, end: Int) {
537     when (fontSize.type) {
538         TextUnitType.Sp ->
539             with(density) {
540                 setSpan(
541                     AbsoluteSizeSpan(/* size */ fontSize.toPx().roundToInt(), /* dip */ false),
542                     start,
543                     end
544                 )
545             }
546         TextUnitType.Em -> {
547             setSpan(RelativeSizeSpan(fontSize.value), start, end)
548         }
549         else -> {} // Do nothing
550     }
551 }
552 
553 @OptIn(InternalPlatformTextApi::class)
setTextDecorationnull554 internal fun Spannable.setTextDecoration(textDecoration: TextDecoration?, start: Int, end: Int) {
555     textDecoration?.let {
556         val textDecorationSpan =
557             TextDecorationSpan(
558                 isUnderlineText = TextDecoration.Underline in it,
559                 isStrikethroughText = TextDecoration.LineThrough in it
560             )
561         setSpan(textDecorationSpan, start, end)
562     }
563 }
564 
setColornull565 internal fun Spannable.setColor(color: Color, start: Int, end: Int) {
566     if (color.isSpecified) {
567         setSpan(ForegroundColorSpan(color.toArgb()), start, end)
568     }
569 }
570 
571 @OptIn(InternalPlatformTextApi::class)
setBaselineShiftnull572 private fun Spannable.setBaselineShift(baselineShift: BaselineShift?, start: Int, end: Int) {
573     baselineShift?.let { setSpan(BaselineShiftSpan(it.multiplier), start, end) }
574 }
575 
Spannablenull576 private fun Spannable.setBrush(brush: Brush?, alpha: Float, start: Int, end: Int) {
577     brush?.let {
578         when (brush) {
579             is SolidColor -> {
580                 setColor(brush.value, start, end)
581             }
582             is ShaderBrush -> {
583                 setSpan(ShaderBrushSpan(brush, alpha), start, end)
584             }
585         }
586     }
587 }
588 
589 /**
590  * Returns true if there is any font settings on this [TextStyle].
591  *
592  * @see hasFontAttributes
593  */
hasFontAttributesnull594 private fun TextStyle.hasFontAttributes(): Boolean {
595     return toSpanStyle().hasFontAttributes() || fontSynthesis != null
596 }
597 
598 /** Helper function that merges a nullable [SpanStyle] with another [SpanStyle]. */
mergenull599 private fun SpanStyle?.merge(spanStyle: SpanStyle): SpanStyle {
600     if (this == null) return spanStyle
601     return this.merge(spanStyle)
602 }
603