Add defaults for script/text font pairings and guessing of script based on body contents.

Co-authored-by: Alex Hart <alex@signal.org>
This commit is contained in:
Cody Henthorne
2022-03-30 12:00:29 -04:00
parent 116e711f1a
commit 284a6ae667
15 changed files with 436 additions and 92 deletions

View File

@@ -28,25 +28,25 @@ data class FontManifest(
* @param chineseSimplified Hans Script Fonts
*/
data class FontScripts(
@JsonProperty("latin-extended") val latinExtended: FontScript,
@JsonProperty("cyrillic-extended") val cyrillicExtended: FontScript,
val devanagari: FontScript,
@JsonProperty("chinese-traditional-hk") val chineseTraditionalHk: FontScript,
@JsonProperty("chinese-traditional") val chineseTraditional: FontScript,
@JsonProperty("chinese-simplified") val chineseSimplified: FontScript,
val arabic: FontScript,
val japanese: FontScript,
@JsonProperty("latin-extended") val latinExtended: FontScript?,
@JsonProperty("cyrillic-extended") val cyrillicExtended: FontScript?,
val devanagari: FontScript?,
@JsonProperty("chinese-traditional-hk") val chineseTraditionalHk: FontScript?,
@JsonProperty("chinese-traditional") val chineseTraditional: FontScript?,
@JsonProperty("chinese-simplified") val chineseSimplified: FontScript?,
val arabic: FontScript?,
val japanese: FontScript?,
)
/**
* A collection of fonts for a specific script
*/
data class FontScript(
val regular: String,
val bold: String,
val serif: String,
val script: String,
val condensed: String
val regular: String?,
val bold: String?,
val serif: String?,
val script: String?,
val condensed: String?
)
companion object {
@@ -76,7 +76,7 @@ data class FontManifest(
objectMapper.readValue(it, FontManifest::class.java)
}
} catch (e: Exception) {
Log.w(TAG, "Failed to load manifest from disk")
Log.w(TAG, "Failed to load manifest from disk", e)
return null
}
}

View File

