Intelligent Font Subsetting for Compose Multiplatform
Klyph is a Kotlin Multiplatform library that brings web-style font subsetting to Compose applications. It dramatically reduces font loading overhead by fetching only the specific character slices needed for your text, making it ideal for applications using large font files like Chinese, Japanese, or Korean typefaces.
- Smart Font Subsetting: Automatically loads only the font slices needed for the characters you're rendering
- CSS Integration: Parses standard CSS
@font-facerules withunicode-rangedescriptors - Automatic URL Resolution: Handles relative URLs in CSS files (e.g.,
./font.woff2,../fonts/font.woff2) - Global Caching: Font slices and CSS files are cached across the entire application session with request deduplication
- Lazy Loading: Font data is loaded on-demand as characters are rendered
- Compose-First: Integrates with Compose Multiplatform using familiar scoped APIs
- Multiplatform Support: Works on Android, iOS, JVM, JavaScript, and WebAssembly targets
Android note: ByteArray-backed fonts use android.graphics.fonts.Font on API 29+, and a temp-file fallback on
older Android versions due to platform API limitations.
Traditional font loading in Compose requires downloading entire font files, which can be problematic for:
- CJK Fonts: Chinese/Japanese/Korean fonts can be 10+ MB per weight
- Network Efficiency: Loading fonts you don't need wastes bandwidth
- Performance: Large font downloads slow down app startup
Klyph solves this by:
- Parsing CSS files that define font slices with unicode-range
- Analyzing your text to determine which characters are needed
- Loading only the relevant font slices from the cache or network
- Applying them character-by-character using AnnotatedString
For now, publish to MavenLocal to use in your projects:
./gradlew :klyph-core:publishToMavenLocal :klyph-css:publishToMavenLocalThen add the dependency to your Compose Multiplatform project:
commonMain.dependencies {
implementation("xyz.hyli:klyph-core:1.0.0")
// CSS providers, parsing, and caching
implementation("xyz.hyli:klyph-css:1.0.0")
}Use SubsetText within a SubsetFontProvider scope for automatic font subsetting:
import xyz.hyli.klyph.*
@Composable
fun MyApp() {
SubsetFontProvider(provider = CssUrlFontDescriptorProvider("https://fonts.googleapis.com/css2?family=Noto+Sans+SC")) {
// SubsetText automatically gets font descriptors from the provider - no need to pass it!
SubsetText(
text = "你好世界 Hello World",
fontSize = 20.sp
)
// Multiple SubsetText calls share the same provider
SubsetText(
text = "Another text 另一段文字",
fontSize = 16.sp,
fontWeight = FontWeight.Bold
)
}
}Key Benefits of Scoped API:
SubsetTextis a scoped function withinSubsetFontProvider- no provider parameter needed!- Type-safe: SubsetText only works within the provider scope
- Clean API: Similar to how
Row,Columnprovide scoped modifiers - Flexible: Provider interface allows CSS URL, CSS content, or custom descriptor sources
SubsetText works similarly to Material3's Text with a compatible API. It automatically:
- Analyzes each character in your text
- Loads only the necessary font slices
- Applies the correct font to each character using
AnnotatedString
You can also provide a FontDescriptorProvider directly for one-off usage:
@Composable
fun MyComponent() {
SubsetText(
text = "你好世界 Direct provider usage",
provider = CssUrlFontDescriptorProvider("https://example.com/fonts.css"),
fontSize = 16.sp
)
}SubsetText supports standard Text parameters:
@Composable
fun StyledText() {
SubsetFontProvider(provider = CssUrlFontDescriptorProvider("https://example.com/fonts.css")) {
SubsetText(
text = "粗体文字 Bold Text",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = Color.Blue,
textDecoration = TextDecoration.Underline
)
}
}Compose's FontFamily can only select one font from a provided list at a time. When you have multiple font slices (e.g., one for Latin characters, one for Chinese characters), Compose will only use one of them, leaving characters from other slices blank.
Klyph uses a provider interface pattern combined with scoped API and character-level font application:
Provider Interface:
FontDescriptorProviderinterface abstracts font descriptor sourcesCssUrlFontDescriptorProvider- loads descriptors from CSS URLCssContentFontDescriptorProvider- parses descriptors from CSS content stringStaticFontDescriptorProvider- provides a static list of pre-constructed descriptors- Extensible: create custom providers for other descriptor sources
Scoped API Benefits:
SubsetFontProvidercreates aSubsetFontScopewith the providerSubsetTextis an extension function on the scope- Provider is captured once and reused by all SubsetText calls
- Type-safe: SubsetText only available within the provider
- Clean, ergonomic API without parameter repetition
Character-Level Font Application:
Klyph's SubsetText solves the FontFamily limitation by applying fonts character-by-character:
- Provider Loading: FontDescriptorProvider supplies parsed font descriptors
- CSS Parsing: CSS providers fetch and parse
@font-facerules withunicode-rangedescriptors - URL Resolution: Relative URLs in CSS (like
url(./font.woff2)) are automatically resolved against the CSS file's base URL - Character Analysis: Analyzes each character in your text and groups consecutive characters using the same font
- Range Matching: Each character is matched against unicode-range values to determine which font slice it needs
- Lazy Loading: Only the matched font slices are fetched from the network (or cache)
- AnnotatedString Building: Builds an
AnnotatedStringwhere each character span gets its ownFontFamily( containing only one font slice) - Rendering: The standard Text composable renders the AnnotatedString with proper font assignments
- Global Caching: Once a font slice or CSS file is loaded, it's cached globally for the session with request deduplication
This approach allows "Hello 你好" to render "Hello" with the Latin font slice and "你好" with the Chinese font slice, all in the same text element.
Klyph supports all standard CSS unicode-range formats:
- Single code point:
U+26 - Range:
U+4E00-9FFF - Wildcard:
U+4??(equivalent toU+400-4FF) - Multiple ranges:
U+0-FF, U+131, U+152-153
interface FontDescriptorProvider {
suspend fun getDescriptors(): List<FontDescriptor>
}Interface for providing parsed font descriptors from various sources. Implementations include:
CssUrlFontDescriptorProvider - Loads descriptors from a CSS URL:
val provider = CssUrlFontDescriptorProvider("https://example.com/fonts.css")CssContentFontDescriptorProvider - Parses descriptors from CSS content string (with caching):
val provider = CssContentFontDescriptorProvider("""
@font-face {
font-family: 'MyFont';
src: url('font.woff2');
unicode-range: U+0-FF;
}
""", baseUrl = "https://example.com/")Note: Results are cached using a hash of the content, so repeated calls with the same CSS content won't re-parse.
StaticFontDescriptorProvider - Provides a static list of pre-constructed descriptors (useful for bundled fonts):
val descriptor = ResourceFontDescriptor(
resource = Res.font.my_font,
fontFamily = "MyFont",
weight = FontWeight.Normal,
style = FontStyle.Normal,
unicodeRanges = UnicodeRangeList()
)
val provider = StaticFontDescriptorProvider(descriptor)You can also use the CSS-string convenience constructor:
val descriptor = ResourceFontDescriptor(
resource = Res.font.my_font,
fontFamily = "MyFont",
weight = "400",
style = "normal",
unicodeRanges = "U+0-FF, U+131, U+152-153"
)@Composable
fun SubsetFontProvider(
provider: FontDescriptorProvider,
fontFamily: FontFamily? = null,
content: @Composable SubsetFontScope.() -> Unit
)Provides a FontDescriptorProvider scope for font subsetting. Creates a SubsetFontScope where the scoped SubsetText
function is available.
Parameters:
provider: The FontDescriptorProvider that supplies parsed font descriptorsfontFamily: Optional fallback FontFamily for characters not covered by subset fontscontent: Composable content within the SubsetFontScope
Usage:
SubsetFontProvider(provider = CssUrlFontDescriptorProvider("https://example.com/fonts.css")) {
// Within this scope, SubsetText doesn't need provider parameter
SubsetText(text = "Hello 世界", fontSize = 20.sp)
}@Composable
fun SubsetFontScope.SubsetText(
text: String,
modifier: Modifier = Modifier,
// ... all standard Text parameters ...
style: TextStyle = LocalTextStyle.current
): UnitA scoped function available within SubsetFontProvider that automatically loads and applies font slices character-by-character. This is the recommended way to use Klyph.
Features:
- Scoped to
SubsetFontProvider- automatically gets provider from scope - Compatible with Material3's
TextAPI for most common use cases - Automatically matches characters to font slices via unicode-range
- Builds AnnotatedString with per-character font assignments
- Supports standard Text parameters (color, fontSize, fontWeight, etc.)
- Type-safe: Only available within provider scope
@Composable
fun SubsetText(
text: String,
// ... all standard Text parameters ...
provider: FontDescriptorProvider
): UnitStandalone version that requires explicit FontDescriptorProvider. Use this for one-off cases outside of
SubsetFontProvider.
Scoped version (Recommended):
@Composable
fun SubsetFontScope.rememberSubsetAnnotatedString(
text: String,
requestedWeight: FontWeight? = null,
requestedStyle: FontStyle? = null
): AnnotatedStringStandalone version:
@Composable
fun rememberSubsetAnnotatedString(
descriptors: List<FontDescriptor>,
text: String,
requestedWeight: FontWeight? = null,
requestedStyle: FontStyle? = null
): AnnotatedStringLow-level composable that provides direct access to the AnnotatedString building process. Use this when you need more
control than SubsetText provides. The standalone version accepts a list of parsed font descriptors, allowing you to
use any descriptor source (not just CSS).
Use Cases:
- Building custom composables that use subset fonts
- Combining multiple AnnotatedStrings
- Using the result in non-Text contexts
- Applying additional styling to the AnnotatedString
Example (Scoped):
@Composable
fun CustomSubsetText() {
SubsetFontProvider(provider = CssUrlFontDescriptorProvider("https://example.com/fonts.css")) {
val annotatedString = rememberSubsetAnnotatedString(
text = "Hello 世界",
requestedWeight = FontWeight.Normal,
requestedStyle = FontStyle.Normal
)
// Use the AnnotatedString however you like
Text(
text = annotatedString,
fontFamily = myFallbackFont, // Fallback for unmapped characters
modifier = Modifier.clickable { /* custom behavior */ }
)
}
}Example (Standalone):
@Composable
fun CustomSubsetText() {
// Fetch and cache font descriptors from CSS
val descriptors by produceState(emptyList(), cssUrl) {
value = CssCache.getOrLoad("https://example.com/fonts.css")
}
val annotatedString = rememberSubsetAnnotatedString(
descriptors = descriptors,
text = "Hello 世界",
requestedWeight = FontWeight.Normal,
requestedStyle = FontStyle.Normal
)
Text(
text = annotatedString,
fontFamily = myFallbackFont
)
}suspend fun getFontCssDescription(url: String): List<FontFace>Fetches and parses CSS to extract @font-face rules. Automatically uses CssCache (from klyph-css) internally for
caching and request deduplication.
Global cache for loaded font slices with request deduplication and monitoring:
object FontSliceCache {
val descriptors: StateFlow<Map<String, FontDescriptor>> // All cached font descriptors
val receivedBytes: StateFlow<Long> // Total bytes downloaded
suspend fun getOrLoad(descriptor: FontDescriptor): FontFamily // Gets from cache or fetches
suspend fun preload(descriptors: List<FontDescriptor>) // Preloads multiple fonts
suspend fun clear() // Clears all cached fonts (suspend)
fun clearAsync() // Clears cache asynchronously (fire-and-forget)
}Request Deduplication: When multiple concurrent requests are made for the same font URL, only one network request is performed and all requests share the result.
Monitoring: Track which fonts are cached and total bandwidth usage via reactive StateFlows.
Example:
If 10 SubsetText instances all render "你好" and mount simultaneously, they will all request the same Chinese font
slice. Without deduplication, this would trigger 10 identical network requests. With deduplication, only 1 request is
made and all 10 instances share the result.
Platform note: On Android, ByteArray-backed fonts are constructed via android.graphics.fonts.Font on API 29+,
with a temp-file fallback on older API levels.
Global cache for parsed CSS files with request deduplication and monitoring:
object CssCache {
val descriptors: StateFlow<Map<String, List<FontDescriptor>>> // All cached CSS descriptors
val receivedBytes: StateFlow<Long> // Total bytes downloaded
suspend fun getOrLoad(url: String): List<FontDescriptor> // Gets from cache or fetches+parses
suspend fun clear() // Clears all cached CSS (suspend)
fun clearAsync() // Clears cache asynchronously (fire-and-forget)
}Request Deduplication: When multiple concurrent requests are made for the same CSS URL, only one network request and parse operation is performed.
Monitoring: Track which CSS files are cached and total bandwidth usage via reactive StateFlows.
Klyph works with CSS files that define font slices using unicode-range. Both absolute and relative URLs are supported:
/* Absolute URLs */
@font-face {
font-family: 'Noto Sans SC';
font-weight: 400;
src: url(https://example.com/font-latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153;
}
/* Relative URLs (automatically resolved against CSS file URL) */
@font-face {
font-family: 'Noto Sans SC';
font-weight: 400;
src: url(./fonts/font-chinese.woff2) format('woff2');
unicode-range: U+4E00-9FFF;
}
/* Parent directory relative URLs */
@font-face {
font-family: 'Noto Sans SC';
font-weight: 400;
src: url(../assets/font-japanese.woff2) format('woff2');
unicode-range: U+3040-309F;
}When you render "Hello 你好", Klyph will:
- Load
font-latin.woff2for "Hello " (U+0048, U+0065, U+006C, U+006F, U+0020) - Load
fonts/font-chinese.woff2(resolved from./fonts/font-chinese.woff2) for "你好" (U+4F60, U+597D) - Skip any other font slices
Currently supports:
- ✅ JavaScript (Browser)
- ✅ WebAssembly JavaScript (Browser)
- Reuse Text: If rendering the same text multiple times,
SubsetTextwill only compute once per unique text content - Cache Warming: Preload font descriptors for common characters:
LaunchedEffect(Unit) { val descriptors = getFontCssDescription(cssUrl) FontSliceCache.preload(descriptors.filter { /* filter common chars */ }) }
- Static Text: For static text that doesn't change, font slices are loaded once and cached
- Dynamic Text: The system efficiently handles dynamic text by only loading new slices as needed
- Scoped Provider: Use
SubsetFontProviderto wrap multipleSubsetTextinstances that share the same CSS URL for better performance - Monitor Usage: Track bandwidth and cache efficiency using StateFlows:
val fontBytes by FontSliceCache.receivedBytes.collectAsState() val cssBytes by CssCache.receivedBytes.collectAsState() Text("Downloaded: ${(fontBytes + cssBytes) / 1024} KB")
- Italic Fallback: When italic fonts aren't available, Klyph automatically falls back to normal style
// Bundles entire font file in the app (can be 10+ MB for CJK fonts)
val fontFamily = FontFamily(Font(Res.font.large_chinese_font))
Text("你好", fontFamily = fontFamily)
// For mixed text, you're limited to one font:
Text("Hello 你好", fontFamily = chineseFont) // "Hello" uses Chinese font (wrong)
Text("Hello 你好", fontFamily = latinFont) // "你好" renders blank (broken)// Loads only the font slices needed for your text
SubsetFontProvider(cssUrl = "font-subset.css") {
SubsetText("你好") // No cssUrl parameter - automatically from scope!
}
// Mixed text works - each character gets matched to its font slice:
SubsetFontProvider(cssUrl = "font-subset.css") {
SubsetText("Hello 你好") // Latin slice for "Hello", Chinese slice for "你好"
SubsetText("More text 更多文字") // Reuses already-loaded slices
}Benefits:
- Dramatically reduced font data transfer (depends on text length)
- Mixed-language text support with character-level font matching
- Character-level font precision
- Clean scoped API - no repetitive cssUrl parameters
Run the sample app to see Klyph in action:
- JavaScript:
./gradlew :sample:composeApp:jsBrowserRun - Wasm:
./gradlew :sample:composeApp:wasmJsBrowserRun
The sample demonstrates:
- Basic font subsetting with Chinese/Latin mixed text
- Multiple font weights and styles
- Large text content with performance benefits
- Real-time cache monitoring (font slice cache and CSS cache sizes)
- Run
./gradlew :klyph-core:publishToMavenLocal :klyph-css:publishToMavenLocal - Open
~/.m2/repository/xyz/hyli/
- Create an account and a namespace on Sonatype: https://central.sonatype.org/register/central-portal/#create-an-account
- Add developer id, name, email and the project url to
./klyph-core/build.gradle.ktsand./klyph-css/build.gradle.kts - Generate a GPG key:
https://getstream.io/blog/publishing-libraries-to-mavencentral-2021/#generating-a-gpg-key-pair
gpg --full-gen-key gpg --keyserver keyserver.ubuntu.com --send-keys XXXXXXXX gpg --export-secret-key XXXXXXXX > XXXXXXXX.gpg - Add these lines to
gradle.properties:signing.keyId=XXXXXXXX signing.password=[key password] signing.secretKeyRingFile=../XXXXXXXX.gpg mavenCentralUsername=[generated username] mavenCentralPassword=[generated password] - Run
./gradlew :klyph-core:publishAndReleaseToMavenCentral :klyph-css:publishAndReleaseToMavenCentral --no-configuration-cache
Copyright 2026 Klyph Contributors
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.
See LICENSE for the full license text.
Contributions are welcome! Please feel free to submit a Pull Request.