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