Unverified Commit 8e62cef3 authored by Sebastiano Poggi's avatar Sebastiano Poggi Committed by GitHub

Switch AWT font loading to Skia-based (#639)

## Improve font loading and listing
Available font listing doesn't rely anymore on discovering and loading font files from the disk. This is MUCH faster than the previous solution, which could take seconds on a performant machine. Besides, this greatly reduces memory usage as we're only holding the font family names list in memory, and cache items in memory lazily only once they are requested to speed up later lookups. Users can manually clear the memory cache, and register custom fonts (from files or resources).

We now get the list of available font families directly from Skia, with some additional features such as adding entries for the system font on macOS (which is hidden by default).

To note, the list of fonts available to Skia is not the same as available to AWT, for a number of reasons:
 1. AWT adds logical fonts (e.g., `Dialog`)
 2. AWT silently substitutes broken fonts
 3. AWT lists the macOS system font, but under a weird name, `.AppleSystemUIFont`, whereas Skia doesn't
 4. AWT and Skia don't use the same logic to list installed fonts on all platforms
 5. AWT includes fonts embedded into the JVM automatically

This makes things a bit more complicated, but if you _know_ the correct family name, everything works fine. While AWT silently substitutes invalid font family names with others, Skia will return `null` if it can't find a font. We follow the Skia approach, returning null if we can't match a font family name.

## Improve AWT `Font` conversion to Skiko `Typeface`
On the JetBrains Runtime, the `Font.toSkikoTypefaceOrNull()` will be able to properly match an AWT `Font` to a Skiko `Typeface`, even on OSes other than macOS. If running on other JVMs, this returns `null`, as requested by @igordmn. This requirement is documented in the KDocs.

The necessity to be on the JBR is due to the "regular" AWT implementations of `sun.font.Font2D` and `sun.funt.TrueTypeFont` being essentially broken: they read the wrong entry from the `name` table in TrueType fonts and use it as font family name. In the JBR, additional logic has been added to read the correct entry, the [_preferred family_](https://developer.apple.com/fonts/TrueType-Reference-Manual/RM06/Chap6name.html), and we can use this information to match with Skia font families, as Skia uses the correct _preferred family name_.
parent 1c901241
......@@ -1155,6 +1155,8 @@ tasks.withType<Test>().configureEach {
systemProperty("sun.java2d.uiScale", "1")
}
}
jvmArgs = listOf("--add-opens", "java.desktop/sun.font=ALL-UNNAMED")
}
afterEvaluate {
......
package org.jetbrains.skiko
/**
* Mark a feature that depends on running on the JetBrains Runtime.
*
* If you use a feature marked by this annotation, you need to make sure
* that you are running your code on the JetBrains Runtime, as it may
* depend on private APIs or additional features that are not available
* on other runtimes.
*
* Refer to the feature documentation to understand what its failure mode
* is when running on other Java Virtual Machine implementations.
*/
@Retention(value = AnnotationRetention.BINARY)
@RequiresOptIn(
level = RequiresOptIn.Level.WARNING,
message = "This functionality will only work correctly on the JetBrains Runtime."
)
annotation class DependsOnJBR
package org.jetbrains.skiko
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.skia.*
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.color.ColorSpace
import java.awt.image.*
import java.awt.event.*
import java.awt.event.KeyEvent.*
import java.awt.font.TextAttribute
import java.awt.image.BufferedImage
import java.awt.image.ComponentColorModel
import java.awt.image.DataBuffer
import java.awt.image.Raster
import java.nio.ByteBuffer
private class DirectDataBuffer(val backing: ByteBuffer): DataBuffer(TYPE_BYTE, backing.limit()) {
private class DirectDataBuffer(val backing: ByteBuffer) : DataBuffer(TYPE_BYTE, backing.limit()) {
override fun getElem(bank: Int, index: Int): Int {
return backing[index].toInt()
}
override fun setElem(bank: Int, index: Int, value: Int) {
throw UnsupportedOperationException("no write access")
}
......@@ -43,7 +52,7 @@ fun Bitmap.toBufferedImage(): BufferedImage {
Transparency.TRANSLUCENT,
DataBuffer.TYPE_BYTE
)
return BufferedImage(colorModel, raster!!, false, null)
return BufferedImage(colorModel, raster!!, false, null)
}
fun BufferedImage.toBitmap(): Bitmap {
......@@ -85,7 +94,7 @@ fun toSkikoEvent(event: MouseEvent): SkikoPointerEvent {
pressedButtons = toSkikoPressedMouseButtons(event),
button = toSkikoMouseButton(event),
modifiers = toSkikoModifiers(event.modifiersEx),
kind = when(event.id) {
kind = when (event.id) {
MouseEvent.MOUSE_PRESSED -> SkikoPointerEventKind.DOWN
MouseEvent.MOUSE_RELEASED -> SkikoPointerEventKind.UP
MouseEvent.MOUSE_DRAGGED -> SkikoPointerEventKind.DRAG
......@@ -100,7 +109,7 @@ fun toSkikoEvent(event: MouseEvent): SkikoPointerEvent {
}
fun toSkikoEvent(event: MouseWheelEvent): SkikoPointerEvent {
val scrollAmount = event.getPreciseWheelRotation()
val scrollAmount = event.preciseWheelRotation
val modifiers = toSkikoModifiers(event.modifiersEx)
val isShiftPressed = modifiers.has(SkikoInputModifiers.SHIFT)
val deltaX = if (isShiftPressed) scrollAmount else 0.0
......@@ -113,8 +122,8 @@ fun toSkikoEvent(event: MouseWheelEvent): SkikoPointerEvent {
pressedButtons = toSkikoPressedMouseButtons(event),
button = toSkikoMouseButton(event),
modifiers = modifiers,
kind = when(event.id) {
MouseEvent.MOUSE_WHEEL-> SkikoPointerEventKind.SCROLL
kind = when (event.id) {
MouseEvent.MOUSE_WHEEL -> SkikoPointerEventKind.SCROLL
else -> SkikoPointerEventKind.UNKNOWN
},
timestamp = event.`when`,
......@@ -126,9 +135,9 @@ fun toSkikoEvent(event: KeyEvent): SkikoKeyboardEvent {
return SkikoKeyboardEvent(
SkikoKey.valueOf(toSkikoKey(event)),
toSkikoModifiers(event.modifiersEx),
when(event.id) {
KeyEvent.KEY_PRESSED -> SkikoKeyboardEventKind.DOWN
KeyEvent.KEY_RELEASED -> SkikoKeyboardEventKind.UP
when (event.id) {
KEY_PRESSED -> SkikoKeyboardEventKind.DOWN
KEY_RELEASED -> SkikoKeyboardEventKind.UP
else -> SkikoKeyboardEventKind.UNKNOWN
},
event.`when`,
......@@ -211,7 +220,7 @@ private fun toSkikoMouseButton(event: MouseEvent): SkikoMouseButtons {
private fun toSkikoModifiers(modifiers: Int): SkikoInputModifiers {
var result = 0
if (modifiers and InputEvent.ALT_DOWN_MASK != 0) {
result = result.or(SkikoInputModifiers.ALT.value)
result = SkikoInputModifiers.ALT.value
}
if (modifiers and InputEvent.SHIFT_DOWN_MASK != 0) {
result = result.or(SkikoInputModifiers.SHIFT.value)
......@@ -227,14 +236,15 @@ private fun toSkikoModifiers(modifiers: Int): SkikoInputModifiers {
private fun toSkikoKey(event: KeyEvent): Int {
var key = event.keyCode
val side = event.getKeyLocation()
val side = event.keyLocation
if (side == KEY_LOCATION_RIGHT) {
if (
key == SkikoKey.KEY_LEFT_CONTROL.platformKeyCode ||
key == SkikoKey.KEY_LEFT_SHIFT.platformKeyCode ||
key == SkikoKey.KEY_LEFT_META.platformKeyCode
)
key = key.or(0x80000000.toInt())
) {
key = key.or(0x80000000.toInt())
}
}
if (side == KEY_LOCATION_NUMPAD) {
if (key == SkikoKey.KEY_ENTER.platformKeyCode) {
......@@ -244,7 +254,96 @@ private fun toSkikoKey(event: KeyEvent): Int {
return key
}
suspend fun java.awt.Font.toSkikoTypeface(fontManager: AwtFontManager = AwtFontManager.DEFAULT): Typeface? {
val file = fontManager.findFontFile(this) ?: return null
return Typeface.makeFromData(Data.makeFromFileName(file.absolutePath))
}
\ No newline at end of file
/**
* 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 kotlinx.coroutines.*
import java.awt.Font
import java.awt.FontFormatException
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
private class FontDescriptor(val file: File, val style: Int)
class AwtFontManager(fontPaths: Array<String> = emptyArray()) {
private var fontsMap = ConcurrentHashMap<String, MutableList<FontDescriptor>>()
@Volatile
private var allFontsCachedImpl = false
private val waitChannel = RendezvousBroadcastChannel<Int>()
private var customFontPaths = mutableListOf(*fontPaths)
private var cacheJob: Job? = null
init {
invalidate()
}
private fun systemFontsPaths(): List<String> {
return when (hostOs) {
OS.Windows -> {
val winPath = System.getenv("WINDIR")
val localAppPath = System.getenv("LOCALAPPDATA")
listOf(
"$winPath\\Fonts",
"$localAppPath\\Microsoft\\Windows\\Fonts"
)
}
OS.MacOS -> {
listOf(
System.getProperty("user.home") + File.separator + "Library/Fonts",
"/Library/Fonts",
"/System/Library/Fonts"
)
}
OS.Linux -> {
val pathsToCheck = arrayOf(
System.getProperty("user.home") + File.separator + ".fonts",
"/usr/share/fonts",
"/usr/local/share/fonts",
"/usr/share/fonts/truetype",
"/usr/share/fonts/TTF"
)
val resultList = ArrayList<String>()
for (i in pathsToCheck.indices.reversed()) {
val path = pathsToCheck[i]
val tmp = File(path)
if (tmp.exists() && tmp.isDirectory && tmp.canRead()) {
resultList.add(path)
}
}
resultList
}
else -> {
throw RuntimeException("Unknown OS: $hostOs")
}
}
}
private fun isFont(extension: String): Boolean {
return extension == "ttf" ||
extension == "ttc" ||
extension == "otf"
}
private fun findFontFiles(paths: List<String>): List<File> {
val files = mutableListOf<File>()
paths.forEach { path ->
val fontDirectory = File(path)
if (fontDirectory.exists()) {
fontDirectory.walk().filter { it.isFile && isFont(it.extension.lowercase()) }.forEach {
files.add(it)
}
}
}
return files
}
private fun addFontFromFile(file: File): Boolean {
val f = try {
FileInputStream(file.absolutePath).use {
Font.createFont(Font.TRUETYPE_FONT, it)
}
} catch (e: FontFormatException){
return false
} catch (e: IOException) {
return false
}
val name = f.family
val list = fontsMap.computeIfAbsent(name) { mutableListOf() }
synchronized(list) {
list.add(FontDescriptor(file.absoluteFile, f.style))
}
return true
}
private suspend fun cacheAllFonts() {
fontsMap.clear()
val fontFiles = findFontFiles(customFontPaths) + findFontFiles(systemFontsPaths())
for (file in fontFiles) {
try {
addFontFromFile(file)
yield()
} catch (e: FontFormatException) {
} catch (e: IOException) {
}
}
}
/**
* Find font file path from an AWT font.
* As font finding is long IO-intensive process, this operation checks if given font
* is already known to the font manager.
*
* If you want stable and predictable result it's better use [findFontFile] or check [allFontsCached].
*
* @param font - AWT font for which we need to know the path
* @return path to font, if known
*/
@DelicateSkikoApi
fun findAvailableFontFile(font: Font): File? {
val list = fontsMap[font.family] ?: return null
return synchronized(list) {
list.find { it.style == font.style } ?: list.firstOrNull()
}?.file
}
/**
* Show all fonts currently known to AWT font manager. As font indexing could take time,
* may have not all elements.
*
* If you want stable and predictable result it's better use [listFontFiles] or check [allFontsCached].
*
* @return list of currently known fonts
*/
@DelicateSkikoApi
fun listAvailableFontFiles(): List<File> {
return fontsMap.values.flatMap { it.map { it.file } }.toList()
}
/**
* Show all fonts known to AWT font manager.
* @return list of known fonts
*/
suspend fun listFontFiles(): List<File> {
waitAllFontsCached()
@OptIn(DelicateSkikoApi::class)
return listAvailableFontFiles()
}
/**
* Find font file path from an AWT font.
* As font finding is long IO-intensive process, this operation may suspend for pretty long time.
* @param font - which AWT font to look for
* @return path to the font file or null, if not found
*/
suspend fun findFontFile(font: Font): File? {
waitAllFontsCached()
@OptIn(DelicateSkikoApi::class)
return findAvailableFontFile(font)
}
/**
* Find font file path from the family name.
* As font finding is long IO-intensive process, this operation may suspend for pretty long time.
* @param family - which AWT font to look for
* @return path to the font file or null, if not found
*/
suspend fun findFontFamilyFile(family: String): File? {
waitAllFontsCached()
val list = fontsMap[family] ?: return null
return synchronized(list) {
list.firstOrNull()
}?.file
}
/**
* Invalidate cache and start caching again. Maybe useful to re-read fonts
* when changed.
*/
fun invalidate() {
cacheJob?.let {
it.cancel()
}
allFontsCachedImpl = false
cacheJob = GlobalScope.launch(Dispatchers.IO) {
cacheAllFonts()
allFontsCachedImpl = true
waitChannel.sendAll(1)
}
}
/**
* Add custom directory to font search paths. Call [invalidate]
* for operation to take effect.
*/
fun addCustomPath(path: String) {
customFontPaths += path
}
/**
* Add custom resource entry as a font known to this resource manager.
*
* @return true, if font was found and identified, and false otherwise
*/
fun addResourceFont(resource: String, loader: ClassLoader = Thread.currentThread().contextClassLoader): Boolean {
val res = loader.getResourceAsStream(resource) ?: ClassLoader.getSystemResourceAsStream(resource) ?: return false
val file = File.createTempFile("tmp", ".ttf")
file.deleteOnExit()
FileOutputStream(file).use { out ->
out.write(res.readAllBytes())
}
return addFontFromFile(file).also {
if (it)
customFontPaths += file.absolutePath
else
file.delete()
}
}
/**
* If all AWT fonts were cached. Check this property before using non-suspend version
* of font conversion APIs.
*/
@DelicateSkikoApi
val allFontsCached: Boolean
get() = allFontsCachedImpl
/**
* Call continuation only when all AWT fonts are cached.
* Please avoid this API and prefer suspend operations.
*/
@DelicateSkikoApi
fun whenAllFontsCachedBlocking(continuation: () -> Unit) {
// TODO: avoid busy loop
while (!allFontsCachedImpl) {}
continuation()
}
/**
* Suspend until all AWT fonts were cached.
*/
private suspend fun waitAllFontsCached() {
if (!allFontsCachedImpl) {
waitChannel.receive()
}
}
companion object {
val DEFAULT by lazy { AwtFontManager() }
}
}
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
import org.jetbrains.skiko.tests.runTest
import org.junit.Assert.assertTrue
import org.junit.Assume
import org.junit.Test
import java.awt.Font
import java.awt.GraphicsEnvironment
class AwtFontInteropTest {
private val fontManager = AwtFontManager()
private fun assumeOk() {
Assume.assumeFalse(GraphicsEnvironment.isHeadless())
Assume.assumeTrue(hostOs != OS.Linux)
}
@OptIn(DelicateSkikoApi::class)
@Test
fun canFindAvailableFont() = fontManager.whenAllFontsCachedBlocking {
assumeOk()
val font = Font("Verdana", Font.BOLD, 12)
val path = fontManager.findAvailableFontFile(font)
assertTrue("Font must be found", path != null)
path!!
assertTrue("Font must be file", path.exists() && path.isFile)
}
@Test
fun canFindFont() {
runTest {
assumeOk()
val font = Font("Verdana", Font.BOLD, 12)
val path = fontManager.findFontFile(font)
assertTrue("Font must be found", path != null)
path!!
assertTrue("Font must be file", path.exists() && path.isFile)
}
}
@Test
fun canFindFamily() {
runTest {
assumeOk()
val path = fontManager.findFontFamilyFile("Verdana")
assertTrue("Font must be found", path != null)
path!!
assertTrue("Font must be file", path.exists() && path.isFile)
}
}
@OptIn(DelicateSkikoApi::class)
@Test
fun nonExistentFont() = fontManager.whenAllFontsCachedBlocking {
assumeOk()
val font = Font("XXXYYY745", Font.BOLD, 12)
val path = fontManager.findAvailableFontFile(font)
assertTrue("Font must not be found", path == null)
}
@Test
fun makeSkikoTypeface() {
runTest {
assumeOk()
Assume.assumeFalse(GraphicsEnvironment.isHeadless())
val font = Font("Verdana", Font.BOLD, 12)
val skikoTypeface = font.toSkikoTypeface()
assertTrue("Skiko typeface must work", skikoTypeface != null)
skikoTypeface!!
assertTrue("Skiko typeface name is incorrect: ${skikoTypeface.familyName}", skikoTypeface.familyName == "Verdana")
}
}
@Test
fun listAllFonts() {
runTest {
assumeOk()
val fontFiles = fontManager.listFontFiles()
assertTrue("There must be fonts", fontFiles.isNotEmpty())
}
}
@Test
fun addCustomPath() {
runTest {
assumeOk()
val resDir = System.getProperty("skiko.test.font.dir")!!
fontManager.addCustomPath(resDir)
fontManager.invalidate()
val path = fontManager.findFontFamilyFile("JetBrains Mono")
assertTrue("Custom font must be found", path != null)
path!!
assertTrue("Font must be file", path.exists() && path.isFile)
}
}
// This test is disabled due to convoluted setup of tests.
// @Test
fun addCustomResource() {
runTest {
assumeOk()
val fontManager = AwtFontManager()
assertTrue("Custom resource must be found",
fontManager.addResourceFont("/fonts/JetBrainsMono-Bold.ttf", Library.javaClass.classLoader))
val path = fontManager.findFontFamilyFile("JetBrains Mono")
assertTrue("Custom font must be found", path != null)
path!!
assertTrue("Font must be file", path.exists() && path.isFile)
}
}
}
\ No newline at end of file
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")
}
Copyright 2017-2019 The Libre Barcode Project Authors (https://github.com/graphicore/librebarcode)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
......@@ -15,7 +15,7 @@ import kotlin.math.ceil
class FrameLimiterTest {
private val frameCount = 8
private val frames = 0 until frameCount
@Test
fun `limit 10ms, render 0ms`() {
fun frameTicksOf(delayPrecisionMillis: Long) =
......
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