/*
* Copyright 2022 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.font
import android.content.Context
import android.graphics.Paint
import android.graphics.Typeface
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.annotation.VisibleForTesting
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.fastJoinToString
import androidx.compose.ui.unit.Density
/**
* Primary internal interface for resolving typefaces from Android platform
*/
internal interface PlatformTypefaces {
/**
* Resolve the system default font
*
* @param fontWeight weight to load, if available, will fallback
* @param fontStyle italic or not, will fallback
*/
fun createDefault(fontWeight: FontWeight, fontStyle: FontStyle): Typeface
/**
* Resolve a system named font. Only supports the names in FontFamily.* definitions.
*
* @param name name of a font, as specified in FontFamily.*
* @param fontWeight weight to load, if available, will fallback
* @param fontStyle italic or not, will fallback
*/
fun createNamed(name: GenericFontFamily, fontWeight: FontWeight, fontStyle: FontStyle): Typeface
/**
* Find a font by name that does not match Typeface.DEFAULT.
*
* This is used to figure out if the platform has fonts like "comic-sans" preloaded.
*
* @param familyName font to attempt to load from system caches
* @param weight weight to load, if available, will fallback
* @param style italic or not, will fallback
* @return typeface from system cache if available, or null if the system doesn't know this font
* name
*/
@OptIn(ExperimentalTextApi::class)
fun optionalOnDeviceFontFamilyByName(
familyName: String,
weight: FontWeight,
style: FontStyle,
variationSettings: FontVariation.Settings,
context: Context
): Typeface?
}
internal fun PlatformTypefaces(): PlatformTypefaces {
return if (Build.VERSION.SDK_INT >= 28) {
PlatformTypefacesApi28()
} else {
PlatformTypefacesApi()
}
}
@VisibleForTesting
private class PlatformTypefacesApi : PlatformTypefaces {
override fun createDefault(fontWeight: FontWeight, fontStyle: FontStyle) =
createAndroidTypefaceUsingTypefaceStyle(null, fontWeight, fontStyle)
override fun createNamed(
name: GenericFontFamily,
fontWeight: FontWeight,
fontStyle: FontStyle
): Typeface {
return loadNamedFromTypefaceCacheOrNull(
getWeightSuffixForFallbackFamilyName(name.name, fontWeight),
fontWeight,
fontStyle
) ?: createAndroidTypefaceUsingTypefaceStyle(name.name, fontWeight, fontStyle)
}
@OptIn(ExperimentalTextApi::class)
override fun optionalOnDeviceFontFamilyByName(
familyName: String,
weight: FontWeight,
style: FontStyle,
variationSettings: FontVariation.Settings,
context: Context
): Typeface? {
// if the developer specified one of the named fonts, behave identically to the
// GenericFontFamily behavior, return the same as createNamed always
val typeface = when (familyName) {
FontFamily.SansSerif.name -> createNamed(FontFamily.SansSerif, weight, style)
FontFamily.Serif.name -> createNamed(FontFamily.Serif, weight, style)
FontFamily.Monospace.name -> createNamed(FontFamily.Monospace, weight, style)
FontFamily.Cursive.name -> createNamed(FontFamily.Cursive, weight, style)
else -> loadNamedFromTypefaceCacheOrNull(familyName, weight, style)
}
return typeface.setFontVariationSettings(variationSettings, context)
}
private fun loadNamedFromTypefaceCacheOrNull(
familyName: String,
weight: FontWeight,
style: FontStyle
): Typeface? {
if (familyName.isEmpty()) return null
val typeface = createAndroidTypefaceUsingTypefaceStyle(familyName, weight, style)
return typeface.takeIf {
// Typeface may lookup missed results via either Typeface.DEFAULT or null, check both
it != Typeface.create(Typeface.DEFAULT, getAndroidTypefaceStyle(weight, style)) &&
it != createAndroidTypefaceUsingTypefaceStyle(null, weight, style)
}
}
private fun createAndroidTypefaceUsingTypefaceStyle(
genericFontFamily: String? = null,
fontWeight: FontWeight = FontWeight.Normal,
fontStyle: FontStyle = FontStyle.Normal
): Typeface {
if (fontStyle == FontStyle.Normal &&
fontWeight == FontWeight.Normal &&
genericFontFamily.isNullOrEmpty()
) {
return Typeface.DEFAULT
}
val targetStyle = getAndroidTypefaceStyle(fontWeight, fontStyle)
return if (genericFontFamily.isNullOrEmpty()) {
Typeface.defaultFromStyle(targetStyle)
} else {
Typeface.create(genericFontFamily, targetStyle)
}
}
}
@VisibleForTesting
@RequiresApi(28)
private class PlatformTypefacesApi28 : PlatformTypefaces {
@OptIn(ExperimentalTextApi::class)
override fun optionalOnDeviceFontFamilyByName(
familyName: String,
weight: FontWeight,
style: FontStyle,
variationSettings: FontVariation.Settings,
context: Context
): Typeface? {
// if the developer specified one of the named fonts, behave identically to the
// GenericFontFamily behavior, return the same as createNamed always
val result = when (familyName) {
FontFamily.SansSerif.name -> createNamed(FontFamily.SansSerif, weight, style)
FontFamily.Serif.name -> createNamed(FontFamily.Serif, weight, style)
FontFamily.Monospace.name -> createNamed(FontFamily.Monospace, weight, style)
FontFamily.Cursive.name -> createNamed(FontFamily.Cursive, weight, style)
else -> loadNamedFromTypefaceCacheOrNull(familyName, weight, style)
}
return result.setFontVariationSettings(variationSettings, context)
}
override fun createDefault(fontWeight: FontWeight, fontStyle: FontStyle) =
createAndroidTypefaceApi28(null, fontWeight, fontStyle)
/**
* This always exposes the platform behavior, as a well formed Android OEM will have all weights
* supported correctly in one FontFamily on Android 28+
*/
override fun createNamed(
name: GenericFontFamily,
fontWeight: FontWeight,
fontStyle: FontStyle
): Typeface = createAndroidTypefaceApi28(name.name, fontWeight, fontStyle)
private fun loadNamedFromTypefaceCacheOrNull(
familyName: String,
weight: FontWeight,
style: FontStyle
): Typeface? {
if (familyName.isEmpty()) return null
val typeface = createAndroidTypefaceApi28(familyName, weight, style)
val isItalic = style == FontStyle.Italic
return typeface.takeIf {
// Typeface may lookup missed results via either Typeface.DEFAULT or null, check both
it != TypefaceHelperMethodsApi28.create(Typeface.DEFAULT, weight.weight, isItalic) &&
it != createAndroidTypefaceApi28(null, weight, style)
}
}
private fun createAndroidTypefaceApi28(
genericFontFamily: String? = null,
fontWeight: FontWeight,
fontStyle: FontStyle
): Typeface {
if (fontStyle == FontStyle.Normal &&
fontWeight == FontWeight.Normal &&
genericFontFamily.isNullOrEmpty()
) {
return Typeface.DEFAULT
}
val familyTypeface = if (genericFontFamily == null) {
Typeface.DEFAULT
} else {
Typeface.create(genericFontFamily, Typeface.NORMAL)
}
return Typeface.create(
familyTypeface,
fontWeight.weight,
fontStyle == FontStyle.Italic
)
}
}
/**
* Apply font variation settings to a typeface on supported API levels (26+)
*/
@ExperimentalTextApi
internal fun Typeface?.setFontVariationSettings(
variationSettings: FontVariation.Settings,
context: Context,
): Typeface? {
return if (Build.VERSION.SDK_INT >= 26) {
TypefaceCompatApi26.setFontVariationSettings(this, variationSettings, context)
} else {
this
}
}
@RequiresApi(26)
private object TypefaceCompatApi26 {
private var threadLocalPaint: ThreadLocal<Paint> = ThreadLocal()
@ExperimentalTextApi
fun setFontVariationSettings(
typeface: Typeface?,
variationSettings: FontVariation.Settings,
context: Context,
): Typeface? {
if (typeface == null) return null
if (variationSettings.settings.isEmpty()) {
return typeface
}
var localPaint = threadLocalPaint.get()
if (localPaint == null) {
localPaint = Paint()
this.threadLocalPaint.set(localPaint)
}
localPaint.typeface = typeface
localPaint.fontVariationSettings = variationSettings.toAndroidString(context)
return localPaint.typeface
}
@ExperimentalTextApi
private fun FontVariation.Settings.toAndroidString(context: Context): String {
val density = Density(context)
return settings.fastJoinToString { setting ->
"'${setting.axisName}' ${setting.toVariationValue(density)}"
}
}
}
/**
* Convert system family name like "sans-serif" to fallback family names like
* "sans-serif-medium" for platforms <28.
*/
@VisibleForTesting
internal fun getWeightSuffixForFallbackFamilyName(
name: String,
fontWeight: FontWeight
): String {
// logic for matching comes from FontFamily.cpp#computeMatch(FontStyle, FontStyle)
// for our purposes, we expect full-coverage from 100-900 for system fonts, and can ignore
// slant, which makes this calculation (weight / 100)
// fonts from fonts.xml in aosp define these optional fallback fonts
// 100 -thin
// 200 ##### not specified; tie break to 300
// 300 -light
// 400 # normal
// 500 -medium
// 600 ##### not specified; tie break to 700
// 700 #bold
// 900 -black
// In platform, to break ties, the font appearing last (typically higher weight) wins, so we
// will map 200->300; 600->700; 800->900 for the missing values
return when (fontWeight.weight / 100) {
in 0..1 -> "$name-thin" // 100
in 2..3 -> "$name-light" // fallback 200, 300
4 -> name // normal lookup 400
5 -> "$name-medium" // 500
in 6..7 -> name // tie break 600 -> 700, 700
in 8..10 -> "$name-black" // 900 black, fallback 800, no match 1000
else -> name // can't reach
}
}