Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
[versions]
agp = "9.2.1"
kotlin = "2.3.21"
kotlin = "2.4.0"
android-compileSdk = "37"
android-minSdk = "26"
android-targetSdk = "37"
app-version-name = "2.0.0"
app-version-code = "57"
app-version-code = "58"
androidx-activity = "1.13.0"
androidx-lifecycle = "2.10.0"
composeHotReload = "1.1.1"
composeMultiplatform = "1.11.0"
composeMultiplatform = "1.11.1"
kotlinx-coroutines = "1.11.0"
kotlinx-serialization-json = "1.11.0"
ktor = "3.5.0"
Expand Down Expand Up @@ -38,7 +38,7 @@ gogs-client = "2.0.4"
resource-container = "1.0.2"
resource-catalog-client = "1.0.3"

mockk = "1.14.9"
mockk = "1.14.11"
junit = "4.13.2"
mockwebserver = "5.3.2"

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package org.bibletranslationtools.writer.core

import android.graphics.fonts.SystemFonts
import android.os.Build
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import org.bibletranslationtools.logger.Logger
import org.bibletranslationtools.writer.DirectoryProvider
import java.io.File

/**
* Lists fonts installed on the Android system plus user-imported fonts in
* [DirectoryProvider.fontsDir]. Uses [SystemFonts.getAvailableFonts] on API 29+,
* falling back to scanning `/system/fonts` on older releases.
*/
class AndroidSystemFontProvider(
private val directoryProvider: DirectoryProvider
) : SystemFontProvider {

override fun listSystemFonts(): List<SystemFont> {
return runCatching {
val systemFiles = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
SystemFonts.getAvailableFonts().mapNotNull { it.file }
} else {
File("/system/fonts").listFiles()?.toList() ?: emptyList()
}
val importedFiles = directoryProvider.fontsDir.listFiles()?.toList() ?: emptyList()
(systemFiles + importedFiles)
.filter { it.extension.lowercase() in FONT_EXTENSIONS }
.distinctBy { it.absolutePath }
.map { SystemFont(displayName = displayName(it), path = it.absolutePath) }
.sortedBy { it.displayName }
}.getOrElse { e ->
Logger.w(TAG, "Failed to list system fonts", e as? Exception)
emptyList()
}
}

override fun loadFontFamily(path: String): FontFamily? = runCatching {
val file = File(path)
if (!file.exists()) null else FontFamily(Font(file))
}.getOrElse { e ->
Logger.w(TAG, "Failed to load font $path", e as? Exception)
null
}

/** Real font name from the file's `name` table, falling back to a prettified filename. */
private fun displayName(file: File): String =
runCatching { FontNameReader.readDisplayName(file.readBytes()) }.getOrNull()
?: prettifyName(file.nameWithoutExtension)

private fun prettifyName(raw: String): String =
raw.replace('_', ' ').replace('-', ' ').trim()