@@ -2,41 +2,44 @@ package org.thoughtcrime.securesms.fonts
import android.content.Context
import android.graphics.Typeface
import android.os.Build
import org.signal.imageeditor.core.Renderer
import org.signal.imageeditor.core.RendererContext
import org.thoughtcrime.securesms.util.FutureTaskListener
import org.thoughtcrime.securesms.util.LocaleUtil
import java.util.Locale
import java.util.concurrent.ExecutionException
/**
* RenderContext TypefaceProvider that provides typefaces using TextFont.
*/
object FontTypefaceProvider : RendererContext.TypefaceProvider {
override fun getSelectedTypeface(context: Context, renderer: Renderer, invalidate: RendererContext.Invalidate): Typeface {
return getTypeface()
// TODO [cody] Need to rework Fonts.kt to not hit network on main, reverting to old typeface for now
// return when (val fontResult = Fonts.resolveFont(context, Locale.getDefault(), TextFont.BOLD)) {
// is Fonts.FontResult.Immediate -> fontResult.typeface
// is Fonts.FontResult.Async -> {
// fontResult.future.addListener(object : FutureTaskListener<Typeface> {
// override fun onSuccess(result: Typeface?) {
// invalidate.onInvalidate(renderer)
// }
//
// override fun onFailure(exception: ExecutionException?) = Unit
// })
//
// fontResult.placeholder
// }
// }
}
class FontTypefaceProvider : RendererContext.TypefaceProvider {
private fun getTypeface(): Typeface {
return if (Build.VERSION.SDK_INT < 26) {
Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
} else {
Typeface.Builder("")
.setFallback("sans-serif")
.setWeight(900)
.build()
private var cachedTypeface: Typeface? = null
private var cachedLocale: Locale? = null
override fun getSelectedTypeface(context: Context, renderer: Renderer, invalidate: RendererContext.Invalidate): Typeface {
val typeface = cachedTypeface
if (typeface != null && cachedLocale == LocaleUtil.getFirstLocale()) {
return typeface
}
return when (val fontResult = Fonts.resolveFont(context, TextFont.BOLD)) {
is Fonts.FontResult.Immediate -> {
cachedTypeface = fontResult.typeface
cachedLocale = LocaleUtil.getFirstLocale()
fontResult.typeface
}
is Fonts.FontResult.Async -> {
fontResult.future.addListener(object : FutureTaskListener<Typeface> {
override fun onSuccess(result: Typeface?) {
invalidate.onInvalidate(renderer)
}
override fun onFailure(exception: ExecutionException?) = Unit
})
fontResult.placeholder
}
}
}
}

View File

@@ -7,6 +7,7 @@ import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.s3.S3
import org.thoughtcrime.securesms.util.ListenableFutureTask
import org.thoughtcrime.securesms.util.LocaleUtil
import java.io.File
import java.util.Collections
import java.util.Locale
@@ -46,15 +47,16 @@ object Fonts {
}
/**
* Attempts to retrieve a Typeface for the given font / locale combination
* Attempts to retrieve a Typeface for the given font / guessed script and default locales combination
*
* @param context An application context
* @param locale The locale the content will be displayed in
* @param font The desired font
* @param guessedScript The script likely being used based on text content
*
* @return a FontResult that represents either a Typeface or a task retrieving a Typeface.
*/
@WorkerThread
fun resolveFont(context: Context, locale: Locale, font: TextFont): FontResult {
fun resolveFont(context: Context, font: TextFont, guessedScript: SupportedScript = SupportedScript.UNKNOWN): FontResult {
synchronized(this) {
val errorFallback = FontResult.Immediate(Typeface.create(font.fallbackFamily, font.fallbackStyle))
val version = FontVersion.get(context)
@@ -66,11 +68,21 @@ object Fonts {
Log.d(TAG, "Loaded manifest.")
val fontScript = resolveScriptNameFromLocale(locale, manifest) ?: return errorFallback
val localeDefaults: List<Locale> = LocaleUtil.getLocaleDefaults()
val supportedScript: SupportedScript = getSupportedScript(localeDefaults, guessedScript)
val fontScript = resolveFontScriptFromScriptName(supportedScript, manifest)
if (fontScript == null) {
Log.d(TAG, "Manifest does not have an entry for $supportedScript. Using default.")
return FontResult.Immediate(getDefaultFontForScriptAndStyle(supportedScript, font))
}
Log.d(TAG, "Loaded script for locale.")
val fontNetworkPath = getScriptPath(font, fontScript)
if (fontNetworkPath == null) {
Log.d(TAG, "Manifest does not contain a network path for $supportedScript. Using default.")
return FontResult.Immediate(getDefaultFontForScriptAndStyle(supportedScript, font))
}
val fontLocalPath = FontFileMap.getNameOnDisk(context, version, fontNetworkPath)
@@ -80,7 +92,7 @@ object Fonts {
}
val fontDownloadKey = FontDownloadKey(
version, locale, font
version, supportedScript, font
)
val taskInProgress = taskCache[fontDownloadKey]
@@ -93,7 +105,7 @@ object Fonts {
} else {
Log.d(TAG, "Could not find a task in progress. Returning new async.")
val newTask = ListenableFutureTask {
val newLocalPath = downloadFont(context, locale, font, version, manifest)
val newLocalPath = downloadFont(context, supportedScript, font, version, manifest)
Log.d(TAG, "Finished download, $newLocalPath")
val typeface = newLocalPath?.let { loadFontIntoTypeface(context, version, it) } ?: errorFallback.typeface
@@ -112,6 +124,69 @@ object Fonts {
}
}
private fun getDefaultFontForScriptAndStyle(supportedScript: SupportedScript, font: TextFont): Typeface {
return when (supportedScript) {
SupportedScript.CYRILLIC -> {
when (font) {
TextFont.REGULAR -> Typeface.SANS_SERIF
TextFont.BOLD -> Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD)
TextFont.SERIF -> Typeface.SERIF
TextFont.SCRIPT -> TypefaceHelper.typefaceFor(TypefaceHelper.Family.SERIF, "semibold", TypefaceHelper.Weight.SEMI_BOLD)
TextFont.CONDENSED -> TypefaceHelper.typefaceFor(TypefaceHelper.Family.SANS_SERIF, "light", TypefaceHelper.Weight.LIGHT)
}
}
SupportedScript.DEVANAGARI -> {
when (font) {
TextFont.REGULAR -> Typeface.SANS_SERIF
TextFont.BOLD -> Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD)
TextFont.SERIF -> Typeface.SANS_SERIF
TextFont.SCRIPT -> Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD)
TextFont.CONDENSED -> TypefaceHelper.typefaceFor(TypefaceHelper.Family.SANS_SERIF, "light", TypefaceHelper.Weight.LIGHT)
}
}
SupportedScript.CHINESE_TRADITIONAL_HK,
SupportedScript.CHINESE_TRADITIONAL,
SupportedScript.CHINESE_SIMPLIFIED,
SupportedScript.UNKNOWN_CJK -> {
when (font) {
TextFont.REGULAR -> Typeface.SANS_SERIF
TextFont.BOLD -> TypefaceHelper.typefaceFor(TypefaceHelper.Family.SANS_SERIF, "semibold", TypefaceHelper.Weight.SEMI_BOLD)
TextFont.SERIF -> TypefaceHelper.typefaceFor(TypefaceHelper.Family.SANS_SERIF, "thin", TypefaceHelper.Weight.THIN)
TextFont.SCRIPT -> TypefaceHelper.typefaceFor(TypefaceHelper.Family.SANS_SERIF, "light", TypefaceHelper.Weight.LIGHT)
TextFont.CONDENSED -> TypefaceHelper.typefaceFor(TypefaceHelper.Family.SANS_SERIF, "demilight", TypefaceHelper.Weight.DEMI_LIGHT)
}
}
SupportedScript.ARABIC -> {
when (font) {
TextFont.REGULAR -> Typeface.SANS_SERIF
TextFont.BOLD -> Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD)
TextFont.SERIF -> Typeface.SERIF
TextFont.SCRIPT -> Typeface.create(Typeface.SERIF, Typeface.BOLD)
TextFont.CONDENSED -> TypefaceHelper.typefaceFor(TypefaceHelper.Family.SANS_SERIF, "black", TypefaceHelper.Weight.BLACK)
}
}
SupportedScript.JAPANESE -> {
when (font) {
TextFont.REGULAR -> Typeface.SANS_SERIF
TextFont.BOLD -> Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD)
TextFont.SERIF -> Typeface.SERIF
TextFont.SCRIPT -> Typeface.create(Typeface.SERIF, Typeface.BOLD)
TextFont.CONDENSED -> TypefaceHelper.typefaceFor(TypefaceHelper.Family.SANS_SERIF, "medium", TypefaceHelper.Weight.MEDIUM)
}
}
SupportedScript.LATIN,
SupportedScript.UNKNOWN -> {
when (font) {
TextFont.REGULAR -> Typeface.SANS_SERIF
TextFont.BOLD -> Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD)
TextFont.SERIF -> Typeface.SERIF
TextFont.SCRIPT -> Typeface.create(Typeface.SERIF, Typeface.BOLD)
TextFont.CONDENSED -> TypefaceHelper.typefaceFor(TypefaceHelper.Family.SANS_SERIF, "black", TypefaceHelper.Weight.BLACK)
}
}
}
}
@WorkerThread
private fun loadFontIntoTypeface(context: Context, fontVersion: FontVersion, fontLocalPath: String): Typeface? {
return try {
@@ -146,9 +221,13 @@ object Fonts {
* Downloads the given font file from S3
*/
@WorkerThread
private fun downloadFont(context: Context, locale: Locale, font: TextFont, fontVersion: FontVersion, fontManifest: FontManifest): String? {
val script: FontManifest.FontScript = resolveScriptNameFromLocale(locale, fontManifest) ?: return null
val path = getScriptPath(font, script)
private fun downloadFont(context: Context, supportedScript: SupportedScript?, font: TextFont, fontVersion: FontVersion, fontManifest: FontManifest): String? {
if (supportedScript == null) {
return null
}
val script: FontManifest.FontScript = resolveFontScriptFromScriptName(supportedScript, fontManifest) ?: return null
val path = getScriptPath(font, script) ?: return null
val networkPath = "$BASE_STATIC_BUCKET_URL/${fontVersion.id}/$path"
val localUUID = UUID.randomUUID().toString()
val localPath = "${fontVersion.path}/" + localUUID
@@ -162,7 +241,7 @@ object Fonts {
}
}
private fun getScriptPath(font: TextFont, script: FontManifest.FontScript): String {
private fun getScriptPath(font: TextFont, script: FontManifest.FontScript): String? {
return when (font) {
TextFont.REGULAR -> script.regular
TextFont.BOLD -> script.bold
@@ -172,22 +251,62 @@ object Fonts {
}
}
private fun resolveScriptNameFromLocale(locale: Locale, fontManifest: FontManifest): FontManifest.FontScript? {
val fontScript: FontManifest.FontScript = when (ScriptUtil.getScript(locale).apply { Log.d(TAG, "Getting Script for $this") }) {
ScriptUtil.LATIN -> fontManifest.scripts.latinExtended
ScriptUtil.ARABIC -> fontManifest.scripts.arabic
ScriptUtil.CHINESE_SIMPLIFIED -> fontManifest.scripts.chineseSimplified
ScriptUtil.CHINESE_TRADITIONAL -> fontManifest.scripts.chineseTraditional
ScriptUtil.CYRILLIC -> fontManifest.scripts.cyrillicExtended
ScriptUtil.DEVANAGARI -> fontManifest.scripts.devanagari
ScriptUtil.JAPANESE -> fontManifest.scripts.japanese
else -> return null
private fun getSupportedScript(locales: List<Locale>, guessedScript: SupportedScript): SupportedScript {
if (guessedScript != SupportedScript.UNKNOWN && guessedScript != SupportedScript.UNKNOWN_CJK) {
return guessedScript
} else if (guessedScript == SupportedScript.UNKNOWN_CJK) {
val likelyScript: SupportedScript? = locales.mapNotNull {
try {
when (it.isO3Country) {
"HKG" -> SupportedScript.CHINESE_TRADITIONAL_HK
"CHN" -> SupportedScript.CHINESE_SIMPLIFIED
"TWN" -> SupportedScript.CHINESE_TRADITIONAL
"JPN" -> SupportedScript.JAPANESE
else -> null
}
} catch (e: java.util.MissingResourceException) {
Log.w(TAG, "Unable to get ISO-3 country code for: $it")
null
}
}.firstOrNull()
if (likelyScript != null) {
return likelyScript
}
}
return if (fontScript == fontManifest.scripts.chineseSimplified && locale.isO3Country == "HKG") {
fontManifest.scripts.chineseTraditionalHk
val locale = locales.first()
val supportedScript: SupportedScript = when (ScriptUtil.getScript(locale).also { Log.d(TAG, "Getting Script for $it") }) {
ScriptUtil.LATIN -> SupportedScript.LATIN
ScriptUtil.ARABIC -> SupportedScript.ARABIC
ScriptUtil.CHINESE_SIMPLIFIED -> SupportedScript.CHINESE_SIMPLIFIED
ScriptUtil.CHINESE_TRADITIONAL -> SupportedScript.CHINESE_TRADITIONAL
ScriptUtil.CYRILLIC -> SupportedScript.CYRILLIC
ScriptUtil.DEVANAGARI -> SupportedScript.DEVANAGARI
ScriptUtil.JAPANESE -> SupportedScript.JAPANESE
else -> SupportedScript.UNKNOWN
}
return if (supportedScript == SupportedScript.CHINESE_SIMPLIFIED && locale.isO3Country == "HKG") {
SupportedScript.CHINESE_TRADITIONAL_HK
} else {
fontScript
supportedScript
}
}
private fun resolveFontScriptFromScriptName(supportedScript: SupportedScript?, fontManifest: FontManifest): FontManifest.FontScript? {
return when (supportedScript.also { Log.d(TAG, "Getting Script for $it") }) {
SupportedScript.LATIN -> fontManifest.scripts.latinExtended
SupportedScript.ARABIC -> fontManifest.scripts.arabic
SupportedScript.CHINESE_SIMPLIFIED -> fontManifest.scripts.chineseSimplified
SupportedScript.CHINESE_TRADITIONAL -> fontManifest.scripts.chineseTraditional
SupportedScript.CYRILLIC -> fontManifest.scripts.cyrillicExtended
SupportedScript.DEVANAGARI -> fontManifest.scripts.devanagari
SupportedScript.JAPANESE -> fontManifest.scripts.japanese
SupportedScript.CHINESE_TRADITIONAL_HK -> fontManifest.scripts.chineseTraditionalHk
SupportedScript.UNKNOWN_CJK -> null
SupportedScript.UNKNOWN -> null
null -> null
}
}
@@ -201,7 +320,7 @@ object Fonts {
private data class FontDownloadKey(
val version: FontVersion,
val locale: Locale,
val script: SupportedScript,
val font: TextFont
)
}

View File

@@ -0,0 +1,17 @@
package org.thoughtcrime.securesms.fonts
/**
* Scripts with font support for Stories
*/
enum class SupportedScript {
LATIN,
CYRILLIC,
DEVANAGARI,
CHINESE_TRADITIONAL_HK,
CHINESE_TRADITIONAL,
CHINESE_SIMPLIFIED,
UNKNOWN_CJK,
ARABIC,
JAPANESE,
UNKNOWN
}

View File

@@ -0,0 +1,50 @@
package org.thoughtcrime.securesms.fonts
/**
* Attempt to guess the script based on the unicode characters used. The script
* with the most use in a string will be picked. Tie goes to [SupportedScript.LATIN].
* Unicode does not cleanly separate Hong Kong and Chinese character ranges so some
* other means must be used to distinguish it (e.g., Locale).
*/
object TextToScript {
private val LATIN_RANGES: List<IntRange> = listOf(0x0000..0x024F, 0x1E00..0x1EFF, 0x2C60..0x2C7F, 0xA720..0xA7FF, 0xAB30..0xAB6F)
private val CYRILLIC_RANGES: List<IntRange> = listOf(0x0400..0x04FF, 0x0500..0x052F, 0x1C80..0x1C8F, 0x2DE0..0x2DFF, 0xA640..0xA69F)
private val DEVANAGARI_RANGES: List<IntRange> = listOf(0x0900..0x097F, 0xA8E0..0xA8FF, 0x30A0..0x30FF)
private val CJK_RANGES: List<IntRange> = listOf(0x31C0..0x31EF, 0x3300..0x33FF, 0x4E00..0x9FFF, 0xF900..0xFAFF, 0xFE30..0xFE4F, 0x20000..0x2EBEF, 0x2F800..0x2FA1F)
private val CJK_JAPANESE_RANGES: List<IntRange> = listOf(0x3040..0x309F, 0x30A0..0x30FF, 0x3190..0x319F)
private val ARABIC_RANGES: List<IntRange> = listOf(0x0600..0x06FF, 0x0750..0x077F, 0x0870..0x089F, 0x08A0..0x08FF)
private val allRanges = mapOf(
SupportedScript.LATIN to LATIN_RANGES,
SupportedScript.CYRILLIC to CYRILLIC_RANGES,
SupportedScript.DEVANAGARI to DEVANAGARI_RANGES,
SupportedScript.UNKNOWN_CJK to CJK_RANGES,
SupportedScript.JAPANESE to CJK_JAPANESE_RANGES,
SupportedScript.ARABIC to ARABIC_RANGES
)
fun guessScript(text: CharSequence): SupportedScript {
val scriptCounts: MutableMap<SupportedScript, Int> = SupportedScript.values().associate { it to 0 }.toMutableMap()
val input = text.toString()
for (i in 0 until input.codePointCount(0, input.length)) {
val codePoint = input.codePointAt(i)
for ((script, ranges) in allRanges) {
if (ranges.contains(codePoint)) {
scriptCounts[script] = scriptCounts[script]!! + 1
}
}
}
val most: SupportedScript = scriptCounts.maxByOrNull { it.value }?.key ?: SupportedScript.UNKNOWN
return if (most == SupportedScript.UNKNOWN_CJK && scriptCounts[SupportedScript.JAPANESE]!! > 0) {
SupportedScript.JAPANESE
} else {
most
}
}
private fun List<IntRange>.contains(x: Int): Boolean = any { x in it }
}

View File

@@ -0,0 +1,39 @@
package org.thoughtcrime.securesms.fonts
import android.graphics.Typeface
import android.os.Build
/**
* API independent, best-effort way of creating [Typeface]s of various family/weight.
*/
object TypefaceHelper {
fun typefaceFor(family: Family, weightName: String, weight: Weight): Typeface {
return when {
Build.VERSION.SDK_INT >= 28 -> Typeface.create(
Typeface.create(family.familyName, Typeface.NORMAL),
weight.value,
false
)
Build.VERSION.SDK_INT >= 21 -> Typeface.create("${family.familyName}-$weightName", Typeface.NORMAL)
else -> Typeface.create(family.familyName, if (weight.value > Weight.MEDIUM.value) Typeface.BOLD else Typeface.NORMAL)
}
}
enum class Family(val familyName: String) {
SANS_SERIF("sans-serif"),
SERIF("serif")
}
enum class Weight(val value: Int) {
THIN(100),
EXTRA_LIGHT(200),
DEMI_LIGHT(200),
LIGHT(300),
NORMAL(400),
MEDIUM(500),
SEMI_BOLD(600),
BOLD(700),
EXTRA_BOLD(800),
BLACK(900),
}
}