New brand mark, and both clients stop fetching their fonts at runtime #128
@@ -150,7 +150,6 @@ dependencies {
|
||||
implementation(libs.compose.ui)
|
||||
implementation(libs.compose.ui.graphics)
|
||||
implementation(libs.compose.material3)
|
||||
implementation(libs.compose.ui.text.google.fonts)
|
||||
debugImplementation(libs.compose.ui.tooling)
|
||||
implementation(libs.compose.ui.tooling.preview)
|
||||
|
||||
|
||||
@@ -2,72 +2,45 @@ package com.fabledsword.minstrel.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.googlefonts.Font
|
||||
import androidx.compose.ui.text.googlefonts.GoogleFont
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.fabledsword.minstrel.R
|
||||
|
||||
/**
|
||||
* Google Fonts provider — fetches font files via Play Services Fonts at
|
||||
* runtime, caches them across launches. Matches the Flutter client's
|
||||
* `google_fonts` package behaviour (no bundled .ttf files in either tree).
|
||||
* Bundled typefaces, vendored into res/font by tools/vendor-fonts.py.
|
||||
*
|
||||
* These were fetched at runtime through the Play Services font provider until
|
||||
* 2026-09-09. That is a network dependency for rendering, and a deployed
|
||||
* instance is not guaranteed one — the provider is also absent entirely on
|
||||
* devices without Play Services, where the app silently fell back to the
|
||||
* platform default and stopped looking like Minstrel. Bundling costs ~0.86 MB
|
||||
* of APK and removes both failure modes.
|
||||
*
|
||||
* Per FabledSword design system:
|
||||
* - Fraunces — display + headline (mythic serif)
|
||||
* - Inter — body + label (clean sans for UI text)
|
||||
* - JetBrains Mono — technical / monospace
|
||||
* Weights are restricted to 400 (regular) and 500 (medium) only.
|
||||
*
|
||||
* Each res/font entry is a single static instance, not a variable font: the
|
||||
* weight declared beside it here must match the file's own OS/2
|
||||
* usWeightClass, which the vendoring script asserts on download.
|
||||
*/
|
||||
private val GoogleFontProvider = GoogleFont.Provider(
|
||||
providerAuthority = "com.google.android.gms.fonts",
|
||||
providerPackage = "com.google.android.gms",
|
||||
certificates = R.array.com_google_android_gms_fonts_certs,
|
||||
)
|
||||
|
||||
private val FrauncesFont = GoogleFont("Fraunces")
|
||||
private val InterFont = GoogleFont("Inter")
|
||||
private val JetBrainsMonoFont = GoogleFont("JetBrains Mono")
|
||||
|
||||
private val Fraunces = FontFamily(
|
||||
Font(
|
||||
googleFont = FrauncesFont,
|
||||
fontProvider = GoogleFontProvider,
|
||||
weight = FontWeight.W400,
|
||||
style = FontStyle.Normal,
|
||||
),
|
||||
Font(
|
||||
googleFont = FrauncesFont,
|
||||
fontProvider = GoogleFontProvider,
|
||||
weight = FontWeight.W500,
|
||||
style = FontStyle.Normal,
|
||||
),
|
||||
Font(R.font.fraunces_regular, FontWeight.W400, FontStyle.Normal),
|
||||
Font(R.font.fraunces_medium, FontWeight.W500, FontStyle.Normal),
|
||||
)
|
||||
|
||||
private val Inter = FontFamily(
|
||||
Font(
|
||||
googleFont = InterFont,
|
||||
fontProvider = GoogleFontProvider,
|
||||
weight = FontWeight.W400,
|
||||
style = FontStyle.Normal,
|
||||
),
|
||||
Font(
|
||||
googleFont = InterFont,
|
||||
fontProvider = GoogleFontProvider,
|
||||
weight = FontWeight.W500,
|
||||
style = FontStyle.Normal,
|
||||
),
|
||||
Font(R.font.inter_regular, FontWeight.W400, FontStyle.Normal),
|
||||
Font(R.font.inter_medium, FontWeight.W500, FontStyle.Normal),
|
||||
)
|
||||
|
||||
private val JetBrainsMono = FontFamily(
|
||||
Font(
|
||||
googleFont = JetBrainsMonoFont,
|
||||
fontProvider = GoogleFontProvider,
|
||||
weight = FontWeight.W400,
|
||||
style = FontStyle.Normal,
|
||||
),
|
||||
Font(R.font.jetbrains_mono_regular, FontWeight.W400, FontStyle.Normal),
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,16 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Google Fonts provider certificate hashes for downloadable fonts via
|
||||
androidx.compose.ui.text.googlefonts.GoogleFont.Provider. Standard
|
||||
values published by Google; copied verbatim from the AndroidX docs. -->
|
||||
<resources>
|
||||
<array name="com_google_android_gms_fonts_certs">
|
||||
<item>@array/com_google_android_gms_fonts_certs_dev</item>
|
||||
<item>@array/com_google_android_gms_fonts_certs_prod</item>
|
||||
</array>
|
||||
<string-array name="com_google_android_gms_fonts_certs_dev">
|
||||
<item>MIIEqDCCA5CgAwIBAgIJANWFuGx90071MA0GCSqGSIb3DQEBBAUAMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTAeFw0wODA0MTUyMzM2NTZaFw0zNTA5MDEyMzM2NTZaMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBANbOLggKv+IxTdGNs8/TGFy0PTP6DHThvbbR24kT9ixcOd9W+EaBPWW+wPPKQmsHxajtWjmQwWfna8mZuSeJS48LIgAZlKkpoyLcfobBPv6yyz8x1IxWWmF9c1IGN3vSL6BLNJEUyMEPzC2WZdwT4ZG2cuJTtzeETl6jWFKx68ETtZxNVHe9Iy9NMxEljDqVZ4y6+FlHaiYJqq3LcJpJVuKYz4kvOcyf3M0nDA8mUlVdfsOlw/H4uoNQ7VrAQUKB4kAyfxsKp/RZmnZSJ7+8Ag9aTC+oguTd1iFNuMqDUlpePo6CGuh73iKuq8mYvtdQQ0Yz+mF4j2YWB7Gj0R1k2cCAQOjgfwwgfkwHQYDVR0OBBYEFI0cxb6VTEM8YYY6FbBMvAPyT+CyMIHJBgNVHSMEgcEwgb6AFI0cxb6VTEM8YYY6FbBMvAPyT+CyoYGapIGXMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbYIJANWFuGx90071MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADggEBABnTDPEF+3iSP0wNfdIjIz1AlnrPzgAIHVvXxunW7SBrDhEglQZBbKJEk5kT0mtKoOD1JMrSu1xuTKEBahWRbqHsXclaXjoBADb0kkjVEJu/Lh5hgYZnOjvlba8Ld7HCKePCVePoTJBdI4fvugnL8TsgK05aIskyY0hKI9L8KfqfGTl1lzOv2KoWD0KWwtAWPoGChZxmQ+nBli+gwYMzM1vAkP+aayLe0a1EQimlOalO762r0GXO0ks+UeXde2Z4e+8S/pf7pITEI/tP+MxJTALw9QUWEv9lKTk+jkbqxbsh8nfBUapfKqYn0eidpwq2AzVp3juYl7//fKnaPhJD9gs=</item>
|
||||
</string-array>
|
||||
<string-array name="com_google_android_gms_fonts_certs_prod">
|
||||
<item>MIIEQzCCAyugAwIBAgIJAMLgh0ZkSjCNMA0GCSqGSIb3DQEBBAUAMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDAeFw0wODA4MjEyMzEzMzRaFw0zNjAxMDcyMzEzMzRaMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKtWLgDYO6IIrgqWbxJOKdoR8qtW0I9Y4sypEwPpt1TTcvZApxsdyxMJZ2JORland2qSGT2y5b+3JKkedxiLDmpHpDsz2WCbdxgxRczfey5YZnTJ4VZbH0xqWVW/8lGmPav5xVwnIiJS6HXk+BVKZF+JcWjAsb/GEuq/eFdpuzSqeYTcfi6idkyugwfYwXFU1+5fZKUaRKYCwkkFQVfcAs1fXA5V+++FGfvjJ/CxURaSxaBvGdGDhfXE28LWuT9ozCl5xw4Yq5OGazvV24mZVSoOO0yZ31j7kYvtwYK6NeADwbSxDdJEqO4k//0zOHKrUiGYXtqw/A0LFFtqoZKFjnkCAwEAAaOB1zCB1DAdBgNVHQ4EFgQUhzkS9E6G+x8U7eIYZVgWyN4j2u4wgaQGA1UdIwSBnDCBmYAUhzkS9E6G+x8U7eIYZVgWyN4j2u6heKR2MHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZIIJAMLgh0ZkSjCNMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADggEBABywqUAtNkXf2EVQuRGiI3pnNvIYx7N5xj4LMtloEdEqMpEcMa6Qe87qDx2hsArOR1nzQAFGsT/8YIIfX0fAJjQuP1lAcExSxVKbFICEvFBaWuhGgOOZ7CYzfHB6tEzJFLR2DQHQrXLT2HKDDhxhe9hKzqIRDSc5Hjr3jY5MMzfYM5lFvKK9pLqEsP6/Ad9SDhupcVoOWVrSCNKfRb6jpJbZuxJhCnq8tmlV4iy5tEW0a3VBYzpRoBdAaORWqHQTUlt+iL3aH7C5OxhgN/JuxvxXBL/3kkc0wK1ZNuk+sb4lNXmHnVqQYTcyowQHRPCRsPzCCl4ANULRpZjxAd0xUgg=</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
@@ -53,7 +53,6 @@ compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" }
|
||||
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
|
||||
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
|
||||
compose-material3 = { module = "androidx.compose.material3:material3" }
|
||||
compose-ui-text-google-fonts = { module = "androidx.compose.ui:ui-text-google-fonts" }
|
||||
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
|
||||
hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
|
||||
room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
|
||||
|
||||
+81
-5
@@ -1,5 +1,9 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Download the web fonts from Google Fonts and vendor them into the repo.
|
||||
"""Download the fonts from Google Fonts and vendor them into the repo.
|
||||
|
||||
Covers BOTH clients — the web bundle (woff2, subsetted) and the Android app
|
||||
(static ttf, whole-font) — because they draw from the same three families and
|
||||
letting them drift is how one of them quietly stops matching the other.
|
||||
|
||||
Run by hand when the font set changes, never at build or run time:
|
||||
|
||||
@@ -22,12 +26,14 @@ request bytes, and a library full of non-English artist names renders instead
|
||||
of falling back mid-list.
|
||||
"""
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
OUT = ROOT / "web/static/fonts"
|
||||
ANDROID_OUT = ROOT / "android/app/src/main/res/font"
|
||||
|
||||
# Matches the faces the design system actually permits: two weights only, 400
|
||||
# and 500 (never 600/700), and Fraunces' optical-size axis across its range.
|
||||
@@ -38,18 +44,86 @@ FAMILIES = (
|
||||
)
|
||||
CSS_URL = f"https://fonts.googleapis.com/css2?{FAMILIES}&display=swap"
|
||||
|
||||
# A modern browser UA is required, not cosmetic: Google serves ancient TTF to
|
||||
# unrecognised clients and woff2 only to browsers known to support it.
|
||||
# The UA is load-bearing, not cosmetic: Google Fonts serves a different FORMAT
|
||||
# per client, and there is no parameter to ask for one directly.
|
||||
# modern Chrome -> woff2 (what the browser wants)
|
||||
# old Android -> ttf (what Android's res/font requires)
|
||||
# MSIE 6 -> eot (an IE-only format; the obvious "old UA" choice
|
||||
# and completely useless here — it downloads and
|
||||
# looks plausible until you check the magic bytes)
|
||||
UA = ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36")
|
||||
UA_TTF = ("Mozilla/5.0 (Linux; U; Android 4.4.2; en-us) AppleWebKit/534.30 "
|
||||
"(KHTML, like Gecko) Version/4.0 Mobile Safari/534.30")
|
||||
|
||||
# Android res/font resource names: lowercase, digits and underscore only.
|
||||
# Each entry is one static weight — Compose selects by the FontWeight declared
|
||||
# alongside it in Typography.kt, so the file must genuinely be that instance.
|
||||
# css2 collapses a multi-weight request to 400 for legacy clients, so each
|
||||
# weight is fetched on its own URL.
|
||||
ANDROID_FACES = [
|
||||
("fraunces_regular", "Fraunces:opsz,wght@9..144,400", 400),
|
||||
("fraunces_medium", "Fraunces:opsz,wght@9..144,500", 500),
|
||||
("inter_regular", "Inter:wght@400", 400),
|
||||
("inter_medium", "Inter:wght@500", 500),
|
||||
("jetbrains_mono_regular", "JetBrains+Mono:wght@400", 400),
|
||||
]
|
||||
|
||||
TTF_MAGIC = (b"\x00\x01\x00\x00", b"true", b"OTTO")
|
||||
|
||||
|
||||
def fetch(url, timeout=30):
|
||||
req = urllib.request.Request(url, headers={"User-Agent": UA})
|
||||
def weight_class(data):
|
||||
"""Read OS/2 usWeightClass out of a TrueType file.
|
||||
|
||||
Worth the twenty lines: css2 silently collapses a multi-weight request to
|
||||
400 for legacy clients, so asking for Medium and getting Regular is a real
|
||||
and quiet failure. The file downloads, has valid TrueType magic, and
|
||||
renders — just at the wrong weight, everywhere, forever. This is the only
|
||||
field that actually distinguishes them.
|
||||
"""
|
||||
count = struct.unpack(">H", data[4:6])[0]
|
||||
for i in range(count):
|
||||
off = 12 + i * 16
|
||||
if data[off:off + 4] == b"OS/2":
|
||||
table = struct.unpack(">I", data[off + 8:off + 12])[0]
|
||||
return struct.unpack(">H", data[table + 4:table + 6])[0]
|
||||
return None
|
||||
|
||||
|
||||
def fetch(url, timeout=30, ttf=False):
|
||||
req = urllib.request.Request(url, headers={"User-Agent": UA_TTF if ttf else UA})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
return r.read()
|
||||
|
||||
|
||||
def vendor_android():
|
||||
"""Fetch static TTFs for the Android client's res/font."""
|
||||
ANDROID_OUT.mkdir(parents=True, exist_ok=True)
|
||||
for old in ANDROID_OUT.glob("*.ttf"):
|
||||
old.unlink()
|
||||
total = 0
|
||||
for name, spec, want_weight in ANDROID_FACES:
|
||||
css = fetch(f"https://fonts.googleapis.com/css2?family={spec}", ttf=True).decode()
|
||||
url = re.search(r"url\((https://[^)]+)\)", css)
|
||||
if not url:
|
||||
sys.exit(f"no font url for {spec}")
|
||||
data = fetch(url.group(1), ttf=True)
|
||||
# Assert the FORMAT, because every wrong one still downloads happily
|
||||
# and only fails later, on a device, as a silently missing typeface.
|
||||
if not data.startswith(TTF_MAGIC):
|
||||
sys.exit(f"{name}: expected TrueType, got magic {data[:4].hex()} "
|
||||
f"(eot/woff means the UA negotiation broke)")
|
||||
got = weight_class(data)
|
||||
if got != want_weight:
|
||||
sys.exit(f"{name}: wanted weight {want_weight}, file reports {got} "
|
||||
f"(css2 collapsed the request to a single weight)")
|
||||
(ANDROID_OUT / f"{name}.ttf").write_bytes(data)
|
||||
total += len(data)
|
||||
print(f" {name + '.ttf':<30} {len(data)/1024:7.1f} KB weight {got}")
|
||||
print(f"{len(ANDROID_FACES)} ttf, {total/1024/1024:.2f} MB total -> "
|
||||
f"{ANDROID_OUT.relative_to(ROOT)}")
|
||||
|
||||
|
||||
def main():
|
||||
css = fetch(CSS_URL).decode()
|
||||
|
||||
@@ -90,6 +164,8 @@ def main():
|
||||
for n, size in sorted(seen.items()):
|
||||
print(f" {n:<44} {size/1024:7.1f} KB")
|
||||
print(f"{len(seen)} files, {total/1024/1024:.2f} MB total -> {OUT.relative_to(ROOT)}")
|
||||
print()
|
||||
vendor_android()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user