New brand mark, and both clients stop fetching their fonts at runtime #128
@@ -0,0 +1,125 @@
|
|||||||
|
package com.fabledsword.minstrel.theme
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guards that the typefaces ship inside the APK instead of being fetched at
|
||||||
|
* runtime.
|
||||||
|
*
|
||||||
|
* Until 2026-09-09 these were resolved through the Play Services font
|
||||||
|
* provider. That needs a network the deployed app is not guaranteed, and a
|
||||||
|
* provider that devices without Play Services do not have at all. Both
|
||||||
|
* failures are silent — text just renders in the platform default, which
|
||||||
|
* reads as a styling regression rather than a missing dependency.
|
||||||
|
*
|
||||||
|
* Expectations are read out of Typography.kt itself rather than hardcoded, so
|
||||||
|
* this cannot drift away from what the app actually declares: adding a face
|
||||||
|
* without vendoring its file fails here, and so does changing a declared
|
||||||
|
* weight without refetching the matching static instance.
|
||||||
|
*/
|
||||||
|
class BundledFontsTest {
|
||||||
|
@Test
|
||||||
|
fun `typography builds its families from bundled resources`() {
|
||||||
|
val source = typographySource()
|
||||||
|
assertTrue(
|
||||||
|
source.contains("R.font."),
|
||||||
|
"Typography.kt should build its families from res/font resources",
|
||||||
|
)
|
||||||
|
FORBIDDEN.forEach { symbol ->
|
||||||
|
assertTrue(
|
||||||
|
!source.contains(symbol),
|
||||||
|
"Typography.kt must not reference $symbol — fonts are bundled, not fetched",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every declared face is vendored as TrueType at its declared weight`() {
|
||||||
|
val declared = FACE_PATTERN.findAll(typographySource()).toList()
|
||||||
|
assertTrue(
|
||||||
|
declared.isNotEmpty(),
|
||||||
|
"no Font(R.font.…, FontWeight.W…) declarations found — the guard would pass vacuously",
|
||||||
|
)
|
||||||
|
|
||||||
|
declared.forEach { match ->
|
||||||
|
val (name, weight) = match.destructured
|
||||||
|
val file = File(appDir(), "src/main/res/font/$name.ttf")
|
||||||
|
assertTrue(file.isFile, "res/font/$name.ttf is missing — run tools/vendor-fonts.py")
|
||||||
|
|
||||||
|
val bytes = file.readBytes()
|
||||||
|
assertTrue(
|
||||||
|
bytes.copyOfRange(0, TTF_MAGIC.size).contentEquals(TTF_MAGIC),
|
||||||
|
"$name.ttf is not TrueType — res/font cannot load a woff2 or an eot",
|
||||||
|
)
|
||||||
|
// The decisive check. Google's css2 endpoint silently collapses a
|
||||||
|
// multi-weight request to 400 for legacy clients, so Medium can
|
||||||
|
// come back as Regular: a valid TrueType file that renders at the
|
||||||
|
// wrong weight everywhere. usWeightClass is the only field that
|
||||||
|
// tells the two apart.
|
||||||
|
assertEquals(
|
||||||
|
weight.toInt(),
|
||||||
|
weightClass(bytes),
|
||||||
|
"$name.ttf carries a different OS/2 usWeightClass than the FontWeight declared beside it",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Typography.kt with comments removed, so prose naming the forbidden
|
||||||
|
* symbols cannot satisfy — or trip — the absence check above. */
|
||||||
|
private fun typographySource(): String =
|
||||||
|
File(appDir(), TYPOGRAPHY)
|
||||||
|
.readText()
|
||||||
|
.replace(BLOCK_COMMENT, "")
|
||||||
|
.replace(LINE_COMMENT, "")
|
||||||
|
|
||||||
|
/** Gradle's working directory for tests is the module dir, but don't rely
|
||||||
|
* on it: walk up until the module is found, and say so if it isn't. */
|
||||||
|
private fun appDir(): File {
|
||||||
|
var dir: File? = File("").absoluteFile
|
||||||
|
while (dir != null) {
|
||||||
|
if (File(dir, TYPOGRAPHY).isFile) return dir
|
||||||
|
if (File(dir, "app/$TYPOGRAPHY").isFile) return File(dir, "app")
|
||||||
|
dir = dir.parentFile
|
||||||
|
}
|
||||||
|
error("could not locate the app module from ${File("").absolutePath}")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun weightClass(bytes: ByteArray): Int {
|
||||||
|
val tables = readU16(bytes, NUM_TABLES)
|
||||||
|
for (i in 0 until tables) {
|
||||||
|
val record = TABLE_DIRECTORY + i * TABLE_RECORD
|
||||||
|
if (String(bytes, record, TAG_LENGTH, Charsets.US_ASCII) == "OS/2") {
|
||||||
|
return readU16(bytes, readU32(bytes, record + OFFSET_FIELD) + WEIGHT_FIELD)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
error("no OS/2 table in the font")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readU16(bytes: ByteArray, at: Int): Int =
|
||||||
|
((bytes[at].toInt() and BYTE_MASK) shl Byte.SIZE_BITS) or (bytes[at + 1].toInt() and BYTE_MASK)
|
||||||
|
|
||||||
|
private fun readU32(bytes: ByteArray, at: Int): Int =
|
||||||
|
(readU16(bytes, at) shl Short.SIZE_BITS) or readU16(bytes, at + 2)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TYPOGRAPHY = "src/main/java/com/fabledsword/minstrel/theme/Typography.kt"
|
||||||
|
|
||||||
|
val FORBIDDEN = listOf("GoogleFont", "googlefonts")
|
||||||
|
val FACE_PATTERN = Regex("""R\.font\.(\w+)\s*,\s*FontWeight\.W(\d+)""")
|
||||||
|
val BLOCK_COMMENT = Regex("""/\*[\s\S]*?\*/""")
|
||||||
|
val LINE_COMMENT = Regex("""//.*""")
|
||||||
|
val TTF_MAGIC = byteArrayOf(0x00, 0x01, 0x00, 0x00)
|
||||||
|
|
||||||
|
// Offsets into the TrueType table directory, per the OpenType spec.
|
||||||
|
const val NUM_TABLES = 4
|
||||||
|
const val TABLE_DIRECTORY = 12
|
||||||
|
const val TABLE_RECORD = 16
|
||||||
|
const val TAG_LENGTH = 4
|
||||||
|
const val OFFSET_FIELD = 8
|
||||||
|
const val WEIGHT_FIELD = 4
|
||||||
|
const val BYTE_MASK = 0xFF
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user