FontMatcher.kt

/*
 * Copyright 2018 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package androidx.compose.ui.text.font

import androidx.compose.ui.text.fastFilter

/**
 * Given a [FontFamily], [FontWeight] and [FontStyle], matches the best font in the [FontFamily]
 * that satisfies the requirements of [FontWeight] and [FontStyle].
 *
 * For the case without font synthesis, applies the rules at
 * [CSS 4 Font Matching](https://www.w3.org/TR/css-fonts-4/#font-style-matching).
 */
internal class FontMatcher {

    /**
     * Given a [FontFamily], [FontWeight] and [FontStyle], matches the best font in the
     * [FontFamily] that satisfies the requirements of [FontWeight] and [FontStyle]. If there is
     * not a font that exactly satisfies the given constraints of [FontWeight] and [FontStyle], the
     * best match will be returned. The rules for the best match are defined in
     * [CSS 4 Font Matching](https://www.w3.org/TR/css-fonts-4/#font-style-matching).
     *
     * If no fonts match, an empty list is returned.
     *
     * @param fontList iterable of fonts to choose the [Font] from
     * @param fontWeight desired [FontWeight]
     * @param fontStyle desired [FontStyle]
     */
    fun matchFont(
        fontList: List<Font>,
        fontWeight: FontWeight,
        fontStyle: FontStyle
    ): List<Font> {
        // check for exact match first
        fontList.fastFilter { it.weight == fontWeight && it.style == fontStyle }.let {
            // TODO(b/130797349): IR compiler bug was here
            if (it.isNotEmpty()) {
                return it
            }
        }

        // if no exact match, filter with style
        val fontsToSearch = fontList.fastFilter { it.style == fontStyle }.ifEmpty { fontList }

        val result = when {
            fontWeight < FontWeight.W400 -> {
                // If the desired weight is less than 400
                // - weights less than or equal to the desired weight are checked in descending order
                // - followed by weights above the desired weight in ascending order

                fontsToSearch.filterByClosestWeight(fontWeight, preferBelow = true)
            }
            fontWeight > FontWeight.W500 -> {
                // If the desired weight is greater than 500
                // - weights greater than or equal to the desired weight are checked in ascending order
                // - followed by weights below the desired weight in descending order
                fontsToSearch.filterByClosestWeight(fontWeight, preferBelow = false)
            }
            else -> {
                // If the desired weight is inclusively between 400 and 500
                // - weights greater than or equal to the target weight are checked in ascending order
                // until 500 is hit and checked,
                // - followed by weights less than the target weight in descending order,
                // - followed by weights greater than 500
                fontsToSearch
                    .filterByClosestWeight(
                        fontWeight,
                        preferBelow = false,
                        minSearchRange = null,
                        maxSearchRange = FontWeight.W500
                    )
                    .ifEmpty {
                        fontsToSearch.filterByClosestWeight(
                            fontWeight,
                            preferBelow = false,
                            minSearchRange = FontWeight.W500,
                            maxSearchRange = null
                        )
                    }
            }
        }

        return result
    }

    @Suppress("NOTHING_TO_INLINE")
    // @VisibleForTesting
    internal inline fun List<Font>.filterByClosestWeight(
        fontWeight: FontWeight,
        preferBelow: Boolean,
        minSearchRange: FontWeight? = null,
        maxSearchRange: FontWeight? = null,
    ): List<Font> {
        var bestWeightAbove: FontWeight? = null
        var bestWeightBelow: FontWeight? = null
        for (index in indices) {
            val font = get(index)
            val possibleWeight = font.weight
            if (minSearchRange != null && possibleWeight < minSearchRange) { continue }
            if (maxSearchRange != null && possibleWeight > maxSearchRange) { continue }
            if (possibleWeight < fontWeight) {
                if (bestWeightBelow == null || possibleWeight > bestWeightBelow) {
                    bestWeightBelow = possibleWeight
                }
            } else if (possibleWeight > fontWeight) {
                if (bestWeightAbove == null || possibleWeight < bestWeightAbove) {
                    bestWeightAbove = possibleWeight
                }
            } else {
                // exact weight match, we can exit now
                bestWeightAbove = possibleWeight
                bestWeightBelow = possibleWeight
                break
            }
        }
        val bestWeight = if (preferBelow) {
            bestWeightBelow ?: bestWeightAbove
        } else {
            bestWeightAbove ?: bestWeightBelow
        }
        return fastFilter { it.weight == bestWeight }
    }

    /**
     * @see matchFont
     */
    fun matchFont(
        fontFamily: FontFamily,
        fontWeight: FontWeight,
        fontStyle: FontStyle
    ): List<Font> {
        if (fontFamily !is FontListFontFamily) throw IllegalArgumentException(
            "Only FontFamily instances that presents a list of Fonts can be used"
        )

        return matchFont(fontFamily, fontWeight, fontStyle)
    }

    /**
     * Required to disambiguate matchFont(fontListFontFamilyInstance).
     *
     * @see matchFont
     */
    fun matchFont(
        fontFamily: FontListFontFamily,
        fontWeight: FontWeight,
        fontStyle: FontStyle
    ): List<Font> {
        return matchFont(fontFamily.fonts, fontWeight, fontStyle)
    }
}