Unverified Commit 63dbbb7e authored by Sebastiano Poggi's avatar Sebastiano Poggi Committed by GitHub

Remove AwtFontManager and related code (#835)

Since https://github.com/JetBrains/compose-multiplatform-core/pull/918,
we don't need this code anymore in Compose, which was its only user.

The still-relevant code was moved to Compose's desktop target.
parent f8968123
package org.jetbrains.skiko package org.jetbrains.skiko
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.skia.* import org.jetbrains.skia.*
import org.jetbrains.skia.impl.BufferUtil import org.jetbrains.skia.impl.BufferUtil
import org.jetbrains.skiko.awt.font.AwtFontUtils.fontFamilyName
import org.jetbrains.skiko.awt.font.AwtFontManager
import java.awt.Transparency import java.awt.Transparency
import java.awt.color.ColorSpace import java.awt.color.ColorSpace
import java.awt.event.* import java.awt.event.*
import java.awt.event.KeyEvent.* import java.awt.event.KeyEvent.*
import java.awt.font.TextAttribute
import java.awt.image.BufferedImage import java.awt.image.BufferedImage
import java.awt.image.ComponentColorModel import java.awt.image.ComponentColorModel
import java.awt.image.DataBuffer import java.awt.image.DataBuffer
...@@ -253,97 +248,3 @@ private fun toSkikoKey(event: KeyEvent): Int { ...@@ -253,97 +248,3 @@ private fun toSkikoKey(event: KeyEvent): Int {
} }
return key return key
} }
/**
* Try to obtain the equivalent [Typeface] for this instance of [Font].
* This currently only works if running on the JetBrains Runtime; it
* will return `null` on all other JVM implementations, due to the lack
* of APIs required to make this work.
*
* @return The corresponding [Typeface] if it exists, or `null` if no
* match is found or the current JVM is not supported.
*
* @see AwtFontManager.isAbleToResolveFamilyNames
*/
@DependsOnJBR
suspend fun java.awt.Font.toSkikoTypefaceOrNull(fontManager: AwtFontManager) = withContext(Dispatchers.Default) {
val fontStyle = FontStyle(
weight = toSkikoWeight(weight),
width = toSkikoWidth(width),
slant = toSkikoSlant(posture)
)
val familyName = fontFamilyName ?: return@withContext null
fontManager.getTypefaceOrNull(familyName, fontStyle)
}
/**
* Makes a best-effort conversion from the AWT [Font] [TextAttribute.WEIGHT] values
* to the [FontWeight] values that Skia uses. Those match CSS font-weight values.
*
* Note that AWT calls their constants in a different way from what Skia does; don't
* expect they will be matching the corresponding constants, as the intent is to map
* to the relative weight scale.
*/
internal fun toSkikoWeight(weight: Float) =
when {
weight <= .01f -> FontWeight.INVISIBLE // Imprecise match
weight <= TextAttribute.WEIGHT_EXTRA_LIGHT -> FontWeight.THIN // Imprecise match
weight <= TextAttribute.WEIGHT_LIGHT -> FontWeight.EXTRA_LIGHT // Imprecise match
weight <= TextAttribute.WEIGHT_DEMILIGHT -> FontWeight.LIGHT // Imprecise match
weight <= TextAttribute.WEIGHT_REGULAR -> FontWeight.NORMAL
weight <= TextAttribute.WEIGHT_MEDIUM -> FontWeight.MEDIUM
weight <= TextAttribute.WEIGHT_DEMIBOLD -> FontWeight.SEMI_BOLD
weight <= TextAttribute.WEIGHT_BOLD -> FontWeight.BOLD
weight <= TextAttribute.WEIGHT_HEAVY -> FontWeight.EXTRA_BOLD // Imprecise match
weight <= TextAttribute.WEIGHT_EXTRABOLD -> FontWeight.BLACK // Imprecise match
else -> FontWeight.EXTRA_BLACK // Imprecise match
}
/**
* Makes a best-effort conversion from the AWT [Font] [TextAttribute.WIDTH] values
* to the [FontWidth] values that Skia uses.
*
* AWT's understanding of widths is pretty limited, compared to Skia's, so the
* conversion is necessarily lossy here. Values below [TextAttribute.WIDTH_CONDENSED]
* are all translated to [FontWidth.CONDENSED], and values above [TextAttribute.WIDTH_EXTENDED]
* are all translated to [FontWidth.EXPANDED]. This means that we cannot correctly assign
* widths of [FontWidth.ULTRA_CONDENSED], [FontWidth.EXTRA_CONDENSED], [FontWidth.EXTRA_EXPANDED],
* and [FontWidth.ULTRA_EXPANDED].
*/
internal fun toSkikoWidth(width: Float) =
when {
width <= TextAttribute.WIDTH_CONDENSED -> FontWidth.CONDENSED
width <= TextAttribute.WIDTH_SEMI_CONDENSED -> FontWidth.SEMI_CONDENSED
width <= TextAttribute.WIDTH_REGULAR -> FontWidth.NORMAL
width <= TextAttribute.WIDTH_SEMI_EXTENDED -> FontWidth.SEMI_EXPANDED
else -> FontWidth.EXPANDED
}
/**
* Makes a best-effort conversion from the AWT [Font] [TextAttribute.POSTURE] values
* to the [FontSlant] values that Skia uses.
*
* AWT's understanding of slant is pretty limited, compared to Skia's, so the
* conversion is necessarily lossy here. Since AWT doesn't know the difference
* between _italic_ (a font designed as slanted) and _oblique_ (a regular font,
* artificially slanted to look italic), we map all values bigger than
* [TextAttribute.POSTURE_REGULAR] as italic.
*
* This is even more confusing, since AWT calls [TextAttribute.POSTURE_OBLIQUE]
* the value that corresponds to [java.awt.Font.ITALIC].
*/
internal fun toSkikoSlant(posture: Float) =
when {
posture <= TextAttribute.POSTURE_REGULAR -> FontSlant.UPRIGHT
else -> FontSlant.ITALIC
}
internal val java.awt.Font.weight
get() = (attributes[TextAttribute.WEIGHT] as? Float) ?: TextAttribute.WEIGHT_REGULAR
internal val java.awt.Font.width
get() = (attributes[TextAttribute.WIDTH] as? Float) ?: TextAttribute.WIDTH_REGULAR
internal val java.awt.Font.posture
get() = (attributes[TextAttribute.POSTURE] as? Float) ?: TextAttribute.POSTURE_REGULAR
package org.jetbrains.skiko
import java.util.concurrent.atomic.AtomicBoolean
internal object InternalSunApiChecker {
private var hasCheckedAccess = AtomicBoolean(false)
private var isSunFontAccessible = AtomicBoolean(false)
fun isSunFontApiAccessible(): Boolean {
if (hasCheckedAccess.get()) return isSunFontAccessible.get()
if (!isRunningOnJetBrainsRuntime()) {
logJbrWarning()
}
val canAccess = canAccessSunFontApi()
if (!canAccess) {
logInstructions()
}
isSunFontAccessible.set(canAccess)
hasCheckedAccess.set(true)
return canAccess
}
private fun canAccessSunFontApi(): Boolean {
try {
val unnamedModule = ClassLoader.getSystemClassLoader().unnamedModule
val desktopModule = ModuleLayer.boot().modules().single { it.name == "java.desktop" }
// Check the necessary open directives are available, so we can access standard sun.font APIs
if (!unnamedModule.canRead(desktopModule)) return false
if (!desktopModule.isOpen("sun.font", unnamedModule)) return false
// Try to obtain an instance of sun.font.FontManager (will fail if the open directive is missing)
val fontManagerClass = Class.forName("sun.font.FontManagerFactory")
fontManagerClass.getDeclaredMethod("getInstance").invoke(null)
Logger.debug { "Sun Font APIs are accessible, advanced font features are available" }
return true
} catch (ignored: Throwable) {
return false
}
}
private fun logJbrWarning() {
Logger.warn {
"""
|The Java Runtime in use may not support all advanced Skiko features.
|It is recommended that you run this app on the JetBrains Runtime for
|best results.
""".trimMargin()
}
}
private fun logInstructions() {
Logger.error {
"""
|
|!!! WARNING !!!
|For Skiko to run optimally, you should add the following argument
|to the command for this program:
|
|--add-opens java.desktop/sun.font=ALL-UNNAMED
|
|This is required to be able to properly match the Skia fonts with
|the AWT fonts and access private JDK APIs used for some advanced
|features.
""".trimMargin()
}
}
}
package org.jetbrains.skiko
internal fun isRunningOnJetBrainsRuntime() =
System.getProperty("java.vendor")
.equals("JetBrains s.r.o.", ignoreCase = true)
package org.jetbrains.skiko
import org.jetbrains.annotations.NonNls
import java.lang.reflect.Field
import java.lang.reflect.InaccessibleObjectException
import java.lang.reflect.Method
import java.util.function.Predicate
internal object ReflectionUtil {
fun getDeclaredMethodOrNull(
clazz: Class<*>,
name: String,
vararg parameters: Class<*>
): Method? =
try {
clazz.getDeclaredMethod(name, *parameters)
.apply { isAccessible = true }
} catch (e: NoSuchMethodException) {
null
} catch (e: InaccessibleObjectException) {
null
}
fun <T> getFieldValueOrNull(
objectClass: Class<*>,
`object`: Any?,
fieldType: Class<T>?,
fieldName: String
): T? =
try {
val field: Field = getAssignableField(objectClass, fieldType, fieldName)
getFieldValue<T>(field, `object`)
} catch (e: NoSuchFieldException) {
null
}
fun getAssignableField(
clazz: Class<*>,
fieldType: Class<*>?,
@NonNls fieldName: String
) = findAssignableField(clazz, fieldType, fieldName)
?: throw NoSuchFieldException("Class: $clazz fieldName: $fieldName fieldType: $fieldType")
fun findAssignableField(
clazz: Class<*>,
fieldType: Class<*>?,
@NonNls fieldName: String
): Field? {
val result = findFieldInHierarchy(
clazz
) { field: Field ->
fieldName == field.name && (fieldType == null || fieldType.isAssignableFrom(
field.type
))
}
return result
}
fun findFieldInHierarchy(
rootClass: Class<*>,
checker: Predicate<in Field>
): Field? {
var aClass: Class<*>? = rootClass
try {
while (aClass != null) {
for (field in aClass.declaredFields) {
if (checker.test(field)) {
field.isAccessible = true
return field
}
}
aClass = aClass.superclass
}
} catch (e: InaccessibleObjectException) {
return null
}
return processInterfaces(rootClass.interfaces, HashSet(), checker)
}
fun processInterfaces(
interfaces: Array<Class<*>>,
visited: MutableSet<in Class<*>>,
checker: Predicate<in Field>
): Field? {
for (anInterface in interfaces) {
if (!visited.add(anInterface)) {
continue
}
for (field in anInterface.declaredFields) {
if (checker.test(field)) {
field.isAccessible = true
return field
}
}
val field = processInterfaces(anInterface.interfaces, visited, checker)
if (field != null) {
return field
}
}
return null
}
fun <T> getFieldValue(field: Field, instance: Any?): T? =
try {
@Suppress("UNCHECKED_CAST")
field[instance] as T
} catch (e: IllegalAccessException) {
null
}
}
package org.jetbrains.skiko.awt.font
import org.jetbrains.skia.FontStyle
import org.jetbrains.skia.Typeface
import org.jetbrains.skiko.DependsOnJBR
import java.io.File
/**
* Manages available fonts, both at the system level and custom ones.
*
* The main entry point is [getTypefaceOrNull]:
*
* ```kotlin
* val fontManager = AwtFontManager()
* val myTypeface = fontManager.getTypefaceOrNull("My Font")
* ```
*
* While system fonts represent global state and are shared across all
* instances, custom fonts are only available on the instance(s) on
* which they have been registered.
*
* In order to free up memory, you should remove custom fonts you don't
* need anymore.
*/
@DependsOnJBR
class AwtFontManager internal constructor(
private val systemFontProvider: FontProvider,
private val embeddedFontProvider: FontProvider,
private val customTypefaceCache: TypefaceCache
) {
constructor() : this(FontProvider.Skia, FontProvider.JvmEmbedded, TypefaceCache.inMemory())
/**
* Invalidate the system font cache, causing the list of font families available
* at the system level to be refreshed.
*
* The system fonts cache is **global** and shared across all [AwtFontManager]
* instances. Calling this will impact all [AwtFontManager]'s behaviour.
*
* Various functions of this class will not be able to proceed until the
* caching is completed.
*/
fun invalidateSystemFontCache() {
systemFontProvider.invalidate()
}
/**
* Get a [Typeface] by family name and style, if it exists.
*
* Custom fonts take precedence over system fonts.
*/
suspend fun getTypefaceOrNull(familyName: String, fontStyle: FontStyle): Typeface? {
val customTypeface = customTypefaceCache.getTypefaceOrNull(familyName, fontStyle)
if (customTypeface != null) return customTypeface
return embeddedFontProvider.getTypefaceOrNull(familyName, fontStyle)
?: systemFontProvider.getTypefaceOrNull(familyName, fontStyle)
}
/**
* List all known font family names, including both system fonts, and
* custom fonts added to this instance.
*/
suspend fun familyNames(): Set<String> =
sortedSetOf<String>()
.also {
it.addAll(systemFamilyNames())
it.addAll(embeddedFamilyNames())
it.addAll(customFamilyNames())
}
/**
* Lists all known system font family names.
*
* @see getTypefaceOrNull
*/
suspend fun systemFamilyNames(): Set<String> = systemFontProvider.familyNames().toSortedSet()
/**
* Lists all known embedded font family names.
*
* @see getTypefaceOrNull
*/
suspend fun embeddedFamilyNames(): Set<String> = embeddedFontProvider.familyNames().toSortedSet()
/**
* List the font families for all registered custom fonts.
*
* @see getTypefaceOrNull
*/
fun customFamilyNames(): Set<String> = customTypefaceCache.familyNames().toSortedSet()
/**
* Add a classpath resource as a custom font.
*
* [AwtFontManager]s don't have knowledge of custom fonts added to other
* instances.
*
* Don't forget to remove custom fonts when you don't need them anymore.
*
* @see removeCustomFontFamily
*/
suspend fun addCustomFontResource(
resource: String,
loader: ClassLoader = Thread.currentThread().contextClassLoader
) = customTypefaceCache.addResource(resource, loader)
/**
* Add a [File] as a custom font.
*
* [AwtFontManager]s don't have knowledge of custom fonts added to other
* instances.
*
* Don't forget to remove custom fonts when you don't need them anymore.
*
* @see removeCustomFontFamily
*/
fun addCustomFontFile(file: File) = customTypefaceCache.addFile(file)
/**
* Add a [Typeface] as a custom font.
*
* [AwtFontManager]s don't have knowledge of custom fonts added to other
* instances.
*
* Don't forget to remove custom fonts when you don't need them anymore.
*
* @see removeCustomFontFamily
*/
fun addCustomFontTypeface(typeface: Typeface) =
customTypefaceCache.addTypeface(typeface)
/**
* Remove a custom font by family name, if it exists.
*
* This will not impact other instances of [AwtFontManager]; if a custom
* font is registered on multiple instances, it needs to be removed from
* all of them.
*
* @see addCustomFontFile
* @see addCustomFontResource
* @see addCustomFontTypeface
*/
fun removeCustomFontFamily(familyName: String) =
customTypefaceCache.removeFontFamily(familyName)
/**
* Remove all custom fonts added to this instance.
*
* This will not impact other instances of [AwtFontManager]; if a custom
* font is registered on multiple instances, it needs to be removed from
* all of them.
*/
fun clearCustomFonts() = customTypefaceCache.clear()
/**
* Indicate whether the current JVM is able to resolve font family names
* accurately or not.
*
* This value will be `true` if using the JetBrains Runtime. It will be
* `false` otherwise, indicating that this class is not able to return
* valid values.
*
* If the return value is `false`, you should assume we are unable to
* obtain the actual font family names, and font resolving might fail.
* In particular, this is important when we need the actual family name
* to match an AWT [Font] to a [Typeface].
*
* On other JVMs running on Windows and Linux, the AWT implementation is
* not enumerating font families correctly. E.g., you may have these entries
* for JetBrains Mono, instead of a single entry: _JetBrains Mono, JetBrains
* Mono Bold, JetBrains Mono ExtraBold, JetBrains Mono ExtraLight, JetBrains
* Mono Light, JetBrains Mono Medium, JetBrains Mono SemiBold, JetBrains
* Mono Thin_.
*
* On the JetBrains Runtime, there are additional APIs that provide the
* necessary information needed to list the actual font families as single
* entries, as one would expect.
*
* @see toSkikoTypefaceOrNull
*/
val isAbleToResolveFamilyNames
get() = AwtFontUtils.isAbleToResolveFontProperties
}
@file:Suppress("PrivatePropertyName") // Reflection-based properties have more meaningful names
package org.jetbrains.skiko.awt.font
import org.jetbrains.skiko.InternalSunApiChecker
import org.jetbrains.skiko.ReflectionUtil.findFieldInHierarchy
import org.jetbrains.skiko.ReflectionUtil.getDeclaredMethodOrNull
import org.jetbrains.skiko.ReflectionUtil.getFieldValueOrNull
import org.jetbrains.skiko.hostOs
import java.awt.Font
import java.awt.GraphicsEnvironment
import java.lang.reflect.Method
import java.util.*
import java.util.concurrent.ConcurrentHashMap
internal object AwtFontUtils {
init {
InternalSunApiChecker.isSunFontApiAccessible()
}
private val FontManagerFactoryClass = Class.forName("sun.font.FontManagerFactory")
private val FontManagerClass = Class.forName("sun.font.FontManager")
private val Font2DClass = Class.forName("sun.font.Font2D")
private val FileFontClass = Class.forName("sun.font.FileFont")
private val CompositeFontClass = Class.forName("sun.font.CompositeFont")
private val CFontClass = if (hostOs.isMacOS) Class.forName("sun.font.CFont") else null
// FontManagerFactory methods
private val FontManagerFactory_getInstanceMethod =
getDeclaredMethodOrNull(FontManagerFactoryClass, "getInstance")
// FontManager methods and fields
private val FontManager_findFont2DMethod = getDeclaredMethodOrNull(
FontManagerClass,
"findFont2D",
String::class.java, // Font name
Int::class.javaPrimitiveType!!, // Font style (e.g., Font.BOLD)
Int::class.javaPrimitiveType!! // Fallback (one of the FontManager.*_FALLBACK values)
)
// Font2D methods and fields
private val Font2D_getTypographicFamilyNameMethod =
getFont2DMethodOrNull("getTypographicFamilyName")
private val Font2D_getFamilyNameMethod =
getFont2DMethodOrNull("getFamilyName", Locale::class.java)
private val Font2D_handleField =
findFieldInHierarchy(Font2DClass) { it.name == "handle" }
// Font2DHandle fields
private val Font2DHandle_font2DField =
findFieldInHierarchy(Class.forName("sun.font.Font2DHandle")) {
it.name == "font2D"
}
// FileFont methods
private val FileFont_getPublicFileNameMethod =
getDeclaredMethodOrNull(clazz = FileFontClass, name = "getPublicFileNameMethod")
// CompositeFont methods
private val CompositeFont_getSlotFontMethod =
getDeclaredMethodOrNull(clazz = CompositeFontClass, name = "getSlotFont", Int::class.javaPrimitiveType!!)
// Copy of FontManager.LOGICAL_FALLBACK
private const val LOGICAL_FALLBACK = 2
private val font2DHandlesCache = ConcurrentHashMap<Font, Any>()
/**
* Indicate whether the current JVM is able to resolve font properties
* accurately or not.
*
* This value will be `true` if using the JetBrains Runtime. It will be
* `false` otherwise, indicating that this class is not able to return
* valid values.
*
* If the return value is `false`, you should assume all APIs in this class
* will return `null` as we can't obtain the necessary information.
*
* On other JVMs running on Windows and Linux, the AWT implementation is
* not enumerating font families correctly. E.g., you may have these entries
* for JetBrains Mono, instead of a single entry: _JetBrains Mono, JetBrains
* Mono Bold, JetBrains Mono ExtraBold, JetBrains Mono ExtraLight, JetBrains
* Mono Light, JetBrains Mono Medium, JetBrains Mono SemiBold, JetBrains
* Mono Thin_.
*
* On the JetBrains Runtime, there are additional APIs that provide the
* necessary information needed to list the actual font families as single
* entries, as one would expect.
*/
val isAbleToResolveFontProperties: Boolean
get() = Font2D_getTypographicFamilyNameMethod != null
/**
* Try to resolve a font family name, that could be a logical font
* face (e.g., [Font.DIALOG]), to the actual physical font family
* it is an alias for.
*
* @param fontName The name of the font face
* @param style The desired font name; must be one of the styles
* supported by the AWT [Font]. It is [Font.PLAIN] by default.
*
* @return The resolved physical font name, or `null` if it can't
* be resolved (either it's unknown, or [isAbleToResolveFontProperties]
* is false)
*
* @see isAbleToResolveFontProperties
*/
internal fun resolvePhysicalFontNameOrNull(
fontName: String,
style: Int = Font.PLAIN
): String? {
if (!isAbleToResolveFontProperties) return null
val fontManager = awtFontManager()
val font2D =
checkNotNull(FontManager_findFont2DMethod) { "FontManager#findFont2DMethod() is not available" }
.invoke(fontManager, fontName, style, LOGICAL_FALLBACK)
return when {
CompositeFontClass.isInstance(font2D) -> {
// For Windows and Linux
val physicalFontObject = CompositeFont_getSlotFontMethod?.invoke(font2D, 0)
Font2D_getFamilyNameMethod?.invoke(physicalFontObject, Locale.getDefault()) as String?
}
CFontClass?.isInstance(font2D) == true -> {
// For macOS
val nativeFontName =
getFieldValueOrNull(CFontClass, font2D, String::class.java, "nativeFontName")
Font(nativeFontName, Font.PLAIN, 10).fontFamilyName
}
else -> error("Unsupported Font2D subclass: ${font2D.javaClass.name}")
}
}
/**
* The list of font family names available through AWT. This list should
* be used instead of the one provided by [GraphicsEnvironment.getAvailableFontFamilyNames]
* since it will provide consistent results across platforms.
*
* Will return `null` if [isAbleToResolveFontProperties] is `false`.
*
* See [fontFamilyName] for further details.
*
* @see fontFamilyName
* @see isAbleToResolveFontProperties
*/
fun fontFamilyNamesOrNull(
graphicsEnvironment: GraphicsEnvironment = GraphicsEnvironment.getLocalGraphicsEnvironment()
): SortedSet<String>? {
if (!isAbleToResolveFontProperties) return null
return graphicsEnvironment.allFonts.map { font -> font.fontFamilyName!! }
.toSortedSet()
}
/**
* The preferred font family name, which should be used instead of the
* [Font.getFamily] and `Font2D.familyName`. It will be `null` if
* [isAbleToResolveFontProperties] is `false`.
*
* On Windows, and potentially in other cases, the family name as reported
* by AWT can contain the style and weight of the [Font] in addition to the
* _actual_ font family name. This can cause issues when trying to match up
* AWT fonts with Skia typefaces, and if used for listing font families,
* will result in multiple entries being present in the list.
*
* You can use [fontFamilyNamesOrNull] to enumerate the actual family names
* available via AWT.
*
* @see fontFamilyNamesOrNull
* @see isAbleToResolveFontProperties
*/
val Font.fontFamilyName: String?
get() {
if (!isAbleToResolveFontProperties) return null
val font2D = obtainFont2D()
return checkNotNull(Font2D_getTypographicFamilyNameMethod) { "Font2D#getTypographicFamilyName() is not available" }
.invoke(font2D) as String
}
/**
* The file that a [Font] is loaded from; it will be `null`
* if [isAbleToResolveFontProperties] is `false`, or if the
* font is not backed by a [sun.font.FileFont].
*
* @see fontFamilyNamesOrNull
* @see isAbleToResolveFontProperties
*/
val Font.fontFileName: String?
get() {
if (!isAbleToResolveFontProperties) return null
val font2D = obtainFont2D()
if (!FileFontClass.isInstance(font2D)) return null
return checkNotNull(FileFont_getPublicFileNameMethod) { "FileFont#getPublicFileName() is not available" }
.invoke(font2D) as String
}
private fun Font.obtainFont2D(): Any {
// Don't store the Font2D instance directly, in case the handle may be changed
// later on. Logic adopted from java.awt.Font#getFont2D()
val handle = font2DHandlesCache.getOrPut(this) {
val fontManager = awtFontManager()
val font2D =
checkNotNull(FontManager_findFont2DMethod) { "FontManager#findFont2DMethod() is not available" }
.invoke(fontManager, name, style, LOGICAL_FALLBACK)
checkNotNull(Font2D_handleField) { "Font2D#handle is not available" }
.get(font2D)
}
return checkNotNull(Font2DHandle_font2DField) { "Font2DHandle#font2D is not available" }
.get(handle)
}
private fun awtFontManager() =
checkNotNull(FontManagerFactory_getInstanceMethod) { "FontManagerFactory#getInstanceMethod() not available" }
.invoke(null)
private fun getFont2DMethodOrNull(methodName: String, vararg parameters: Class<*>): Method? =
getDeclaredMethodOrNull(Font2DClass, methodName, *parameters)
}
package org.jetbrains.skiko.awt.font
import org.jetbrains.skia.*
/**
* Holds info about a font family. Intended to be used as a cache entry,
* to which typefaces are lazily loaded as new [Typeface]s are loaded.
*
* @param familyName The primary font family name. Must not be blank.
* @param typefacesByStyle A mutable map of typefaces, organised by their style.
*
* @see AwtFontManager
*/
class FontFamily(
val familyName: String,
val source: FontFamilySource,
private val typefacesByStyle: MutableMap<FontStyle, Typeface> = mutableMapOf()
) : Map<FontStyle, Typeface> by typefacesByStyle {
init {
require(familyName.isNotBlank()) { "The font family name must not be blank" }
}
/**
* The available styles loaded for the font family.
*/
val availableStyles
get() = typefacesByStyle.keys
/**
* The available typefaces loaded for the font family.
*/
val availableTypefaces
get() = typefacesByStyle.values.toSet()
operator fun plus(typeface: Typeface) =
FontFamily(familyName, source, typefacesByStyle.toMutableMap())
.apply { addTypeface(typeface) }
operator fun plusAssign(typeface: Typeface) = addTypeface(typeface)
/**
* Adds the specified [typeface] to this [FontFamily].
*
* If the font family already contains a [Typeface] with the same [FontStyle],
* this **will replace** the previous value.
*
* @throws IllegalArgumentException if the [typeface] family name doesn't match
* this instance's [familyName].
*/
fun addTypeface(typeface: Typeface) {
ensureTypefaceIsCompatible(typeface)
typefacesByStyle += (typeface.fontStyle to typeface)
}
operator fun minusAssign(typeface: Typeface) = removeTypeface(typeface)
operator fun minus(typeface: Typeface) =
FontFamily(familyName, source, typefacesByStyle.toMutableMap())
.apply { removeTypeface(typeface) }
operator fun minusAssign(style: FontStyle) = removeTypefaceByStyle(style)
operator fun minus(style: FontStyle) =
FontFamily(familyName, source, typefacesByStyle.toMutableMap())
.apply { removeTypefaceByStyle(style) }
/**
* Removes a typeface from the family if it exists.
*
* @throws IllegalArgumentException if the [typeface]'s font family is different from
* this instance's [familyName].
*/
fun removeTypeface(typeface: Typeface) {
if (typefacesByStyle.isEmpty()) return
val style = typeface.fontStyle
ensureTypefaceIsCompatible(typeface)
typefacesByStyle -= style
}
private fun ensureTypefaceIsCompatible(typeface: Typeface) {
val candidateName = typeface.familyName
require(familyName.equals(candidateName, ignoreCase = true)) {
"The provided typeface $typeface is not compatible with this font family, '$familyName'"
}
}
/**
* Removes the typeface from the family with the given style if it exists.
*/
fun removeTypefaceByStyle(style: FontStyle) {
typefacesByStyle -= style
}
enum class FontFamilySource {
System,
JvmEmbedded,
Custom
}
companion object {
/**
* Creates a new [FontFamily] instance, comprised of the provided [typefaces].
*
* All provided [Typeface]s **must** have the same [Typeface.familyName] and
* [Typeface.familyNames], or this function will throw an exception.
* No two elements in [typefaces] are allowed to represent the same [FontStyle],
* because a family can only contain one typeface for each style.
*/
fun fromTypefaces(
familyName: String,
source: FontFamilySource,
vararg typefaces: Typeface,
): FontFamily {
if (typefaces.isEmpty()) return FontFamily(familyName, source)
val map = HashMap<FontStyle, Typeface>(typefaces.size)
for (typeface in typefaces) {
require(typeface.familyName.equals(familyName, ignoreCase = true)) {
"Not all provided typefaces are compatible with the family name " +
"(expected: '$familyName', found: $typeface)"
}
require(typeface.fontStyle !in map.keys) {
"Trying to add a typeface for style ${typeface.fontStyle}, but it already exists."
}
map[typeface.fontStyle] = typeface
}
return FontFamily(familyName, source, map)
}
/**
* Find the closest typeface by style
*/
fun closestStyle(iterable: Iterable<FontStyle>, style: FontStyle): FontStyle? {
val keys = iterable.toMutableList()
keys.sortBy { it.weight }
val closeByWight = keys.find { it.weight >= style.weight } ?: keys.lastOrNull()
keys.retainAll { it.weight == closeByWight?.weight }
keys.sortBy { it.width }
val closeByWidth = keys.find { it.width >= style.width } ?: keys.lastOrNull()
keys.retainAll { it.width == closeByWidth?.width }
keys.sortBy { it.slant }
val closeBySlant = keys.find { it.slant >= style.slant } ?: keys.lastOrNull()
keys.retainAll { it.slant == closeBySlant?.slant }
return keys.firstOrNull()
}
}
}
package org.jetbrains.skiko.awt.font
/**
* Used to transparently provide non-case-sensitive font family name matching,
* as Skia will match font family names regardless of casing.
*/
internal class FontFamilyKey(val familyName: String) : Comparable<String> {
val identifier = familyName.lowercase()
override fun compareTo(other: String) =
identifier.compareTo(other.lowercase())
@Suppress("RedundantIf") // Auto-generated
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as FontFamilyKey
if (identifier != other.identifier) return false
return true
}
override fun hashCode() = identifier.hashCode()
override fun toString(): String = "FontFamilyKey(familyName='$familyName')"
object Apple {
val SystemFont = FontFamilyKey("System Font")
val AppleSystemUiFont = FontFamilyKey(".AppleSystemUIFont")
val hiddenSystemFontNames = setOf(SystemFont, AppleSystemUiFont)
}
@Suppress("MemberVisibilityCanBePrivate")
object Awt {
val Serif = FontFamilyKey("Serif")
val SansSerif = FontFamilyKey("SansSerif")
val Monospaced = FontFamilyKey("Monospaced")
val Dialog = FontFamilyKey("Dialog")
val DialogInput = FontFamilyKey("DialogInput")
val awtLogicalFonts = setOf(Serif, SansSerif, Monospaced, Dialog, DialogInput)
}
}
@file:Suppress("JAVA_MODULE_DOES_NOT_EXPORT_PACKAGE")
package org.jetbrains.skiko.awt.font
import org.jetbrains.skia.FontStyle
import org.jetbrains.skia.Typeface
import org.jetbrains.skiko.awt.font.FontProvider.Companion.Skia
import java.io.IOException
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.absolute
/**
* A cache of font family names available in the system.
*
* You can get the default, global implementation from [Skia].
*/
internal interface FontProvider {
/**
* Invalidate the system font cache, causing the list of font families available
* at the system level to be refreshed.
*/
fun invalidate()
/**
* List all known system font family names.
*
* You can call [invalidate] if you need to refresh this list.
* This function will suspend until the caching is complete.
*
* @see invalidate
*/
suspend fun familyNames(): Set<String>
/**
* Check if the given [familyName] exists in the system.
*
* This function will suspend until the caching is complete.
*
* @see invalidate
* @see familyNames
*/
suspend fun contains(familyName: String): Boolean
/**
* Load a [Typeface] from the system, given a [familyName] and a [fontStyle].
*/
suspend fun getTypefaceOrNull(familyName: String, fontStyle: FontStyle): Typeface?
/**
* Load a [FontFamily] from the system, given a [familyName].
*/
suspend fun getFontFamilyOrNull(familyName: String): FontFamily?
companion object {
/**
* The default, global implementation of the interface.
* It uses Skia APIs to enumerate available font families.
*/
val Skia: FontProvider
get() = SkiaFontProvider
/**
* The default, global implementation of the interface.
* It uses Skia APIs to enumerate available font families.
*/
val JvmEmbedded: FontProvider
get() = JvmEmbeddedFontProvider
}
}
internal fun Path.isParentOf(other: Path): Boolean {
val parent = absolute().normalize()
val child = other.absolute().normalize()
if (parent.nameCount >= other.nameCount) return false
val childParent = child.parent ?: return false
return parent.isSameFileAs(childParent) || parent.isParentOf(childParent)
}
internal fun Path.isSameFileAs(other: Path): Boolean =
try {
// Try to use Files.isSameFile() as it also follows symlinks
Files.isSameFile(this, other)
} catch (e: IOException) {
// Fall back on simple path equivalence
absolute().normalize() == other.absolute().normalize()
}
package org.jetbrains.skiko.awt.font
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.jetbrains.skia.FontStyle
import org.jetbrains.skia.Typeface
import org.jetbrains.skia.makeFromFile
import org.jetbrains.skiko.ReflectionUtil
import org.jetbrains.skiko.RendezvousBroadcastChannel
import org.jetbrains.skiko.awt.font.AwtFontUtils.fontFamilyName
import org.jetbrains.skiko.awt.font.AwtFontUtils.fontFileName
import org.jetbrains.skiko.isRunningOnJetBrainsRuntime
import java.awt.GraphicsEnvironment
import java.util.concurrent.ConcurrentHashMap
import kotlin.io.path.Path
import kotlin.io.path.absolutePathString
internal object JvmEmbeddedFontProvider : FontProvider {
private val embeddedFamilyMap: MutableMap<String, String> = ConcurrentHashMap()
private val embeddedFamilies: MutableMap<FontFamilyKey, FontFamily> = ConcurrentHashMap()
private val embeddedFontFiles: MutableMap<Typeface, String> = ConcurrentHashMap()
private val _familyNames: MutableSet<String> = ConcurrentHashMap.newKeySet()
@Volatile
private var allFontsCachedImpl = false
private val waitChannel = RendezvousBroadcastChannel<Unit>()
private var cacheJob: Job? = null
private val FontManagerFactoryClass = Class.forName("sun.font.FontManagerFactory")
private val SunFontManagerClass = Class.forName("sun.font.SunFontManager")
// FontManagerFactory methods
private val FontManagerFactory_getInstanceMethod =
ReflectionUtil.getDeclaredMethodOrNull(FontManagerFactoryClass, "getInstance")
// SunFontManagerFactory fields
private val SunFontManagerFactory_jreBundledFontFiles =
ReflectionUtil.findAssignableField(SunFontManagerClass, HashSet::class.java, "jreBundledFontFiles")
private val SunFontManagerFactory_jreFamilyMap =
ReflectionUtil.findAssignableField(SunFontManagerClass, HashMap::class.java, "jreFamilyMap")
private val javaHomePath
get() = Path(System.getProperty("java.home"))
private val jbrEmbeddedFontsPath
get() = javaHomePath.resolve("lib").resolve("fonts")
init {
invalidate()
}
@OptIn(DelicateCoroutinesApi::class)
override fun invalidate() {
cacheJob?.cancel()
allFontsCachedImpl = false
cacheJob = GlobalScope.launch {
cacheEmbeddedFonts()
allFontsCachedImpl = true
waitChannel.sendAll(Unit)
}
}
private fun cacheEmbeddedFonts() {
embeddedFamilies.clear()
embeddedFontFiles.clear()
_familyNames.clear()
if (canUseJetBrainsRuntimeFeatures) {
cacheJetBrainsRuntimeEmbeddedFonts()
cacheJetBrainsRuntimeEmbeddedFamilyMap()
} else {
tryCachingNonJbrEmbeddedFonts()
}
}
private fun cacheJetBrainsRuntimeEmbeddedFonts() {
val field =
checkNotNull(SunFontManagerFactory_jreBundledFontFiles) { "JetBrains Runtime SunFontManager fields not accessible" }
try {
field.isAccessible = true
val fontManager = sunFontManager()
@Suppress("UNCHECKED_CAST")
val embeddedFontFileNames = field.get(fontManager) as HashSet<String>
val embeddedFontPaths = embeddedFontFileNames.map { jbrEmbeddedFontsPath.resolve(it) }
.sortedBy { it.absolutePathString() }
.distinctBy { it.absolutePathString() }
embeddedFontPaths.asSequence()
.map { path ->
val absolutePath = path.absolutePathString()
val typeface = Typeface.makeFromFile(absolutePath)
TypefaceWithPath(typeface, absolutePath)
}
.distinctBy { it.path }
.groupBy { it.typeface.familyName }
.forEach { (familyName, typefacesWithPath) ->
val typefaces = typefacesWithPath
.distinctBy { it.typeface.fontStyle }
.onEach { embeddedFontFiles += it.typeface to it.path }
.map { it.typeface }
val fontFamily = FontFamily.fromTypefaces(
familyName = familyName,
source = FontFamily.FontFamilySource.JvmEmbedded,
typefaces = typefaces.toTypedArray()
)
val key = FontFamilyKey(familyName)
embeddedFamilies += key to fontFamily
_familyNames += familyName
}
} finally {
field.isAccessible = false
}
}
private fun cacheJetBrainsRuntimeEmbeddedFamilyMap() {
val field =
checkNotNull(SunFontManagerFactory_jreFamilyMap) { "JetBrains Runtime SunFontManager fields not accessible" }
try {
field.isAccessible = true
val fontManager = sunFontManager()
@Suppress("UNCHECKED_CAST")
val map = field.get(fontManager) as HashMap<String, String>
for ((rawFamilyName, readableFamilyName) in map) {
embeddedFamilyMap += rawFamilyName to readableFamilyName
}
} finally {
field.isAccessible = false
}
}
private fun tryCachingNonJbrEmbeddedFonts() {
GraphicsEnvironment.getLocalGraphicsEnvironment().allFonts.asSequence()
.map { font -> font.fontFileName to font }
.filter { (fileName, _) ->
// Only take physical fonts that live in the JVM folder
fileName != null && javaHomePath.isParentOf(Path(fileName))
}
.groupBy { (_, font) -> font.fontFamilyName ?: font.family }
.forEach { (familyName, pathAndFonts) ->
val typefacesWithPath =
pathAndFonts.mapNotNull { (path, _) -> path }
.map { path -> TypefaceWithPath(Typeface.makeFromFile(path), path) }
val typefaces = typefacesWithPath
.onEach { embeddedFontFiles += it.typeface to it.path }
.map { it.typeface }
val fontFamily = FontFamily.fromTypefaces(
familyName = familyName,
source = FontFamily.FontFamilySource.JvmEmbedded,
typefaces = typefaces.toTypedArray()
)
embeddedFamilies += FontFamilyKey(familyName) to fontFamily
_familyNames += familyName
}
}
/**
* Suspend execution until the font family names caching has
* been completed.
*/
private suspend fun ensureEmbeddedFontsCached() {
if (!allFontsCachedImpl) {
waitChannel.receive()
}
}
override suspend fun familyNames(): Set<String> {
ensureEmbeddedFontsCached()
return _familyNames
}
suspend fun embeddedFontFilePaths(): Set<String> {
ensureEmbeddedFontsCached()
return embeddedFontFiles.values.toSet()
}
suspend fun embeddedFontFamilyMap(): Map<String, String> {
ensureEmbeddedFontsCached()
return embeddedFamilyMap
}
override suspend fun contains(familyName: String): Boolean {
ensureEmbeddedFontsCached()
return _familyNames.contains(familyName)
}
override suspend fun getTypefaceOrNull(familyName: String, fontStyle: FontStyle): Typeface? {
val fontFamily = getFontFamilyOrNull(familyName) ?: return null
return fontFamily[fontStyle]
?: FontFamily.closestStyle(fontFamily.availableStyles, fontStyle)?.let(fontFamily::get)
}
override suspend fun getFontFamilyOrNull(familyName: String): FontFamily? {
ensureEmbeddedFontsCached()
val key = FontFamilyKey(familyName)
return embeddedFamilies[key]
}
val canUseJetBrainsRuntimeFeatures: Boolean
get() = isRunningOnJetBrainsRuntime() && SunFontManagerFactory_jreBundledFontFiles != null
private fun sunFontManager() =
checkNotNull(FontManagerFactory_getInstanceMethod) { "FontManagerFactory#getInstanceMethod() not available" }
.invoke(null)
.also { check(SunFontManagerClass.isAssignableFrom(it.javaClass)) { "FontManager is not an instance of SunFontManager" } }
internal data class TypefaceWithPath(val typeface: Typeface, val path: String)
}
package org.jetbrains.skiko.awt.font
import kotlinx.coroutines.*
import org.jetbrains.skia.FontMgr
import org.jetbrains.skia.FontStyle
import org.jetbrains.skia.FontStyleSet
import org.jetbrains.skia.Typeface
import org.jetbrains.skiko.OS
import org.jetbrains.skiko.RendezvousBroadcastChannel
import org.jetbrains.skiko.hostOs
import java.util.concurrent.ConcurrentHashMap
internal object SkiaFontProvider : FontProvider {
private val familyNamesCache: MutableSet<FontFamilyKey> = ConcurrentHashMap.newKeySet(FontMgr.default.familiesCount)
private val _familyNames: MutableSet<String> = ConcurrentHashMap.newKeySet(FontMgr.default.familiesCount)
private val awtLogicalFamilyNames: MutableMap<FontFamilyKey, String> =
ConcurrentHashMap(FontFamilyKey.Awt.awtLogicalFonts.size)
@Volatile
private var allFontsCachedImpl = false
private val waitChannel = RendezvousBroadcastChannel<Unit>()
private var cacheJob: Job? = null
init {
invalidate()
}
@OptIn(DelicateCoroutinesApi::class)
override fun invalidate() {
cacheJob?.cancel()
allFontsCachedImpl = false
cacheJob = GlobalScope.launch {
cacheAllSystemFonts()
cacheAwtLogicalFonts()
allFontsCachedImpl = true
waitChannel.sendAll(Unit)
}
}
private suspend fun cacheAllSystemFonts() {
familyNamesCache.clear()
val fontManager = FontMgr.default
val familyNames = mutableSetOf<String>()
(0 until fontManager.familiesCount)
.map { i -> fontManager.getFamilyName(i) }
.forEach { familyName ->
familyNamesCache.add(FontFamilyKey(familyName))
familyNames += familyName
yield()
}
// Since macOS pretends the San Francisco font doesn't exist, we force-load it into
// our cache, and store it as "System font" and as ".AppleSystemUIFont".
// The latter is done for Swing/AWT interop reasons, since AWT loads it as
// ".AppleSystemUIFont" (at least on the JetBrains Runtime).
// The AwtFontManager will transparently rewrite the ".AppleSystemUIFont" alias
// to the "System Font" one that Skia knows.
//
// Note that the AWT default font on macOS is not SF, but rather Helvetica Neue.
if (hostOs == OS.MacOS) {
fontManager.matchFamily(FontFamilyKey.Apple.SystemFont.familyName)
.use {
if (it.count() > 0) {
familyNamesCache.add(FontFamilyKey(FontFamilyKey.Apple.SystemFont.familyName))
familyNames += FontFamilyKey.Apple.SystemFont.familyName
familyNamesCache.add(FontFamilyKey(FontFamilyKey.Apple.AppleSystemUiFont.familyName))
familyNames += FontFamilyKey.Apple.AppleSystemUiFont.familyName
}
}
}
// Refresh our cache of system font family names
// (we pre-compute it to save us time later, since there can be many fonts)
_familyNames.clear()
_familyNames.addAll(familyNames)
}
/**
* AWT logical fonts are found in [FontFamilyKey.Awt.awtLogicalFonts];
* these fonts are just aliases for other, physical system fonts.
*/
private fun cacheAwtLogicalFonts() {
try {
for (logicalFont in FontFamilyKey.Awt.awtLogicalFonts) {
val physicalFontFamilyName = AwtFontUtils.resolvePhysicalFontNameOrNull(logicalFont.familyName)
?: continue
awtLogicalFamilyNames += logicalFont to physicalFontFamilyName
}
} catch (ignored: Throwable) {
}
}
/**
* Suspend execution until the font family names caching has
* been completed.
*/
private suspend fun ensureSystemFontsCached() {
if (!allFontsCachedImpl) {
waitChannel.receive()
}
}
override suspend fun familyNames(): Set<String> {
ensureSystemFontsCached()
return _familyNames
}
override suspend fun contains(familyName: String): Boolean {
val key = FontFamilyKey(familyName)
return contains(key)
}
override suspend fun getTypefaceOrNull(familyName: String, fontStyle: FontStyle): Typeface? {
ensureSystemFontsCached()
// trim because we can have spaces on Linux (real example - "Mitra " font family)
val fontFamilyFixed = familyName.trim()
val key = FontFamilyKey(fontFamilyFixed)
if (isAppleSystemFont(key)) {
// Rewrite requests for ".AppleSystemUIFont" on macOS to "System font".
// They are the same, hidden San Francisco font, but we need to do this
// for AWT compatibility reasons.
return FontMgr.default.matchFamilyStyle(FontFamilyKey.Apple.SystemFont.familyName, fontStyle)
}
if (isAwtLogicalFont(key)) {
val physicalFontName = awtLogicalFamilyNames[key]
return FontMgr.default.matchFamilyStyle(physicalFontName, fontStyle)
}
if (!familyNamesCache.contains(key)) return null
return FontMgr.default.matchFamilyStyle(fontFamilyFixed, fontStyle)
}
private fun isAwtLogicalFont(key: FontFamilyKey) =
awtLogicalFamilyNames.containsKey(key)
override suspend fun getFontFamilyOrNull(familyName: String): FontFamily? {
ensureSystemFontsCached()
// trim because we can have spaces on Linux (real example - "Mitra " font family)
val fontFamilyFixed = familyName.trim()
val key = FontFamilyKey(fontFamilyFixed)
if (isAppleSystemFont(key)) {
// Rewrite requests for ".AppleSystemUIFont" on macOS to "System font".
// They are the same, hidden San Francisco font, but we need to do this
// for AWT compatibility reasons.
return FontMgr.default.matchFamily(FontFamilyKey.Apple.SystemFont.familyName)
.use { it.toFontFamilyOrNull(fontFamilyFixed, FontFamily.FontFamilySource.System) }
}
if (!familyNamesCache.contains(key)) return null
return FontMgr.default.matchFamily(familyName)
.use { it.toFontFamilyOrNull(familyName, FontFamily.FontFamilySource.System) }
}
private fun FontStyleSet.toFontFamilyOrNull(familyName: String, source: FontFamily.FontFamilySource): FontFamily? {
if (count() < 1) return null
return FontFamily(familyName, source).apply {
for (i in 0 until count()) {
addTypeface(getTypeface(i)!!)
}
}
}
private fun isAppleSystemFont(key: FontFamilyKey): Boolean {
if (hostOs != OS.MacOS) return false
return key in FontFamilyKey.Apple.hiddenSystemFontNames
}
/**
* Check if the given [key] exists.
*
* This function will suspend until the caching is complete.
*
* @see invalidate
* @see familyNames
*/
suspend fun contains(key: FontFamilyKey): Boolean {
ensureSystemFontsCached()
return familyNamesCache.contains(key)
}
}
package org.jetbrains.skiko.awt.font
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.skia.Data
import org.jetbrains.skia.FontStyle
import org.jetbrains.skia.Typeface
import org.jetbrains.skia.makeFromFile
import org.jetbrains.skiko.awt.font.TypefaceCache.Companion.inMemory
import java.io.File
import java.util.concurrent.ConcurrentHashMap
/**
* A generic typeface cache. An instance of the default in-memory
* implementation can be created with the [inMemory] factory function.
*/
internal interface TypefaceCache {
/**
* Add a classpath resource to this cache.
*
* [TypefaceCache]s don't have knowledge of entries added to other
* instances.
*
* Don't forget to remove entries when you don't need them anymore.
*
* @see removeFontFamily
*/
suspend fun addResource(
resource: String,
loader: ClassLoader = Thread.currentThread().contextClassLoader
)
/**
* Add a [File] to this cache.
*
* [TypefaceCache]s don't have knowledge of entries added to other
* instances.
*
* Don't forget to remove entries when you don't need them anymore.
*
* @see removeFontFamily
*/
fun addFile(file: File)
/**
* Add a [Typeface] to this cache.
* [TypefaceCache]s don't have knowledge of entries added to other
* instances.
*
* Don't forget to remove entries when you don't need them anymore.
*
* @see removeFontFamily
*/
fun addTypeface(typeface: Typeface)
/**
* Remove a custom font by family name, if it exists.
*
* This will not impact other instances of [TypefaceCache]; if a custom
* font is registered on multiple instances, it needs to be removed from
* all of them.
*
* @see addFile
* @see addResource
* @see addTypeface
*/
fun removeFontFamily(familyName: String)
/**
* Remove all entries added to this instance.
*
* This will not impact other instances of [TypefaceCache]; if a custom
* font is registered on multiple instances, it needs to be removed from
* all of them.
*/
fun clear()
/**
* Get a [Typeface] by family name and style, if it exists.
*
* Font family names are matched case-insensitively.
*/
fun getTypefaceOrNull(familyName: String, fontStyle: FontStyle): Typeface?
/**
* Get a [FontFamily] by family name, if it exists.
*
* Font family names are matched case-insensitively.
*/
fun getFontFamilyOrNull(familyName: String): FontFamily?
/**
* List the font families for all registered entries.
*/
fun familyNames(): Set<String>
val size: Int
fun isEmpty() = size == 0
companion object {
/**
* Create an instance of the default in-memory implementation.
* The implementation is thread safe.
*/
fun inMemory(): TypefaceCache = InMemoryTypefaceCache()
}
}
private class InMemoryTypefaceCache private constructor(
private val fontFamiliesCache: ConcurrentHashMap<FontFamilyKey, FontFamily>
) : TypefaceCache {
constructor() : this(ConcurrentHashMap())
private val familyNamesCache: MutableSet<String> = ConcurrentHashMap.newKeySet()
override val size: Int
get() = fontFamiliesCache.size
override suspend fun addResource(
resource: String,
loader: ClassLoader
) {
val res = loader.getResourceAsStream(resource)
?: ClassLoader.getSystemResourceAsStream(resource)
?: error("Unable to access the resources from the provided classloader")
val resourceBytes = withContext(Dispatchers.IO) {
res.readAllBytes()
}
val typeface = Typeface.makeFromData(Data.makeFromBytes(resourceBytes))
addTypeface(typeface)
}
override fun addFile(file: File) {
val typeface = Typeface.makeFromFile(file.absolutePath)
addTypeface(typeface)
}
override fun addTypeface(typeface: Typeface) {
val key = FontFamilyKey(typeface.familyName)
val fontFamily = FontFamily.fromTypefaces(typeface.familyName, FontFamily.FontFamilySource.Custom, typeface)
fontFamiliesCache.getOrPut(key) { fontFamily }
familyNamesCache += typeface.familyName
}
override fun removeFontFamily(familyName: String) {
fontFamiliesCache -= FontFamilyKey(familyName)
familyNamesCache -= familyName
}
override fun clear() {
fontFamiliesCache.clear()
familyNamesCache.clear()
}
override fun getTypefaceOrNull(familyName: String, fontStyle: FontStyle): Typeface? {
val family = getFontFamilyOrNull(familyName)
?: return null
return family[fontStyle]
}
override fun getFontFamilyOrNull(familyName: String) =
fontFamiliesCache[FontFamilyKey(familyName)]
override fun familyNames(): Set<String> = familyNamesCache
}
package org.jetbrains.skiko.awt.font
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.skia.Data
import org.jetbrains.skia.FontStyle
import org.jetbrains.skia.Typeface
import org.jetbrains.skia.tests.makeFromResource
import org.jetbrains.skiko.*
import org.jetbrains.skiko.awt.font.AwtFontUtils.fontFamilyName
import org.jetbrains.skiko.awt.font.AwtFontUtils.resolvePhysicalFontNameOrNull
import org.jetbrains.skiko.isRunningOnJetBrainsRuntime
import org.jetbrains.skiko.tests.runTest
import org.junit.Assume
import org.junit.Test
import java.awt.GraphicsEnvironment
import kotlin.io.path.createTempFile
import kotlin.io.path.writeBytes
import kotlin.test.*
class AwtFontManagerTest {
// Since Arial is not available on Linux, we need a substitute that
// works at least on Ubuntu
private val aFontName = when (hostOs) {
OS.Linux -> "Liberation Sans"
else -> "Arial"
}
private val systemFontProvider = FakeFontProvider().apply {
addEmptyFontFamily(aFontName).also {
it.addTypeface(Typeface.makeFromName(aFontName, FontStyle.NORMAL))
it.addTypeface(Typeface.makeFromName(aFontName, FontStyle.ITALIC))
it.addTypeface(Typeface.makeFromName(aFontName, FontStyle.BOLD))
it.addTypeface(Typeface.makeFromName(aFontName, FontStyle.BOLD_ITALIC))
}
addEmptyFontFamily("Potato Sans")
addEmptyFontFamily("Walrus Display")
}
private val embeddedFontProvider = FakeFontProvider().apply {
addEmptyFontFamily(aFontName).also {
it.addTypeface(Typeface.makeFromName(aFontName, FontStyle.NORMAL))
it.addTypeface(Typeface.makeFromName(aFontName, FontStyle.ITALIC))
it.addTypeface(Typeface.makeFromName(aFontName, FontStyle.BOLD))
it.addTypeface(Typeface.makeFromName(aFontName, FontStyle.BOLD_ITALIC))
}
addEmptyFontFamily("Potato Sans")
addEmptyFontFamily("Walrus Display")
}
private val customTypefaceCache = TestTypefaceCache()
private val fontManager = AwtFontManager(systemFontProvider, embeddedFontProvider, customTypefaceCache)
@Test
fun `should be able to find regular fonts`() = runTest {
val typeface = fontManager.getTypefaceOrNull(aFontName, FontStyle.NORMAL)
assertNotNull(typeface, "Font must exist")
assertEquals(aFontName, typeface.familyName, "Font family name must match")
assertFalse(typeface.isBold, "Font must not be bold")
assertFalse(typeface.isItalic, "Font must not be italic")
}
@OptIn(DependsOnJBR::class)
@Test
fun `should be able to convert all default AWT fonts`() = runTest {
// Remove after fixing https://github.com/Pragmatists/JUnitParams/issues/180
val ignoredFamilies = setOf(
"Franklin Gothic Medium",
"Segoe UI Variable",
"Sitka",
"Roboto Light",
"Roboto Thin"
)
// Listing of font family names is broken on non-macOS JVM implementations,
// except when running on the JetBrains Runtime. Our matching logic only
// works on the JetBrains Runtime.
Assume.assumeTrue("Not running on the JetBrains Runtime", isRunningOnJetBrainsRuntime())
val fontManager = AwtFontManager()
val awtFonts = GraphicsEnvironment.getLocalGraphicsEnvironment().allFonts
val skiaFonts = awtFonts.map { it.toSkikoTypefaceOrNull(fontManager) }
fun String.resolveFontFamily() = if (FontFamilyKey(this) in FontFamilyKey.Awt.awtLogicalFonts) {
resolvePhysicalFontNameOrNull(this) ?: this
} else {
this
}
val awtFamilies = awtFonts.map { it.fontFamilyName!!.resolveFontFamily() }
val skiaFamilies = skiaFonts.map { it?.familyName }
val wrongConversions = mutableSetOf<String>()
for (i in awtFamilies.indices) {
val awtFamily = awtFamilies[i]
val skiaFamily = skiaFamilies[i]
when {
awtFamily == ".AppleSystemUIFont" -> {
if (skiaFamily == null) {
wrongConversions.add("$awtFamily -> null")
}
}
awtFamily != skiaFamily && awtFamily !in ignoredFamilies -> {
wrongConversions.add("$awtFamily -> $skiaFamily")
}
}
}
assertTrue(
wrongConversions.isEmpty(),
"These AWT fonts were wrongly converted:\n${wrongConversions.joinToString("\n") { " * $it" }}"
)
}
@Test
fun `should be able to find bold fonts`() = runTest {
val typeface = fontManager.getTypefaceOrNull(aFontName, FontStyle.BOLD)
assertNotNull(typeface, "Font must exist")
assertEquals(aFontName, typeface.familyName, "Font family name must match")
assertTrue(typeface.isBold, "Font must be bold")
assertFalse(typeface.isItalic, "Font must not be italic")
}
@Test
fun `should be able to find italic fonts`() = runTest {
val typeface = fontManager.getTypefaceOrNull(aFontName, FontStyle.ITALIC)
assertNotNull(typeface, "Font must exist")
assertEquals(aFontName, typeface.familyName, "Font family name must match")
assertFalse(typeface.isBold, "Font must not be bold")
assertTrue(typeface.isItalic, "Font must be italic")
}
@Test
fun `should be able to find bold italic fonts`() = runTest {
val typeface = fontManager.getTypefaceOrNull(aFontName, FontStyle.BOLD_ITALIC)
assertNotNull(typeface, "Font must exist")
assertEquals(aFontName, typeface.familyName, "Font family name must match")
assertTrue(typeface.isBold, "Font must be bold")
assertTrue(typeface.isItalic, "Font must be italic")
}
@Test
fun `should not be able to find non-existent fonts`() = runTest {
val typeface = fontManager.getTypefaceOrNull("XXXYYY745", FontStyle.NORMAL)
assertNull(typeface, "Font must not match (doesn't exist!)")
}
@Test
fun `should not find any custom fonts on a new instance`() {
assertTrue(fontManager.customFamilyNames().isEmpty())
}
@Test
fun `should be able to register, and then find custom fonts from classpath resources`() = runTest {
assertNull(
fontManager.getTypefaceOrNull("Libre Barcode 39", FontStyle.NORMAL),
"The font we're trying to add must not already be loaded"
)
fontManager.addCustomFontResource("LibreBarcode39-Regular.ttf")
val typeface = fontManager.getTypefaceOrNull("Libre Barcode 39", FontStyle.NORMAL)!!
assertContains(fontManager.familyNames(), typeface.familyName, "Custom font not listed in all fonts")
assertContains(
fontManager.customFamilyNames(),
typeface.familyName,
"Custom font not listed in custom fonts"
)
assertNotNull(typeface, "Font must have been loaded from resources")
assertEquals("Libre Barcode 39", typeface.familyName, "Font family name must match")
assertFalse(typeface.isBold, "Font must not be bold")
assertFalse(typeface.isItalic, "Font must not be italic")
}
@Test
fun `should be able to register, and then find custom fonts from a file`() = runTest {
assertNull(
fontManager.getTypefaceOrNull("Libre Barcode 39", FontStyle.NORMAL),
"The font we're trying to add must not already be loaded"
)
val fontFile = createTempFile("awtfontmanagertest", "testfont")
withContext(Dispatchers.IO) {
val fontBytes = Thread.currentThread()
.contextClassLoader
.getResourceAsStream("LibreBarcode39-Regular.ttf")!!
.readAllBytes()
fontFile.writeBytes(fontBytes)
}
fontManager.addCustomFontFile(fontFile.toFile())
assertTrue(fontManager.customFamilyNames().contains("Libre Barcode 39"), "Font must be registered")
val typeface = fontManager.getTypefaceOrNull("Libre Barcode 39", FontStyle.NORMAL)
assertNotNull(typeface, "Font must have been loaded from disk")
assertEquals("Libre Barcode 39", typeface.familyName, "Font family name must match")
assertFalse(typeface.isBold, "Font must not be bold")
assertFalse(typeface.isItalic, "Font must not be italic")
assertContains(fontManager.familyNames(), typeface.familyName, "Custom font not listed in all fonts")
assertContains(
fontManager.customFamilyNames(),
typeface.familyName,
"Custom font not listed in custom fonts"
)
}
@Test
fun `should be able to register, and then find custom fonts from a typeface`() = runTest {
assertNull(
fontManager.getTypefaceOrNull("Libre Barcode 39", FontStyle.NORMAL),
"The font we're trying to add must not already be loaded"
)
val loadedTypeface = withContext(Dispatchers.IO) {
val fontBytes = Thread.currentThread()
.contextClassLoader
.getResourceAsStream("LibreBarcode39-Regular.ttf")!!
.readAllBytes()
Typeface.makeFromData(Data.makeFromBytes(fontBytes))
}
fontManager.addCustomFontTypeface(loadedTypeface)
val typeface = fontManager.getTypefaceOrNull("Libre Barcode 39", FontStyle.NORMAL)!!
assertContains(fontManager.familyNames(), typeface.familyName, "Custom font not listed in all fonts")
assertContains(
fontManager.customFamilyNames(),
typeface.familyName,
"Custom font not listed in custom fonts"
)
assertNotNull(typeface, "Font must have been loaded from disk")
assertEquals("Libre Barcode 39", typeface.familyName, "Font family name must match")
assertFalse(typeface.isBold, "Font must not be bold")
assertFalse(typeface.isItalic, "Font must not be italic")
}
@Test
fun `should remove all custom fonts when clearing custom fonts`() = runTest {
fontManager.addCustomFontResource("./fonts/Inter-V.ttf")
fontManager.addCustomFontTypeface(Typeface.makeFromResource("./fonts/JetBrainsMono-Regular.ttf"))
fontManager.addCustomFontResource("./fonts/JetBrainsMono-Italic.ttf")
val fontFile = createTempFile("awtfontmanagertest", "testfont")
withContext(Dispatchers.IO) {
val fontBytes = Thread.currentThread()
.contextClassLoader
.getResourceAsStream("./fonts/JetBrainsMono-Bold.ttf")!!
.readAllBytes()
fontFile.writeBytes(fontBytes)
}
fontManager.addCustomFontFile(fontFile.toFile())
assertEquals(2, fontManager.customFamilyNames().size)
fontManager.clearCustomFonts()
assertTrue(fontManager.customFamilyNames().isEmpty())
}
@Test
fun `should do nothing when trying to remove a non-existent custom font`() {
fontManager.removeCustomFontFamily("Bananananananananana*&^%$")
}
@Test
fun `should contain at least the system font families`() = runTest {
val families = fontManager.familyNames()
assertEquals(3, families.size, "Missing some system families")
}
@Test
fun `should prefer custom to embedded and system fonts when existing`() = runTest {
fontManager.addCustomFontTypeface(Typeface.makeFromName(aFontName, FontStyle.NORMAL))
val typeface = fontManager.getTypefaceOrNull(aFontName, FontStyle.NORMAL)
assertNotNull(typeface, "Should find typeface")
assertEquals(aFontName, customTypefaceCache.lastGetName)
assertNull(embeddedFontProvider.lastContainsName, "Should not get embedded fonts")
assertNull(embeddedFontProvider.lastGetName, "Should not get embedded fonts")
assertNull(systemFontProvider.lastContainsName, "Should not get system fonts")
assertNull(systemFontProvider.lastGetName, "Should not get system fonts")
}
@Test
fun `should prefer embedded to system fonts when existing and no matching custom font`() = runTest {
val typeface = fontManager.getTypefaceOrNull(aFontName, FontStyle.NORMAL)
assertNotNull(typeface, "Should find typeface")
assertEquals(aFontName, customTypefaceCache.lastGetName)
assertEquals(aFontName, embeddedFontProvider.lastGetName)
assertNull(systemFontProvider.lastContainsName, "Should not get system fonts")
assertNull(systemFontProvider.lastGetName, "Should not get system fonts")
}
@Test
fun `should return null when existing fonts are missing the requested style`() = runTest {
systemFontProvider.addEmptyFontFamily("JetBrains Mono")
fontManager.addCustomFontResource("./fonts/JetBrainsMono-Regular.ttf")
val typeface = fontManager.getTypefaceOrNull("JetBrains Mono", FontStyle.BOLD)
assertNull(typeface, "Embedded typeface should be missing in this test")
assertEquals("JetBrains Mono", customTypefaceCache.lastGetName)
assertEquals("JetBrains Mono", embeddedFontProvider.lastGetName)
assertEquals("JetBrains Mono", systemFontProvider.lastGetName)
}
@Test
fun `should return false from isAbleToResolveFamilyNames when not running on JBR`() {
Assume.assumeFalse("Running on the JetBrains Runtime", isRunningOnJetBrainsRuntime())
assertFalse(fontManager.isAbleToResolveFamilyNames)
}
@Test
fun `should return true from isAbleToResolveFamilyNames when running on JBR`() {
Assume.assumeTrue("Not running on the JetBrains Runtime", isRunningOnJetBrainsRuntime())
assertTrue(fontManager.isAbleToResolveFamilyNames)
}
}
package org.jetbrains.skiko.awt.font
import org.jetbrains.skia.FontStyle
import org.jetbrains.skia.Typeface
import org.jetbrains.skiko.awt.font.FontFamily.FontFamilySource
internal class FakeFontProvider(
private val families: MutableMap<FontFamilyKey, FontFamily> = mutableMapOf()
) : FontProvider {
var lastGetName: String? = null
private set
var lastContainsName: String? = null
private set
constructor(vararg families: FontFamily) : this(
families.associateBy { FontFamilyKey(it.familyName) }
.toMutableMap()
)
override fun invalidate() {
// Do nothing
}
override suspend fun familyNames() =
families.keys
.map { it.familyName }
.toSet()
override suspend fun contains(familyName: String): Boolean {
lastContainsName = familyName
return familyNames().contains(familyName)
}
override suspend fun getTypefaceOrNull(familyName: String, fontStyle: FontStyle): Typeface? {
val key = FontFamilyKey(familyName)
lastGetName = familyName
return families[key]?.get(fontStyle)
}
override suspend fun getFontFamilyOrNull(familyName: String): FontFamily? {
val key = FontFamilyKey(familyName)
lastGetName = familyName
return families[key]
}
fun addEmptyFontFamily(familyName: String, source: FontFamilySource = FontFamilySource.Custom): FontFamily {
val key = FontFamilyKey(familyName)
val family = FontFamily(familyName, source)
families += key to family
return family
}
fun resetNamesTracking() {
lastGetName = null
}
}
package org.jetbrains.skiko.awt.font
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
class FontFamilyKeyTest {
@Test
fun `should be equals to another instance with the same family name (case sensitive)`() {
assertEquals(FontFamilyKey("Banana"), FontFamilyKey("Banana"))
}
@Test
fun `should be equals to another instance with the same family name (case insensitive)`() {
assertEquals(FontFamilyKey("Banana"), FontFamilyKey("banana"))
}
@Test
fun `should not be equals to another instance with a different family name`() {
assertNotEquals(FontFamilyKey("Banana"), FontFamilyKey("potato"))
}
@Test
fun `should have the same hashcode as another instance with the same family name (case sensitive)`() {
assertEquals(FontFamilyKey("Banana").hashCode(), FontFamilyKey("Banana").hashCode())
}
@Test
fun `should have the same hashcode as another instance with the same family name (case insensitive)`() {
assertEquals(FontFamilyKey("Banana").hashCode(), FontFamilyKey("banana").hashCode())
}
@Test
fun `should not have the same hashcode as another instance with a different family name`() {
assertNotEquals(FontFamilyKey("Banana").hashCode(), FontFamilyKey("potato").hashCode())
}
}
package org.jetbrains.skiko.awt.font
import kotlinx.coroutines.runBlocking
import org.jetbrains.skia.*
import org.jetbrains.skia.tests.makeFromResource
import org.jetbrains.skiko.awt.font.FontFamily.FontFamilySource
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue
class FontFamilyTest {
private val aFontFamily = runBlocking {
FontFamily.fromTypefaces(
"JetBrains Mono",
FontFamilySource.Custom,
Typeface.makeFromResource("./fonts/JetBrainsMono-Regular.ttf"),
Typeface.makeFromResource("./fonts/JetBrainsMono-Italic.ttf"),
Typeface.makeFromResource("./fonts/JetBrainsMono-Bold.ttf")
)
}
private val expectedFontMap = runBlocking {
mapOf(
Typeface.makeFromResource("./fonts/JetBrainsMono-Regular.ttf").let { it.fontStyle to it },
Typeface.makeFromResource("./fonts/JetBrainsMono-Italic.ttf").let { it.fontStyle to it },
Typeface.makeFromResource("./fonts/JetBrainsMono-Bold.ttf").let { it.fontStyle to it },
)
}
@Test
fun `should throw IllegalArgumentException when constructed with a blank family name`() {
assertFailsWith<IllegalArgumentException> {
FontFamily("", FontFamilySource.Custom)
}
assertFailsWith<IllegalArgumentException> {
FontFamily(" ", FontFamilySource.Custom)
}
assertFailsWith<IllegalArgumentException> {
FontFamily("\t", FontFamilySource.Custom)
}
}
@Test
fun `should list all available styles provided`() {
assertEquals(expectedFontMap.keys, aFontFamily.availableStyles)
}
@Test
fun `should list all available typefaces provided`() {
assertTrue(expectedFontMap.values.equalsLogically(aFontFamily.availableTypefaces))
}
@Test
fun `should add existing typeface to an empty instance`() {
val loadedTypeface = runBlocking {
Typeface.makeFromResource("fonts/JetBrainsMono-Regular.ttf")
}
val newFontFamilyInfo = FontFamily.fromTypefaces("JetBrains Mono", FontFamilySource.Custom, loadedTypeface)
assertEquals(setOf(loadedTypeface.fontStyle), newFontFamilyInfo.availableStyles)
}
@Test
fun `should add existing typeface`() {
val loadedTypeface = runBlocking {
Typeface.makeFromResource("fonts/JetBrainsMono-Regular.ttf")
}
val oldTypeface = aFontFamily[loadedTypeface.fontStyle]!!
val newFontFamilyInfo = aFontFamily + loadedTypeface
assertEquals(expectedFontMap.keys, newFontFamilyInfo.availableStyles)
assertNotEquals(oldTypeface, loadedTypeface)
}
@Test
fun `should throw when adding new, incompatible typeface`() {
val incompatibleFont = runBlocking { Typeface.makeFromResource("fonts/Inter-V.ttf") }
assertFailsWith(IllegalArgumentException::class) {
aFontFamily + incompatibleFont
}
}
@Test
fun `should remove available style`() {
val removedStyle = expectedFontMap.keys.last()
val fontFamilyCache = aFontFamily - removedStyle
assertEquals(expectedFontMap.keys - removedStyle, fontFamilyCache.availableStyles)
}
@Test
fun `should remove available typeface`() {
val removedTypeface = expectedFontMap.values.last()
val fontFamilyCache = aFontFamily - removedTypeface
val removedStyle = removedTypeface.fontStyle
assertTrue(fontFamilyCache.availableTypefaces.equalsLogically((expectedFontMap - removedStyle).values))
}
@Test
fun `should do nothing when removing any style from an empty family info`() {
val anyStyle = FontStyle.BOLD
val fontFamily = FontFamily("Anything", FontFamilySource.Custom) - anyStyle
assertTrue(fontFamily.isEmpty())
}
@Test
fun `should do nothing when removing typeface from an empty family info`() {
val anyTypeface = expectedFontMap.values.first()
val fontFamily = FontFamily("Anything", FontFamilySource.Custom) - anyTypeface
assertTrue(fontFamily.isEmpty())
}
@Test
fun `should do nothing when removing any non-existing style`() {
val anyStyle = FontStyle(weight = 1000, FontWidth.EXTRA_CONDENSED, FontSlant.OBLIQUE)
val fontFamilyCache = aFontFamily - anyStyle
assertEquals(aFontFamily.availableStyles, fontFamilyCache.availableStyles)
}
@Test
fun `find closest style weight`() {
val styles = setOf(
FontStyle(weight = 400, width = 5, slant = FontSlant.UPRIGHT),
FontStyle(weight = 400, width = 7, slant = FontSlant.ITALIC),
FontStyle(weight = 400, width = 5, slant = FontSlant.ITALIC),
FontStyle(weight = 700, width = 5, slant = FontSlant.UPRIGHT),
)
fun closestStyle(weight: Int, width: Int, slant: FontSlant) =
FontFamily.closestStyle(styles, FontStyle(weight, width, slant))
assertEquals(
FontStyle(weight = 400, width = 5, slant = FontSlant.UPRIGHT),
closestStyle(weight = 300, width = 5, slant = FontSlant.UPRIGHT)
)
assertEquals(
FontStyle(weight = 400, width = 5, slant = FontSlant.UPRIGHT),
closestStyle(weight = 400, width = 5, slant = FontSlant.UPRIGHT)
)
assertEquals(
FontStyle(weight = 700, width = 5, slant = FontSlant.UPRIGHT),
closestStyle(weight = 500, width = 5, slant = FontSlant.UPRIGHT)
)
assertEquals(
FontStyle(weight = 700, width = 5, slant = FontSlant.UPRIGHT),
closestStyle(weight = 700, width = 5, slant = FontSlant.UPRIGHT)
)
assertEquals(
FontStyle(weight = 700, width = 5, slant = FontSlant.UPRIGHT),
closestStyle(weight = 800, width = 5, slant = FontSlant.UPRIGHT)
)
}
@Test
fun `find closest style width`() {
val styles = setOf(
FontStyle(weight = 400, width = 5, slant = FontSlant.UPRIGHT),
FontStyle(weight = 400, width = 7, slant = FontSlant.ITALIC),
FontStyle(weight = 400, width = 5, slant = FontSlant.ITALIC),
FontStyle(weight = 700, width = 5, slant = FontSlant.UPRIGHT),
)
fun closestStyle(weight: Int, width: Int, slant: FontSlant) =
FontFamily.closestStyle(styles, FontStyle(weight, width, slant))
assertEquals(
FontStyle(weight = 400, width = 5, slant = FontSlant.UPRIGHT),
closestStyle(weight = 300, width = 3, slant = FontSlant.UPRIGHT)
)
assertEquals(
FontStyle(weight = 400, width = 5, slant = FontSlant.UPRIGHT),
closestStyle(weight = 300, width = 5, slant = FontSlant.UPRIGHT)
)
assertEquals(
FontStyle(weight = 400, width = 7, slant = FontSlant.ITALIC),
closestStyle(weight = 300, width = 6, slant = FontSlant.UPRIGHT)
)
assertEquals(
FontStyle(weight = 400, width = 7, slant = FontSlant.ITALIC),
closestStyle(weight = 300, width = 7, slant = FontSlant.UPRIGHT)
)
assertEquals(
FontStyle(weight = 700, width = 5, slant = FontSlant.UPRIGHT),
closestStyle(weight = 700, width = 7, slant = FontSlant.UPRIGHT)
)
}
@Test
fun `should do nothing when removing any non-existing typeface`() {
val anyTypeface = runBlocking { Typeface.makeFromResource("fonts/Inter-V.ttf") }
val fontFamily = FontFamily("Anything", FontFamilySource.Custom) - anyTypeface
assertTrue(fontFamily.isEmpty())
}
@Test
fun `should throw when removing an existing but logically different typeface`() {
val incompatibleTypeface = runBlocking { Typeface.makeFromResource("fonts/Inter-V.ttf") }
assertFailsWith(IllegalArgumentException::class) {
aFontFamily - incompatibleTypeface
}
}
}
private fun Collection<Typeface>.equalsLogically(other: Collection<Typeface>): Boolean {
if (size != other.size) return false
return all { thisTypeface ->
other.any { it.equalsLogically(thisTypeface) }
}
}
private fun Typeface.equalsLogically(other: Typeface?): Boolean {
if (other == null) return false
fun Array<FontFamilyName>.equalsLogically(other: Array<FontFamilyName>): Boolean {
if (size != other.size) return false
return all { thisName ->
other.any { it.language == thisName.language && it.name == thisName.name }
}
}
return familyName == other.familyName &&
familyNames.equalsLogically(other.familyNames) &&
fontStyle == other.fontStyle
}
package org.jetbrains.skiko.awt.font
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.skia.Data
import org.jetbrains.skia.FontStyle
import org.jetbrains.skia.Typeface
import org.jetbrains.skia.tests.makeFromResource
import org.jetbrains.skiko.tests.runTest
import org.junit.Test
import kotlin.io.path.createTempFile
import kotlin.io.path.writeBytes
import kotlin.test.*
class InMemoryTypefaceCacheTest {
private val cache = TypefaceCache.inMemory()
@Test
fun `should be able to register, and then find custom fonts from classpath resources`() = runTest {
cache.addResource("LibreBarcode39-Regular.ttf")
val typeface = cache.getTypefaceOrNull("Libre Barcode 39", FontStyle.NORMAL)!!
assertContains(cache.familyNames(), typeface.familyName, "Custom font not listed in all fonts")
assertContains(
cache.familyNames(),
typeface.familyName,
"Custom font not listed in custom fonts"
)
assertNotNull(typeface, "Font must have been loaded from resources")
assertEquals("Libre Barcode 39", typeface.familyName, "Font family name must match")
assertFalse(typeface.isBold, "Font must not be bold")
assertFalse(typeface.isItalic, "Font must not be italic")
}
@Test
fun `should be able to register, and then find custom fonts from a file`() = runTest {
assertNull(
cache.getTypefaceOrNull("Libre Barcode 39", FontStyle.NORMAL),
"The font we're trying to add must not already be loaded"
)
val fontFile = createTempFile("awtfontmanagertest", "testfont")
withContext(Dispatchers.IO) {
val fontBytes = Thread.currentThread()
.contextClassLoader
.getResourceAsStream("LibreBarcode39-Regular.ttf")!!
.readAllBytes()
fontFile.writeBytes(fontBytes)
}
cache.addFile(fontFile.toFile())
assertTrue(cache.familyNames().contains("Libre Barcode 39"), "Font must be registered")
val typeface = cache.getTypefaceOrNull("Libre Barcode 39", FontStyle.NORMAL)
assertNotNull(typeface, "Font must have been loaded from disk")
assertEquals("Libre Barcode 39", typeface.familyName, "Font family name must match")
assertFalse(typeface.isBold, "Font must not be bold")
assertFalse(typeface.isItalic, "Font must not be italic")
assertContains(cache.familyNames(), typeface.familyName, "Custom font not listed in all fonts")
assertContains(
cache.familyNames(),
typeface.familyName,
"Custom font not listed in custom fonts"
)
}
@Test
fun `should be able to register, and then find custom fonts from a typeface`() = runTest {
assertNull(
cache.getTypefaceOrNull("Libre Barcode 39", FontStyle.NORMAL),
"The font we're trying to add must not already be loaded"
)
val loadedTypeface = withContext(Dispatchers.IO) {
val fontBytes = Thread.currentThread()
.contextClassLoader
.getResourceAsStream("LibreBarcode39-Regular.ttf")!!
.readAllBytes()
Typeface.makeFromData(Data.makeFromBytes(fontBytes))
}
cache.addTypeface(loadedTypeface)
val typeface = cache.getTypefaceOrNull("Libre Barcode 39", FontStyle.NORMAL)!!
assertContains(cache.familyNames(), typeface.familyName, "Custom font not listed in all fonts")
assertContains(
cache.familyNames(),
typeface.familyName,
"Custom font not listed in custom fonts"
)
assertNotNull(typeface, "Font must have been loaded from disk")
assertEquals("Libre Barcode 39", typeface.familyName, "Font family name must match")
assertFalse(typeface.isBold, "Font must not be bold")
assertFalse(typeface.isItalic, "Font must not be italic")
}
@Test
fun `should remove all custom fonts when clearing custom fonts`() = runTest {
cache.addResource("./fonts/Inter-V.ttf")
cache.addTypeface(Typeface.makeFromResource("./fonts/JetBrainsMono-Regular.ttf"))
cache.addResource("./fonts/JetBrainsMono-Italic.ttf")
val fontFile = createTempFile("awtfontmanagertest", "testfont")
withContext(Dispatchers.IO) {
val fontBytes = Thread.currentThread()
.contextClassLoader
.getResourceAsStream("./fonts/JetBrainsMono-Bold.ttf")!!
.readAllBytes()
fontFile.writeBytes(fontBytes)
}
cache.addFile(fontFile.toFile())
assertEquals(2, cache.familyNames().size)
cache.clear()
assertTrue(cache.familyNames().isEmpty())
}
@Test
fun `should do nothing when trying to remove a non-existent custom font`() {
cache.removeFontFamily("Bananananananananana*&^%$")
assertTrue(cache.isEmpty())
}
}
package org.jetbrains.skiko.awt.font
import org.jetbrains.skiko.isRunningOnJetBrainsRuntime
import org.jetbrains.skiko.tests.runTest
import org.junit.Assume
import org.junit.Test
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.Path
import kotlin.io.path.absolutePathString
import kotlin.io.path.exists
import kotlin.io.path.extension
import kotlin.streams.asSequence
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class JvmEmbeddedFontProviderTest {
private val provider = JvmEmbeddedFontProvider
@Test
fun `should be able to access JBR features when running on JBR`() = runTest {
Assume.assumeTrue(isRunningOnJetBrainsRuntime())
assertTrue(provider.canUseJetBrainsRuntimeFeatures, "JBR features should be available")
}
@Test
fun `should not be able to access JBR features when not running on JBR`() = runTest {
Assume.assumeFalse(isRunningOnJetBrainsRuntime())
assertFalse(provider.canUseJetBrainsRuntimeFeatures, "JBR features should not be available")
}
@Test
fun `should read JBR embedded font family name mappings`() = runTest {
Assume.assumeTrue("Not running on the JetBrains Runtime", isRunningOnJetBrainsRuntime())
val expectedFontFamilyMap = mapOf("Roboto-Light" to "Roboto Light", "Roboto-Thin" to "Roboto Thin")
val detected = provider.embeddedFontFamilyMap()
val missingMapEntries = mutableSetOf<Map.Entry<String, String>>()
val badMapEntries = mutableMapOf<Map.Entry<String, String>, String>()
for (familyMapEntry in expectedFontFamilyMap) {
val key = familyMapEntry.key
if (!detected.containsKey(key)) {
missingMapEntries += familyMapEntry
} else if (detected[key] != familyMapEntry.value) {
badMapEntries += familyMapEntry to detected[key]!!
}
}
assertTrue(
actual = missingMapEntries.isEmpty(),
message = "These JBR embedded family map entries didn't get picked up:\n" +
missingMapEntries.joinToString("\n") { " * $it" }
)
assertTrue(
actual = badMapEntries.isEmpty(),
message = "These JBR embedded family map entries are wrong:\n" +
badMapEntries.entries
.joinToString("\n") { (expected, actual) ->
" * $expected (but was: $actual)"
}
)
}
@Test
fun `should include all JBR embedded fonts`() = runTest {
Assume.assumeTrue("Not running on the JetBrains Runtime", isRunningOnJetBrainsRuntime())
val jbrFontPaths = getJbrEmbeddedFontPaths()
assertTrue(jbrFontPaths.isNotEmpty(), "JBR embeds fonts but none was picked up by the test code")
val detected = provider.embeddedFontFilePaths()
val missingEmbeddedFiles = mutableSetOf<String>()
for (jbrFontPath in jbrFontPaths) {
if (!detected.contains(jbrFontPath)) {
missingEmbeddedFiles += jbrFontPath
}
}
assertTrue(
actual = missingEmbeddedFiles.isEmpty(),
message = "These JBR embedded font files didn't get picked up:\n" +
missingEmbeddedFiles.joinToString("\n") { " * $it" }
)
}
@Test
fun `should include all non-JBR embedded fonts`() = runTest {
Assume.assumeFalse("Running on the JetBrains Runtime", isRunningOnJetBrainsRuntime())
val embeddedFontPaths = getNonJbrEmbeddedFontPaths()
Assume.assumeTrue("This JVM has no embedded fonts", embeddedFontPaths.isNotEmpty())
val detected = provider.embeddedFontFilePaths()
val missingEmbeddedFiles = mutableSetOf<String>()
for (jbrFontPath in embeddedFontPaths) {
if (!detected.contains(jbrFontPath)) {
missingEmbeddedFiles += jbrFontPath
}
}
assertTrue(
actual = missingEmbeddedFiles.isEmpty(),
message = "These non-JBR embedded font files didn't get picked up:\n" +
missingEmbeddedFiles.joinToString("\n") { " * $it" }
)
}
private fun getJbrEmbeddedFontPaths(): List<String> {
val javaHome = System.getProperty("java.home")
val jbrFontsDirPath = Path(javaHome, "lib", "fonts")
assertTrue(jbrFontsDirPath.exists(), "JetBrains Runtime fonts directory doesn't exist")
return Files.walk(jbrFontsDirPath).asSequence()
.filter { path -> path.looksLikeFontFile() }
.map { it.absolutePathString() }
.filterNot {
// The JBR ships with two Bold Italic versions of JetBrains Mono; given
// a font family can only accept one typeface per FontStyle, the prod code
// silently drops the second of the two: JetBrainsMono-BoldItalic.ttf, as
// it comes alphabetically after JetBrainsMono-Bold-Italic.ttf.
it.endsWith("JetBrainsMono-BoldItalic.ttf", ignoreCase = true)
}
.toList()
}
private fun getNonJbrEmbeddedFontPaths(): List<String> {
val javaHome = Path(System.getProperty("java.home"))
return Files.walk(javaHome).asSequence()
.filter { path -> path.looksLikeFontFile() }
.map { it.absolutePathString() }
.toList()
}
private fun Path.looksLikeFontFile(): Boolean =
extension.endsWith("ttf", ignoreCase = true) ||
extension.endsWith("otf", ignoreCase = true) ||
extension.endsWith("ttc", ignoreCase = true)
}
package org.jetbrains.skiko.awt.font
import kotlinx.coroutines.runBlocking
import org.jetbrains.skiko.OS
import org.jetbrains.skiko.hostOs
import org.jetbrains.skiko.isRunningOnJetBrainsRuntime
import org.jetbrains.skiko.tests.runTest
import org.jetbrains.skiko.util.assertOpensAreSet
import org.junit.Assume
import org.junit.Test
import kotlin.test.assertContains
import kotlin.test.assertTrue
class SkiaFontProviderTest {
private val provider = FontProvider.Skia
@Test
fun `should contain at least some font families`() = runTest {
val families = provider.familyNames()
assertTrue(families.isNotEmpty(), "Available font families must not be empty")
}
@Test
fun `should include 'System Font' and its AWT alias in system font families (only on macOS)`() = runTest {
Assume.assumeTrue(hostOs == OS.MacOS)
val systemFamilies = provider.familyNames()
assertContains(
iterable = systemFamilies,
element = FontFamilyKey.Apple.AppleSystemUiFont.familyName,
message = ".AppleSystemUIFont not found in system families"
)
assertContains(
iterable = systemFamilies,
element = FontFamilyKey.Apple.SystemFont.familyName,
message = "System Font not found in system families"
)
}
@Test
fun `should be able to resolve logical fonts when running on JetBrains Runtime`() = runTest {
Assume.assumeTrue(isRunningOnJetBrainsRuntime())
assertOpensAreSet()
val logicalFamilies = AwtFontUtils.fontFamilyNamesOrNull()!!
.onlyAwtLogicalFamilies()
.toSet()
val missingLogicalFamilies = mutableSetOf<String>()
for (logicalFamily in logicalFamilies) {
val resolved = AwtFontUtils.resolvePhysicalFontNameOrNull(logicalFamily)
if (resolved == null) {
missingLogicalFamilies += logicalFamily
}
}
assertTrue(
actual = missingLogicalFamilies.isEmpty(),
message = "These logical font families can't be resolved:\n" +
missingLogicalFamilies.joinToString("\n") { " * $it" }
)
}
@Test
fun `should provide all the system fonts also available via AWT`() = runTest {
// Remove after fixing https://github.com/Pragmatists/JUnitParams/issues/180
val ignoredFamilies = setOf(
"Franklin Gothic Medium",
"Segoe UI Variable",
"Sitka"
)
// Listing of font family names is broken on non-macOS JVM implementations,
// except when running on the JetBrains Runtime. Our matching logic only
// works on the JetBrains Runtime.
Assume.assumeTrue("Not running on the JetBrains Runtime", isRunningOnJetBrainsRuntime())
val awtFamilies = AwtFontUtils.fontFamilyNamesOrNull()!!
.ignoreVirtualAwtFontFamilies()
.ignoreEmbeddedFontFamilies()
.toSet()
val skiaFamilies = provider.familyNames()
val missingAwtFamilies = mutableSetOf<String>()
for (awtFamily in awtFamilies) {
if (awtFamily !in skiaFamilies && awtFamily !in ignoredFamilies) {
missingAwtFamilies += awtFamily
}
}
assertTrue(
missingAwtFamilies.isEmpty(),
"These AWT font families are missing:\n${missingAwtFamilies.joinToString("\n") { " * $it" }}"
)
}
}
private fun Iterable<String>.ignoreVirtualAwtFontFamilies() =
filterNot { FontFamilyKey(it) in FontFamilyKey.Awt.awtLogicalFonts }
private fun Iterable<String>.ignoreEmbeddedFontFamilies(): List<String> {
val embeddedFamilyKeys = runBlocking { JvmEmbeddedFontProvider.familyNames() }
.map { FontFamilyKey(it) }
val embeddedMappedFamilyKeys = runBlocking { JvmEmbeddedFontProvider.embeddedFontFamilyMap() }
.map { FontFamilyKey(it.value) }
return filterNot { FontFamilyKey(it) in embeddedFamilyKeys }
.filterNot { FontFamilyKey(it) in embeddedMappedFamilyKeys }
}
private fun Iterable<String>.onlyAwtLogicalFamilies() =
filter { FontFamilyKey(it) in FontFamilyKey.Awt.awtLogicalFonts }
package org.jetbrains.skiko.awt.font
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.skia.Data
import org.jetbrains.skia.FontStyle
import org.jetbrains.skia.Typeface
import org.jetbrains.skia.makeFromFile
import java.io.File
internal class TestTypefaceCache : TypefaceCache {
val families = mutableMapOf<FontFamilyKey, FontFamily>()
var lastGetName: String? = null
private set
override val size: Int
get() = families.size
override suspend fun addResource(
resource: String,
loader: ClassLoader
) {
val res = loader.getResourceAsStream(resource)
?: ClassLoader.getSystemResourceAsStream(resource)
?: error("Unable to access the resources from the provided classloader")
val resourceBytes = withContext(Dispatchers.IO) {
res.readAllBytes()
}
val typeface = Typeface.makeFromData(Data.makeFromBytes(resourceBytes))
addTypeface(typeface)
}
override fun addFile(file: File) {
val typeface = Typeface.makeFromFile(file.absolutePath)
addTypeface(typeface)
}
override fun addTypeface(typeface: Typeface) {
val key = FontFamilyKey(typeface.familyName)
val fontFamily = families.getOrPut(key) { FontFamily(typeface.familyName, FontFamily.FontFamilySource.Custom) }
fontFamily += typeface
}
override fun removeFontFamily(familyName: String) {
families -= FontFamilyKey(familyName)
}
override fun clear() {
families.clear()
}
override fun getTypefaceOrNull(familyName: String, fontStyle: FontStyle): Typeface? {
val family = getFontFamilyOrNull(familyName)
?: return null
return family[fontStyle]
}
override fun getFontFamilyOrNull(familyName: String): FontFamily? {
lastGetName = familyName
return families[FontFamilyKey(familyName)]
}
override fun familyNames() =
families.keys
.map { it.familyName }
.toSet()
fun resetNamesTracking() {
lastGetName = null
}
}
package org.jetbrains.skiko.util
import org.jetbrains.skiko.InternalSunApiChecker
import kotlin.test.assertTrue
internal fun assertOpensAreSet() {
assertTrue(InternalSunApiChecker.isSunFontApiAccessible(), "The java.desktop/sun.font module doesn't seem to be opened")
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment