blob: 639186d6e9965ad2ec586de0d6b2335b646760fd [file] [log] [blame]
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.compose.ui.text.platform
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.text.AnnotatedString.Range
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.Paragraph
import androidx.compose.ui.text.ParagraphIntrinsics
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.SkiaParagraph
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.ceilToInt
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontFamilyResolverImpl
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontSynthesis
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.font.SkiaFontLoader
import androidx.compose.ui.text.font.createFontFamilyResolver
import androidx.compose.ui.text.intl.LocaleList
import androidx.compose.ui.text.style.BaselineShift
import androidx.compose.ui.text.style.ResolvedTextDirection
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextGeometricTransform
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.isSpecified
import androidx.compose.ui.unit.isUnspecified
import androidx.compose.ui.unit.sp
import org.jetbrains.skia.Font as SkFont
import org.jetbrains.skia.FontStyle as SkFontStyle
import org.jetbrains.skia.Paint
import org.jetbrains.skia.paragraph.Alignment as SkAlignment
import org.jetbrains.skia.paragraph.BaselineMode
import org.jetbrains.skia.paragraph.DecorationLineStyle as SkDecorationLineStyle
import org.jetbrains.skia.paragraph.DecorationStyle as SkDecorationStyle
import org.jetbrains.skia.paragraph.Direction as SkDirection
import org.jetbrains.skia.paragraph.Paragraph as SkParagraph
import org.jetbrains.skia.paragraph.ParagraphBuilder as SkParagraphBuilder
import org.jetbrains.skia.paragraph.ParagraphStyle
import org.jetbrains.skia.paragraph.PlaceholderAlignment
import org.jetbrains.skia.paragraph.PlaceholderStyle
import org.jetbrains.skia.paragraph.Shadow as SkShadow
import org.jetbrains.skia.paragraph.StrutStyle
import org.jetbrains.skia.paragraph.TextBox
import org.jetbrains.skia.paragraph.TextStyle as SkTextStyle
private val DefaultFontSize = 16.sp
@Suppress("DEPRECATION")
@Deprecated(
"Font.ResourceLoader is deprecated, instead pass FontFamily.Resolver",
replaceWith = ReplaceWith("ActualParagraph(text, style, spanStyles, placeholders, " +
"maxLines, ellipsis, width, density, fontFamilyResolver)"),
)
internal actual fun ActualParagraph(
text: String,
style: TextStyle,
spanStyles: List<Range<SpanStyle>>,
placeholders: List<Range<Placeholder>>,
maxLines: Int,
ellipsis: Boolean,
width: Float,
density: Density,
@Suppress("DEPRECATION") resourceLoader: Font.ResourceLoader
): Paragraph = SkiaParagraph(
SkiaParagraphIntrinsics(
text,
style,
spanStyles,
placeholders,
density,
createFontFamilyResolver(resourceLoader)
),
maxLines,
ellipsis,
Constraints(maxWidth = width.ceilToInt())
)
internal actual fun ActualParagraph(
text: String,
style: TextStyle,
spanStyles: List<Range<SpanStyle>>,
placeholders: List<Range<Placeholder>>,
maxLines: Int,
ellipsis: Boolean,
constraints: Constraints,
density: Density,
fontFamilyResolver: FontFamily.Resolver
): Paragraph = SkiaParagraph(
SkiaParagraphIntrinsics(
text,
style,
spanStyles,
placeholders,
density,
fontFamilyResolver
),
maxLines,
ellipsis,
constraints
)
@Suppress("UNUSED_PARAMETER")
internal actual fun ActualParagraph(
paragraphIntrinsics: ParagraphIntrinsics,
maxLines: Int,
ellipsis: Boolean,
constraints: Constraints
): Paragraph = SkiaParagraph(
paragraphIntrinsics as SkiaParagraphIntrinsics,
maxLines,
ellipsis,
constraints
)
private fun fontSizeInHierarchy(density: Density, base: Float, other: TextUnit): Float {
return when {
other.isUnspecified -> base
other.isEm -> base * other.value
other.isSp -> with(density) { other.toPx() }
else -> error("Unexpected size in fontSizeInHierarchy")
}
}
// Computed ComputedStyles always have font/letter size in pixels for particular `density`.
// It's important because density could be changed in runtime and it should force
// SkTextStyle to be recalculated. Or we can have different densities in different windows.
internal data class ComputedStyle(
var color: Color,
var fontSize: Float,
var fontWeight: FontWeight?,
var fontStyle: FontStyle?,
var fontSynthesis: FontSynthesis?,
var fontFamily: FontFamily?,
var fontFeatureSettings: String?,
var letterSpacing: Float?,
var baselineShift: BaselineShift?,
var textGeometricTransform: TextGeometricTransform?,
var localeList: LocaleList?,
var background: Color = Color.Unspecified,
var textDecoration: TextDecoration?,
var shadow: Shadow?
) {
constructor(density: Density, spanStyle: SpanStyle) : this(
color = spanStyle.color,
fontSize = with(density) { spanStyle.fontSize.toPx() },
fontWeight = spanStyle.fontWeight,
fontStyle = spanStyle.fontStyle,
fontSynthesis = spanStyle.fontSynthesis,
fontFamily = spanStyle.fontFamily,
fontFeatureSettings = spanStyle.fontFeatureSettings,
letterSpacing = if (spanStyle.letterSpacing.isUnspecified) {
null
} else {
with(density) {
spanStyle.letterSpacing.toPx()
}
},
baselineShift = spanStyle.baselineShift,
textGeometricTransform = spanStyle.textGeometricTransform,
localeList = spanStyle.localeList,
background = spanStyle.background,
textDecoration = spanStyle.textDecoration,
shadow = spanStyle.shadow
)
@OptIn(ExperimentalTextApi::class)
fun toSkTextStyle(fontFamilyResolver: FontFamily.Resolver): SkTextStyle {
val res = SkTextStyle()
if (color != Color.Unspecified) {
res.color = color.toArgb()
}
fontStyle?.let {
res.fontStyle = it.toSkFontStyle()
}
textDecoration?.let {
res.decorationStyle = it.toSkDecorationStyle(this.color)
}
if (background != Color.Unspecified) {
res.background = Paint().also {
it.color = background.toArgb()
}
}
fontWeight?.let {
res.fontStyle = res.fontStyle.withWeight(it.weight)
}
shadow?.let {
res.addShadow(it.toSkShadow())
}
letterSpacing?.let {
res.letterSpacing = it
}
res.fontSize = fontSize
fontFamily?.let {
@Suppress("UNCHECKED_CAST")
val resolved = fontFamilyResolver.resolve(
it,
fontWeight ?: FontWeight.Normal,
fontStyle ?: FontStyle.Normal,
fontSynthesis ?: FontSynthesis.None
).value as FontLoadResult
res.fontFamilies = resolved.aliases.toTypedArray()
}
return res
}
fun merge(density: Density, other: SpanStyle) {
val fontSize = fontSizeInHierarchy(density, fontSize, other.fontSize)
if (other.color.isSpecified) {
color = other.color
}
other.fontFamily?.let { fontFamily = it }
this.fontSize = fontSize
other.fontWeight?.let { fontWeight = it }
other.fontStyle?.let { fontStyle = it }
other.fontSynthesis?.let { fontSynthesis = it }
other.fontFeatureSettings?.let { fontFeatureSettings = it }
if (!other.letterSpacing.isUnspecified) {
when {
other.letterSpacing.isEm ->
letterSpacing = fontSize * other.letterSpacing.value
other.letterSpacing.isSp ->
letterSpacing = with(density) {
other.letterSpacing.toPx()
}
else -> throw UnsupportedOperationException()
}
}
other.baselineShift?.let { baselineShift = it }
other.textGeometricTransform?.let { textGeometricTransform = it }
other.localeList?.let { localeList = it }
if (other.background.isSpecified) {
background = other.background
}
other.textDecoration?.let { textDecoration = it }
other.shadow?.let { shadow = it }
}
}
internal expect class WeakHashMap<K, V>() : MutableMap<K, V>
// Building of SkTextStyle is a relatively expensive operation. We enable simple caching by
// mapping SpanStyle to SkTextStyle. To increase the efficiency of this mapping we are making
// most of the computations before converting Compose paragraph styles to Skia paragraph
private val skTextStylesCache = WeakHashMap<ComputedStyle, SkTextStyle>()
internal class ParagraphBuilder(
val fontFamilyResolver: FontFamily.Resolver,
val text: String,
var textStyle: TextStyle,
var ellipsis: String = "",
var maxLines: Int = Int.MAX_VALUE,
val spanStyles: List<Range<SpanStyle>>,
val placeholders: List<Range<Placeholder>>,
val density: Density,
val textDirection: ResolvedTextDirection
) {
private lateinit var initialStyle: SpanStyle
private lateinit var defaultStyle: ComputedStyle
private lateinit var ops: List<Op>
/**
* SkParagraph styles model doesn't match Compose's one.
* SkParagraph has only a stack-based push/pop styles interface that works great with Span
* trees.
* But in Compose we have a list of SpanStyles attached to arbitrary ranges, possibly
* overlapped, where a position in the list denotes style's priority
* We map Compose styles to SkParagraph styles by projecting every range start/end to single
* positions line and maintaining a list of active styles while building a paragraph. This list
* of active styles is being compiled into single SkParagraph's style for every chunk of text
*/
@OptIn(ExperimentalTextApi::class)
fun build(): SkParagraph {
initialStyle = textStyle.toSpanStyle().withDefaultFontSize()
defaultStyle = ComputedStyle(density, initialStyle)
ops = makeOps(
spanStyles,
placeholders
)
var pos = 0
val ps = textStyleToParagraphStyle(textStyle, defaultStyle)
if (maxLines != Int.MAX_VALUE) {
ps.maxLinesCount = maxLines
ps.ellipsis = ellipsis
}
// this downcast is always safe because of sealed types and we control construction
@OptIn(ExperimentalTextApi::class)
val platformFontLoader = (fontFamilyResolver as FontFamilyResolverImpl).platformFontLoader
val fontCollection = when (platformFontLoader) {
is SkiaFontLoader -> platformFontLoader.fontCollection
else -> throw IllegalStateException("Unsupported font loader $platformFontLoader")
}
val pb = SkParagraphBuilder(ps, fontCollection)
var addText = true
for (op in ops) {
if (addText && pos < op.position) {
pb.addText(text.subSequence(pos, op.position).toString())
}
when (op) {
is Op.StyleAdd -> {
// FontLoader may have changed, so ensure that Font resolution is still valid
fontFamilyResolver.resolve(
op.style.fontFamily,
op.style.fontWeight ?: FontWeight.Normal,
op.style.fontStyle ?: FontStyle.Normal,
op.style.fontSynthesis ?: FontSynthesis.All
)
pb.pushStyle(makeSkTextStyle(op.style))
}
is Op.PutPlaceholder -> {
val placeholderStyle =
PlaceholderStyle(
op.width,
op.height,
op.cut.placeholder.placeholderVerticalAlign
.toSkPlaceholderAlignment(),
// TODO: figure out how exactly we have to work with BaselineMode & offset
BaselineMode.ALPHABETIC,
0f
)
pb.addPlaceholder(placeholderStyle)
addText = false
}
is Op.EndPlaceholder -> {
addText = true
}
}
pos = op.position
}
if (addText && pos < text.length) {
pb.addText(text.subSequence(pos, text.length).toString())
}
return pb.build()
}
private sealed class Op {
abstract val position: Int
data class StyleAdd(
override val position: Int,
val style: ComputedStyle
) : Op()
data class PutPlaceholder(
val cut: Cut.PutPlaceholder,
var width: Float,
var height: Float
) : Op() {
override val position: Int by cut::position
}
data class EndPlaceholder(
val cut: Cut.EndPlaceholder
) : Op() {
override val position: Int by cut::position
}
}
private sealed class Cut {
abstract val position: Int
data class StyleAdd(
override val position: Int,
val style: SpanStyle
) : Cut()
data class StyleRemove(
override val position: Int,
val style: SpanStyle
) : Cut()
data class PutPlaceholder(
override val position: Int,
val placeholder: Placeholder,
) : Cut()
data class EndPlaceholder(override val position: Int) : Cut()
}
private fun makeOps(
spans: List<Range<SpanStyle>>,
placeholders: List<Range<Placeholder>>
): List<Op> {
val cuts = mutableListOf<Cut>()
for (span in spans) {
cuts.add(Cut.StyleAdd(span.start, span.item))
cuts.add(Cut.StyleRemove(span.end, span.item))
}
for (placeholder in placeholders) {
cuts.add(Cut.PutPlaceholder(placeholder.start, placeholder.item))
cuts.add(Cut.EndPlaceholder(placeholder.end))
}
val ops = mutableListOf<Op>(Op.StyleAdd(0, defaultStyle))
cuts.sortBy { it.position }
val activeStyles = mutableListOf(initialStyle)
for (cut in cuts) {
when {
cut is Cut.StyleAdd -> {
activeStyles.add(cut.style)
val prev = previousStyleAddAtTheSamePosition(cut.position, ops)
if (prev == null) {
ops.add(
Op.StyleAdd(
cut.position,
mergeStyles(activeStyles).also { it.merge(density, cut.style) }
)
)
} else {
prev.style.merge(density, cut.style)
}
}
cut is Cut.StyleRemove -> {
activeStyles.remove(cut.style)
ops.add(Op.StyleAdd(cut.position, mergeStyles(activeStyles)))
}
cut is Cut.PutPlaceholder -> {
val currentStyle = mergeStyles(activeStyles)
val op = Op.PutPlaceholder(
cut = cut,
width = fontSizeInHierarchy(
density,
currentStyle.fontSize,
cut.placeholder.width
),
height = fontSizeInHierarchy(
density,
currentStyle.fontSize,
cut.placeholder.height
),
)
ops.add(op)
}
cut is Cut.EndPlaceholder ->
ops.add(Op.EndPlaceholder(cut))
}
}
return ops
}
private fun mergeStyles(activeStyles: List<SpanStyle>): ComputedStyle {
// there is always at least one active style
val style = ComputedStyle(density, activeStyles[0])
for (i in 1 until activeStyles.size) {
style.merge(density, activeStyles[i])
}
return style
}
private fun previousStyleAddAtTheSamePosition(position: Int, ops: List<Op>): Op.StyleAdd? {
for (prevOp in ops.asReversed()) {
if (prevOp.position < position) return null
if (prevOp is Op.StyleAdd) return prevOp
}
return null
}
private fun textStyleToParagraphStyle(
style: TextStyle,
computedStyle: ComputedStyle
): ParagraphStyle {
val pStyle = ParagraphStyle()
pStyle.textStyle = makeSkTextStyle(computedStyle)
style.textAlign.let {
pStyle.alignment = it.toSkAlignment()
}
if (style.lineHeight.isSpecified) {
val strutStyle = StrutStyle()
strutStyle.isEnabled = true
strutStyle.isHeightOverridden = true
val fontSize = with(density) {
style.fontSize.orDefaultFontSize().toPx()
}
val lineHeight = when {
style.lineHeight.isSp -> with(density) {
style.lineHeight.toPx()
}
style.lineHeight.isEm -> fontSize * style.lineHeight.value
else -> error("Unexpected size in textStyleToParagraphStyle")
}
strutStyle.height = lineHeight / fontSize
pStyle.strutStyle = strutStyle
}
pStyle.direction = textDirection.toSkDirection()
return pStyle
}
private fun makeSkTextStyle(style: ComputedStyle): SkTextStyle {
return skTextStylesCache.getOrPut(style) {
style.toSkTextStyle(fontFamilyResolver)
}
}
@OptIn(ExperimentalTextApi::class)
internal val defaultFont by lazy {
val loadResult = textStyle.fontFamily?.let {
@Suppress("UNCHECKED_CAST")
fontFamilyResolver.resolve(
it,
textStyle.fontWeight ?: FontWeight.Normal,
textStyle.fontStyle ?: FontStyle.Normal,
textStyle.fontSynthesis ?: FontSynthesis.All
).value as FontLoadResult
}
SkFont(loadResult?.typeface, defaultStyle.fontSize)
}
internal val defaultHeight by lazy {
defaultFont.metrics.height
}
}
private fun TextUnit.orDefaultFontSize() = when {
isUnspecified -> DefaultFontSize
isEm -> DefaultFontSize * value
else -> this
}
private fun SpanStyle.withDefaultFontSize(): SpanStyle {
val fontSize = this.fontSize.orDefaultFontSize()
val letterSpacing = when {
this.letterSpacing.isEm -> fontSize * this.letterSpacing.value
else -> this.letterSpacing
}
return this.copy(
fontSize = fontSize,
letterSpacing = letterSpacing
)
}
fun FontStyle.toSkFontStyle(): SkFontStyle {
return when (this) {
FontStyle.Italic -> org.jetbrains.skia.FontStyle.ITALIC
else -> org.jetbrains.skia.FontStyle.NORMAL
}
}
fun TextDecoration.toSkDecorationStyle(color: Color): SkDecorationStyle {
val underline = contains(TextDecoration.Underline)
val overline = false
val lineThrough = contains(TextDecoration.LineThrough)
val gaps = false
val lineStyle = SkDecorationLineStyle.SOLID
val thicknessMultiplier = 1f
return SkDecorationStyle(
underline,
overline,
lineThrough,
gaps,
color.toArgb(),
lineStyle,
thicknessMultiplier
)
}
fun PlaceholderVerticalAlign.toSkPlaceholderAlignment(): PlaceholderAlignment {
return when (this) {
PlaceholderVerticalAlign.AboveBaseline -> PlaceholderAlignment.ABOVE_BASELINE
PlaceholderVerticalAlign.TextTop -> PlaceholderAlignment.TOP
PlaceholderVerticalAlign.TextBottom -> PlaceholderAlignment.BOTTOM
PlaceholderVerticalAlign.TextCenter -> PlaceholderAlignment.MIDDLE
// TODO: figure out how we have to handle it properly
PlaceholderVerticalAlign.Top -> PlaceholderAlignment.TOP
PlaceholderVerticalAlign.Bottom -> PlaceholderAlignment.BOTTOM
PlaceholderVerticalAlign.Center -> PlaceholderAlignment.MIDDLE
else -> error("Invalid PlaceholderVerticalAlign.")
}
}
internal fun Shadow.toSkShadow(): SkShadow {
return SkShadow(color.toArgb(), offset.x, offset.y, blurRadius.toDouble())
}
internal fun TextAlign.toSkAlignment(): SkAlignment {
return when (this) {
TextAlign.Unspecified -> SkAlignment.START
TextAlign.Left -> SkAlignment.LEFT
TextAlign.Right -> SkAlignment.RIGHT
TextAlign.Center -> SkAlignment.CENTER
TextAlign.Justify -> SkAlignment.JUSTIFY
TextAlign.Start -> SkAlignment.START
TextAlign.End -> SkAlignment.END
else -> error("Invalid TextAlign: $this")
}
}
internal fun ResolvedTextDirection.toSkDirection(): SkDirection {
return when (this) {
ResolvedTextDirection.Ltr -> SkDirection.LTR
ResolvedTextDirection.Rtl -> SkDirection.RTL
}
}
internal fun TextBox.cursorHorizontalPosition(opposite: Boolean = false): Float {
return when (direction) {
SkDirection.LTR -> if (opposite) rect.left else rect.right
SkDirection.RTL -> if (opposite) rect.right else rect.left
}
}