companion object {
private const val TAG = "AndroidSystemFontProvider"
private val FONT_EXTENSIONS = setOf("ttf", "otf")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import org.bibletranslationtools.writer.AndroidDirectoryProvider
import org.bibletranslationtools.writer.AndroidPlatform
import org.bibletranslationtools.writer.DirectoryProvider
import org.bibletranslationtools.writer.Platform
import org.bibletranslationtools.writer.core.AndroidSystemFontProvider
import org.bibletranslationtools.writer.core.BackupScheduler
import org.bibletranslationtools.writer.core.SystemFontProvider
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.bind
import org.koin.dsl.module
Expand All @@ -22,6 +24,7 @@ actual val platformModule = module {
singleOf(::AndroidPlatform).bind<Platform>()
singleOf(::AndroidDirectoryProvider).bind<DirectoryProvider>()
singleOf(::AndroidBackupScheduler).bind<BackupScheduler>()
singleOf(::AndroidSystemFontProvider).bind<SystemFontProvider>()

@OptIn(ExperimentalSettingsApi::class, ExperimentalSettingsImplementation::class)
single<ObservableSettings> {
Expand Down
4 changes: 4 additions & 0 deletions shared/src/commonMain/composeResources/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -779,6 +779,10 @@ Do you want to enable SD card access? If so then:
<string name="success_translation_update">Your translation has been successfully updated.</string>
<string name="success_translation_update_with_conflicts">Your translation has been successfully updated.\nSome chunks had conflicts and need review.</string>
<string name="search_hint">Enter Search String</string>
<string name="title_add_custom_font">Add custom font</string>
<string name="summary_add_custom_font">Import a .ttf or .otf font file</string>
<string name="font_imported_title">Font imported</string>
<string name="font_imported_message">"%1$s" was imported. You can now select it as your source or target font.</string>
<string name="search_source">Search Source:</string>
<string name="search_translation">Search Translation:</string>
<string name="tip_export_to_usfm">Creates a .usfm file of your project</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package org.bibletranslationtools.writer
import btt_writer.shared.generated.resources.Res
import btt_writer.shared.generated.resources.keys_dir
import io.github.vinceglb.filekit.PlatformFile
import io.github.vinceglb.filekit.name
import io.github.vinceglb.filekit.sink
import io.github.vinceglb.filekit.source
import kotlinx.coroutines.Dispatchers
Expand Down Expand Up @@ -81,6 +82,15 @@ interface DirectoryProvider {
mkdirs()
}

/**
* The directory where user-imported custom fonts are stored. Scanned by the
* system font providers so imported fonts appear in the font picker.
*/
val fontsDir: File
get() = File(externalAppDir, "fonts").apply {
mkdirs()
}

/**
* Returns the sharing directory
* @return
Expand Down Expand Up @@ -251,6 +261,25 @@ interface DirectoryProvider {
}
}

/**
* Copies a user-picked file into [dest] dir and returns the stored file.
*/
suspend fun copyFile(file: PlatformFile, dest: File): File {
if (!dest.isDirectory) {
throw IllegalArgumentException("Dest should be a directory.")
}

return withContext(Dispatchers.IO) {
val target = File(dest, file.name)
file.inputStream().use { input ->
target.outputStream().use { output ->
input.copyTo(output)
}
}
target
}
}

/**
* Clear the cache directory
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package org.bibletranslationtools.writer.core

/**
* Reads the human-readable font name from the sfnt `name` table of a TrueType/OpenType
* font. Returns the full font name (nameID 4) when present, otherwise the family name
* (nameID 1), or null when the bytes aren't a parseable sfnt font.
*
* See https://learn.microsoft.com/typography/opentype/spec/name
*/
object FontNameReader {

private const val NAME_ID_FAMILY = 1
private const val NAME_ID_FULL = 4

fun readDisplayName(bytes: ByteArray): String? {
return runCatching { parse(bytes) }.getOrNull()
}

private fun parse(b: ByteArray): String? {
if (b.size < 12) return null

val numTables = u16(b, 4)
var recordOffset = 12
var nameTableOffset = -1
repeat(numTables) {
if (recordOffset + 16 > b.size) return null
val tag = b.decodeToString(recordOffset, recordOffset + 4)
if (tag == "name") {
nameTableOffset = u32(b, recordOffset + 8)
}
recordOffset += 16
}
if (nameTableOffset < 0 || nameTableOffset + 6 > b.size) return null

val count = u16(b, nameTableOffset + 2)
val stringStorage = nameTableOffset + u16(b, nameTableOffset + 4)
val firstRecord = nameTableOffset + 6

// Track best candidate; prefer full name (4) over family (1).
var family: String? = null
var full: String? = null

repeat(count) { i ->
val rec = firstRecord + i * 12
if (rec + 12 > b.size) return@repeat
val platformId = u16(b, rec)
val nameId = u16(b, rec + 6)
if (nameId != NAME_ID_FAMILY && nameId != NAME_ID_FULL) return@repeat

val length = u16(b, rec + 8)
val offset = stringStorage + u16(b, rec + 10)
if (offset + length > b.size) return@repeat

val value = decode(b, offset, length, platformId) ?: return@repeat
when (nameId) {
NAME_ID_FULL -> if (full == null) full = value
NAME_ID_FAMILY -> if (family == null) family = value
}
}
return (full ?: family)?.trim()?.takeIf { it.isNotEmpty() }
}

private fun decode(b: ByteArray, offset: Int, length: Int, platformId: Int): String? {
val slice = b.copyOfRange(offset, offset + length)
return when (platformId) {
// Windows (3) and Unicode (0) store UTF-16BE.
0, 3 -> if (length % 2 != 0) null else buildString {
var i = 0
while (i < slice.size) {
val code = ((slice[i].toInt() and 0xFF) shl 8) or (slice[i + 1].toInt() and 0xFF)
append(code.toChar())
i += 2
}
}
// Macintosh (1) Roman — treat as Latin-1/ASCII.
else -> buildString {
for (byte in slice) append((byte.toInt() and 0xFF).toChar())
}
}
}

private fun u16(b: ByteArray, i: Int): Int =
((b[i].toInt() and 0xFF) shl 8) or (b[i + 1].toInt() and 0xFF)

private fun u32(b: ByteArray, i: Int): Int =
((b[i].toInt() and 0xFF) shl 24) or
((b[i + 1].toInt() and 0xFF) shl 16) or
((b[i + 2].toInt() and 0xFF) shl 8) or
(b[i + 3].toInt() and 0xFF)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package org.bibletranslationtools.writer.core

import androidx.compose.ui.text.font.FontFamily

/**
* A font installed on the host operating system.
*
* @param displayName human-readable name shown in the settings picker.
* @param path absolute path to the font file; also the value persisted in preferences.
*/
data class SystemFont(
val displayName: String,
val path: String
)

/**
* Discovers and loads fonts installed on the host operating system.
*
* Implemented per platform (Android scans [android.graphics.fonts.SystemFonts],
* desktop scans the OS font directories) and provided through DI.
*/
interface SystemFontProvider {

/**
* Lists fonts installed on the system, sorted by [SystemFont.displayName].
* Returns an empty list when scanning fails or no fonts are found.
*/
fun listSystemFonts(): List<SystemFont>

/**
* Loads the font at [path] into a [FontFamily], or returns null when the
* file is missing or cannot be parsed.
*/
fun loadFontFamily(path: String): FontFamily?
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import btt_writer.shared.generated.resources.scheherazade_r
import btt_writer.shared.generated.resources.snr
import btt_writer.shared.generated.resources.tai_heritage_pro_r
import btt_writer.shared.generated.resources.tuladha_jejeg_gr
import androidx.compose.ui.text.font.FontFamily
import org.bibletranslationtools.writer.data.Preference
import org.bibletranslationtools.writer.data.getPref
import org.jetbrains.compose.resources.FontResource
Expand All @@ -42,7 +43,10 @@ enum class TextStyleType(val sizeMultiplier: Float) {
/**
* Created by mxaln on 2/25/2026.
*/
class Typography(private val preference: Preference) {
class Typography(
private val preference: Preference,
private val systemFontProvider: SystemFontProvider
) {

private lateinit var defaultFontName: String
private lateinit var defaultFontSize: String
Expand Down Expand Up @@ -96,7 +100,9 @@ class Typography(private val preference: Preference) {
val fontName = languageSubstituteFonts[languageCode] ?: getFontName(translationType)

return TextFormatConfig(
fontAssetPath = "fonts/$fontName",
// Bundled keys are bare filenames; system fonts are absolute paths. Keep as-is so
// resolveSystemFontFamily receives the real path (resolveFontResource strips any prefix).
fontAssetPath = fontName,
fontSizeSp = baseFontSize * style.sizeMultiplier,
isBold = style == TextStyleType.TITLE,
directionString = direction
Expand All @@ -117,9 +123,20 @@ class Typography(private val preference: Preference) {
}

fun getFontNames(): List<String> {
return fontResources.keys.toList()
return fontResources.keys.toList() + getSystemFonts().map { it.path }
}

/** Fonts installed on the host OS, discovered through [SystemFontProvider]. */
fun getSystemFonts(): List<SystemFont> = systemFontProvider.listSystemFonts()

/** True when [fontKey] refers to a font bundled with the app (not a system font). */
fun isBundledFont(fontKey: String): Boolean =
fontResources.containsKey(fontKey.substringAfterLast('/').lowercase())

/** Loads a system font file into a [FontFamily], or null if unavailable. */
fun resolveSystemFontFamily(path: String): FontFamily? =
systemFontProvider.loadFontFamily(path)

private fun getFontName(translationType: TranslationType): String {
val prefKey = if (translationType == TranslationType.SOURCE) {
Preference.KEY_PREF_SOURCE_TYPEFACE
Expand Down
Loading
Loading