diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9381769..2f70bad 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" @@ -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" diff --git a/shared/src/androidMain/kotlin/org/bibletranslationtools/writer/core/AndroidSystemFontProvider.kt b/shared/src/androidMain/kotlin/org/bibletranslationtools/writer/core/AndroidSystemFontProvider.kt new file mode 100644 index 0000000..7fe2319 --- /dev/null +++ b/shared/src/androidMain/kotlin/org/bibletranslationtools/writer/core/AndroidSystemFontProvider.kt @@ -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 { + 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") + } +} diff --git a/shared/src/androidMain/kotlin/org/bibletranslationtools/writer/di/Modules.android.kt b/shared/src/androidMain/kotlin/org/bibletranslationtools/writer/di/Modules.android.kt index 7f9648c..a043dff 100644 --- a/shared/src/androidMain/kotlin/org/bibletranslationtools/writer/di/Modules.android.kt +++ b/shared/src/androidMain/kotlin/org/bibletranslationtools/writer/di/Modules.android.kt @@ -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 @@ -22,6 +24,7 @@ actual val platformModule = module { singleOf(::AndroidPlatform).bind() singleOf(::AndroidDirectoryProvider).bind() singleOf(::AndroidBackupScheduler).bind() + singleOf(::AndroidSystemFontProvider).bind() @OptIn(ExperimentalSettingsApi::class, ExperimentalSettingsImplementation::class) single { diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index eec342c..4490692 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -779,6 +779,10 @@ Do you want to enable SD card access? If so then: Your translation has been successfully updated. Your translation has been successfully updated.\nSome chunks had conflicts and need review. Enter Search String + Add custom font + Import a .ttf or .otf font file + Font imported + "%1$s" was imported. You can now select it as your source or target font. Search Source: Search Translation: Creates a .usfm file of your project diff --git a/shared/src/commonMain/kotlin/org/bibletranslationtools/writer/DirectoryProvider.kt b/shared/src/commonMain/kotlin/org/bibletranslationtools/writer/DirectoryProvider.kt index a9247de..6095c31 100644 --- a/shared/src/commonMain/kotlin/org/bibletranslationtools/writer/DirectoryProvider.kt +++ b/shared/src/commonMain/kotlin/org/bibletranslationtools/writer/DirectoryProvider.kt @@ -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 @@ -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 @@ -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 */ diff --git a/shared/src/commonMain/kotlin/org/bibletranslationtools/writer/core/FontNameReader.kt b/shared/src/commonMain/kotlin/org/bibletranslationtools/writer/core/FontNameReader.kt new file mode 100644 index 0000000..72d221e --- /dev/null +++ b/shared/src/commonMain/kotlin/org/bibletranslationtools/writer/core/FontNameReader.kt @@ -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) +} diff --git a/shared/src/commonMain/kotlin/org/bibletranslationtools/writer/core/SystemFontProvider.kt b/shared/src/commonMain/kotlin/org/bibletranslationtools/writer/core/SystemFontProvider.kt new file mode 100644 index 0000000..3f5db8c --- /dev/null +++ b/shared/src/commonMain/kotlin/org/bibletranslationtools/writer/core/SystemFontProvider.kt @@ -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 + + /** + * 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? +} diff --git a/shared/src/commonMain/kotlin/org/bibletranslationtools/writer/core/Typography.kt b/shared/src/commonMain/kotlin/org/bibletranslationtools/writer/core/Typography.kt index 04da2a2..ccca858 100644 --- a/shared/src/commonMain/kotlin/org/bibletranslationtools/writer/core/Typography.kt +++ b/shared/src/commonMain/kotlin/org/bibletranslationtools/writer/core/Typography.kt @@ -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 @@ -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 @@ -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 @@ -117,9 +123,20 @@ class Typography(private val preference: Preference) { } fun getFontNames(): List { - return fontResources.keys.toList() + return fontResources.keys.toList() + getSystemFonts().map { it.path } } + /** Fonts installed on the host OS, discovered through [SystemFontProvider]. */ + fun getSystemFonts(): List = 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 diff --git a/shared/src/commonMain/kotlin/org/bibletranslationtools/writer/ui/settings/ListPreferenceDialog.kt b/shared/src/commonMain/kotlin/org/bibletranslationtools/writer/ui/settings/ListPreferenceDialog.kt index 354279b..c7a0439 100644 --- a/shared/src/commonMain/kotlin/org/bibletranslationtools/writer/ui/settings/ListPreferenceDialog.kt +++ b/shared/src/commonMain/kotlin/org/bibletranslationtools/writer/ui/settings/ListPreferenceDialog.kt @@ -8,20 +8,30 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.LaunchedEffect import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectableGroup import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.RadioButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import btt_writer.shared.generated.resources.Res +import btt_writer.shared.generated.resources.search_hint import org.bibletranslationtools.writer.ui.dialogs.OverlayDialog +import org.jetbrains.compose.resources.stringResource @Composable fun ListPreferenceDialog( @@ -30,8 +40,19 @@ fun ListPreferenceDialog( entryValues: List, selectedValue: String, onValueSelected: (String) -> Unit, - onDismissRequest: () -> Unit + onDismissRequest: () -> Unit, + searchable: Boolean = false ) { + var query by remember { mutableStateOf("") } + + // Pair each display name with its value, then filter by the search query. + val visible = entries.mapIndexedNotNull { index, name -> + val value = entryValues.getOrNull(index) ?: return@mapIndexedNotNull null + name to value + }.filter { (name, _) -> + query.isBlank() || name.contains(query.trim(), ignoreCase = true) + } + OverlayDialog( onDismiss = onDismissRequest ) { @@ -44,13 +65,29 @@ fun ListPreferenceDialog( fontSize = 24.sp ) + if (searchable) { + OutlinedTextField( + value = query, + onValueChange = { query = it }, + singleLine = true, + placeholder = { Text(stringResource(Res.string.search_hint)) }, + modifier = Modifier.fillMaxWidth() + ) + } + + val listState = rememberLazyListState() + LaunchedEffect(Unit) { + val selectedIndex = visible.indexOfFirst { it.second == selectedValue } + if (selectedIndex >= 0) listState.scrollToItem(selectedIndex) + } + LazyColumn( + state = listState, modifier = Modifier .selectableGroup() .padding(vertical = 8.dp) ) { - itemsIndexed(entries) { index, entryDisplayName -> - val entryValue = entryValues.getOrNull(index) ?: return@itemsIndexed + items(visible, key = { it.second }) { (entryDisplayName, entryValue) -> val isSelected = entryValue == selectedValue Row( diff --git a/shared/src/commonMain/kotlin/org/bibletranslationtools/writer/ui/settings/SettingsComponent.kt b/shared/src/commonMain/kotlin/org/bibletranslationtools/writer/ui/settings/SettingsComponent.kt index ada8557..1be2446 100644 --- a/shared/src/commonMain/kotlin/org/bibletranslationtools/writer/ui/settings/SettingsComponent.kt +++ b/shared/src/commonMain/kotlin/org/bibletranslationtools/writer/ui/settings/SettingsComponent.kt @@ -89,6 +89,8 @@ interface SettingsComponent { fun updateTranslationFontSize(newValue: String) fun updateSourceTypeface(newValue: String) fun updateSourceFontSize(newValue: String) + fun importFont(file: PlatformFile) + fun dismissImportFontDialog() fun onContentServerChanged(newValue: String) fun updateGogsApiUrl(newValue: String) fun updateMediaServerUrl(newValue: String) @@ -125,6 +127,7 @@ interface SettingsComponent { val currentSourceFontName: String = "", val currentSourceFontSizeValue: String = "", val currentSourceFontSizeName: String = "", + val importedFontName: String? = null, // Server Prefs val contentServerNames: List = emptyList(), @@ -362,24 +365,44 @@ class DefaultSettingsComponent( private fun loadTypefaces() { launchWithProgress { - val fontNames = getStringArray(Res.array.pref_typeface_titles) - val loadedFonts = typography.getFontNames() - val defaultFont = getString(Res.string.pref_default_translation_typeface) + refreshTypefaces() + } + } + + private suspend fun refreshTypefaces() { + val bundledTitles = getStringArray(Res.array.pref_typeface_titles) + val defaultFont = getString(Res.string.pref_default_translation_typeface) + + // Scanning the OS font directories touches the filesystem; keep it off the main thread. + val (loadedFonts, systemNameByPath) = withContext(Dispatchers.IO) { + typography.getFontNames() to typography.getSystemFonts().associate { + it.path to it.displayName + } + } + + // Pair each value with its display name (bundled titles aligned by index, system fonts + // by their own name), then sort the whole list alphabetically by display name. + val sorted = loadedFonts + .mapIndexed { index, value -> + value to (systemNameByPath[value] ?: bundledTitles.getOrNull(index) ?: value) + } + .sortedBy { it.second.lowercase() } + val orderedFonts = sorted.map { it.first } + val fontNames = sorted.map { it.second } _state.update { state -> - val translationIndex = loadedFonts.indexOf(state.currentTranslationFontValue) + val translationIndex = orderedFonts.indexOf(state.currentTranslationFontValue) val translationFontName = fontNames.getOrNull(translationIndex) ?: defaultFont - val sourceIndex = loadedFonts.indexOf(state.currentSourceFontValue) + val sourceIndex = orderedFonts.indexOf(state.currentSourceFontValue) val sourceFontName = fontNames.getOrNull(sourceIndex) ?: defaultFont state.copy( - availableFonts = loadedFonts, + availableFonts = orderedFonts, availableFontNames = fontNames, currentTranslationFontName = translationFontName, currentSourceFontName = sourceFontName ) } - } } override fun updateColorTheme(newValue: String) { @@ -401,9 +424,8 @@ class DefaultSettingsComponent( override fun updateTranslationTypeface(newFileName: String) { preference.setPref(Preference.KEY_PREF_TRANSLATION_TYPEFACE, newFileName) - val newName = _state.value.availableFonts.find { - it == newFileName - } ?: "Default" + val index = _state.value.availableFonts.indexOf(newFileName) + val newName = _state.value.availableFontNames.getOrNull(index) ?: "Default" _state.update { it.copy( @@ -441,10 +463,28 @@ class DefaultSettingsComponent( } } + override fun importFont(file: PlatformFile) { + launchWithProgress { + val imported = withContext(Dispatchers.IO) { + directoryProvider.copyFile(file, directoryProvider.fontsDir) + } + refreshTypefaces() + + val index = _state.value.availableFonts.indexOf(imported.absolutePath) + val name = _state.value.availableFontNames.getOrNull(index) ?: imported.name + _state.update { it.copy(importedFontName = name) } + } + } + + override fun dismissImportFontDialog() { + _state.update { it.copy(importedFontName = null) } + } + override fun updateSourceTypeface(newValue: String) { preference.setPref(Preference.KEY_PREF_SOURCE_TYPEFACE, newValue) - val newName = _state.value.availableFonts.find { it == newValue } ?: "Default" + val index = _state.value.availableFonts.indexOf(newValue) + val newName = _state.value.availableFontNames.getOrNull(index) ?: "Default" _state.update { it.copy( diff --git a/shared/src/commonMain/kotlin/org/bibletranslationtools/writer/ui/settings/SettingsScreen.kt b/shared/src/commonMain/kotlin/org/bibletranslationtools/writer/ui/settings/SettingsScreen.kt index c311310..49f5d78 100644 --- a/shared/src/commonMain/kotlin/org/bibletranslationtools/writer/ui/settings/SettingsScreen.kt +++ b/shared/src/commonMain/kotlin/org/bibletranslationtools/writer/ui/settings/SettingsScreen.kt @@ -58,6 +58,10 @@ import btt_writer.shared.generated.resources.title_media_server import btt_writer.shared.generated.resources.title_migrate_old_app import btt_writer.shared.generated.resources.title_reader_server import btt_writer.shared.generated.resources.title_source_typeface +import btt_writer.shared.generated.resources.font_imported_message +import btt_writer.shared.generated.resources.font_imported_title +import btt_writer.shared.generated.resources.summary_add_custom_font +import btt_writer.shared.generated.resources.title_add_custom_font import btt_writer.shared.generated.resources.title_source_typeface_size import btt_writer.shared.generated.resources.title_tm_url import btt_writer.shared.generated.resources.title_translation_typeface @@ -70,7 +74,9 @@ import btt_writer.shared.generated.resources.view_statement_of_faith import btt_writer.shared.generated.resources.view_translation_guidelines import io.github.vinceglb.filekit.PlatformFile import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings +import io.github.vinceglb.filekit.dialogs.FileKitType import io.github.vinceglb.filekit.dialogs.compose.rememberDirectoryPickerLauncher +import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher import org.bibletranslationtools.writer.ui.dialogs.BaseDialog import org.bibletranslationtools.writer.ui.dialogs.ConfirmDialog import org.bibletranslationtools.writer.ui.dialogs.LegalDocumentDialog @@ -112,6 +118,13 @@ fun SettingsScreen( directory?.let(component::migrateOldAppData) } + val openFontPicker = rememberFilePickerLauncher( + type = FileKitType.File(extensions = listOf("ttf", "otf")), + dialogSettings = FileKitDialogSettings.createDefault() + ) { file: PlatformFile? -> + file?.let(component::importFont) + } + val uriHandler = LocalUriHandler.current LaunchedEffect(component) { @@ -200,6 +213,16 @@ fun SettingsScreen( item { HorizontalDivider() } + item { + ClickablePreference( + title = stringResource(Res.string.title_add_custom_font), + summary = stringResource(Res.string.summary_add_custom_font), + onClick = { openFontPicker.launch() } + ) + } + + item { HorizontalDivider() } + item { ListItem( headlineContent = { Text(stringResource(Res.string.version)) }, @@ -426,6 +449,20 @@ fun SettingsScreen( } } + state.importedFontName?.let { fontName -> + BaseDialog( + onDismiss = component::dismissImportFontDialog, + title = stringResource(Res.string.font_imported_title), + message = stringResource(Res.string.font_imported_message, fontName) + ) { + TextButton( + onClick = component::dismissImportFontDialog + ) { + Text(stringResource(Res.string.label_ok)) + } + } + } + if (state.migrationFinished) { BaseDialog( onDismiss = component::onMigrationFinished, @@ -462,7 +499,8 @@ fun SettingsScreen( component.updateTranslationTypeface(newFileName) showTranslationFontDialog = false }, - onDismissRequest = { showTranslationFontDialog = false } + onDismissRequest = { showTranslationFontDialog = false }, + searchable = true ) } @@ -490,7 +528,8 @@ fun SettingsScreen( component.updateSourceTypeface(newFileName) showSourceFontDialog = false }, - onDismissRequest = { showSourceFontDialog = false } + onDismissRequest = { showSourceFontDialog = false }, + searchable = true ) } diff --git a/shared/src/commonMain/kotlin/org/bibletranslationtools/writer/utils/TypographyExt.kt b/shared/src/commonMain/kotlin/org/bibletranslationtools/writer/utils/TypographyExt.kt index 4635f2b..7da3bd0 100644 --- a/shared/src/commonMain/kotlin/org/bibletranslationtools/writer/utils/TypographyExt.kt +++ b/shared/src/commonMain/kotlin/org/bibletranslationtools/writer/utils/TypographyExt.kt @@ -27,11 +27,13 @@ fun Typography.getComposeTextStyle( getFormatConfig(translationType, style, languageCode, direction) } - val fontFamily = FontFamily( - Font( - resolveFontResource(config.fontAssetPath) - ) - ) + val fontFamily = if (isBundledFont(config.fontAssetPath)) { + FontFamily(Font(resolveFontResource(config.fontAssetPath))) + } else { + remember(config.fontAssetPath) { + resolveSystemFontFamily(config.fontAssetPath) + } ?: FontFamily(Font(resolveFontResource(config.fontAssetPath))) + } val safeSize = if (config.fontSizeSp > 0f) config.fontSizeSp else 18f return TextStyle( @@ -44,18 +46,3 @@ fun Typography.getComposeTextStyle( color = MaterialTheme.colorScheme.onSurface ) } - -//@Composable -//private fun rememberFontFamily(absolutePath: String): FontFamily { -// var family by remember(absolutePath) { -// mutableStateOf(FontFamily.Default) -// } -// -// LaunchedEffect(absolutePath) { -// runCatching { -// val bytes = PlatformFile(absolutePath).readBytes() -// family = FontFamily(Font(identity = absolutePath, data = bytes)) -// } -// } -// return family -//} diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/ui/settings/FakeSettingsComponent.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/ui/settings/FakeSettingsComponent.kt index 921fb50..991464f 100644 --- a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/ui/settings/FakeSettingsComponent.kt +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/ui/settings/FakeSettingsComponent.kt @@ -79,6 +79,16 @@ class FakeSettingsComponent : SettingsComponent { updateSourceFontSizeCalledWith = newValue } + var importFontCalledWith: PlatformFile? = null + override fun importFont(file: PlatformFile) { + importFontCalledWith = file + } + + var dismissImportFontDialogCalled = false + override fun dismissImportFontDialog() { + dismissImportFontDialogCalled = true + } + override fun onContentServerChanged(newValue: String) { onContentServerChangedCalledWith = newValue } diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/ui/settings/ListPreferenceDialogTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/ui/settings/ListPreferenceDialogTest.kt new file mode 100644 index 0000000..b170f4b --- /dev/null +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/ui/settings/ListPreferenceDialogTest.kt @@ -0,0 +1,75 @@ +package org.bibletranslationtools.writer.integration.ui.settings + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.v2.runComposeUiTest +import org.bibletranslationtools.writer.integration.ui.ScreenTestBase +import org.bibletranslationtools.writer.ui.settings.ListPreferenceDialog +import org.junit.Test + +@OptIn(ExperimentalTestApi::class) +class ListPreferenceDialogTest : ScreenTestBase() { + + private val entries = listOf("Charis SIL Regular", "Padauk", "Noto Sans") + private val values = listOf("a.ttf", "b.ttf", "c.ttf") + + @Test + fun searchable_filtersEntriesByQuery() = runComposeUiTest { + setContent { + ListPreferenceDialog( + title = "Font", + entries = entries, + entryValues = values, + selectedValue = "a.ttf", + searchable = true, + onValueSelected = {}, + onDismissRequest = {} + ) + } + + onNode(hasSetTextAction()).performTextInput("padauk") + + onNodeWithText("Padauk").assertIsDisplayed() + onNodeWithText("Charis SIL Regular").assertDoesNotExist() + onNodeWithText("Noto Sans").assertDoesNotExist() + } + + @Test + fun scrollsToSelectedFont_onOpen() = runComposeUiTest { + val many = (1..60).map { "Font $it" } + val manyValues = (1..60).map { "f$it.ttf" } + setContent { + ListPreferenceDialog( + title = "Font", + entries = many, + entryValues = manyValues, + selectedValue = "f55.ttf", + searchable = true, + onValueSelected = {}, + onDismissRequest = {} + ) + } + + onNodeWithText("Font 55").assertIsDisplayed() + } + + @Test + fun notSearchable_hasNoSearchField() = runComposeUiTest { + setContent { + ListPreferenceDialog( + title = "Theme", + entries = entries, + entryValues = values, + selectedValue = "a.ttf", + onValueSelected = {}, + onDismissRequest = {} + ) + } + + onNode(hasSetTextAction()).assertDoesNotExist() + onNodeWithText("Charis SIL Regular").assertIsDisplayed() + } +} diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/core/FontNameReaderTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/core/FontNameReaderTest.kt new file mode 100644 index 0000000..9800d0f --- /dev/null +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/core/FontNameReaderTest.kt @@ -0,0 +1,104 @@ +package org.bibletranslationtools.writer.unit.core + +import org.bibletranslationtools.writer.core.FontNameReader +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class FontNameReaderTest { + + @Test + fun readsWindowsFullName_nameId4_utf16be() { + val bytes = sfntWithNames( + NameRecord(platformId = 3, encodingId = 1, nameId = 4, value = "Charis SIL Regular") + ) + assertEquals("Charis SIL Regular", FontNameReader.readDisplayName(bytes)) + } + + @Test + fun fallsBackToFamily_nameId1_whenFullNameAbsent() { + val bytes = sfntWithNames( + NameRecord(platformId = 3, encodingId = 1, nameId = 1, value = "Charis SIL") + ) + assertEquals("Charis SIL", FontNameReader.readDisplayName(bytes)) + } + + @Test + fun prefersFullName_nameId4_overFamily_nameId1() { + val bytes = sfntWithNames( + NameRecord(platformId = 3, encodingId = 1, nameId = 1, value = "Charis SIL"), + NameRecord(platformId = 3, encodingId = 1, nameId = 4, value = "Charis SIL Bold") + ) + assertEquals("Charis SIL Bold", FontNameReader.readDisplayName(bytes)) + } + + @Test + fun readsMacRoman_platform1_ascii() { + val bytes = sfntWithNames( + NameRecord(platformId = 1, encodingId = 0, nameId = 4, value = "Padauk") + ) + assertEquals("Padauk", FontNameReader.readDisplayName(bytes)) + } + + @Test + fun returnsNull_forGarbageBytes() { + assertNull(FontNameReader.readDisplayName(byteArrayOf(0, 1, 2, 3, 4))) + } + + // --- minimal sfnt builder for tests --- + + private data class NameRecord( + val platformId: Int, + val encodingId: Int, + val nameId: Int, + val value: String, + val languageId: Int = if (platformId == 3) 0x0409 else 0 + ) + + private fun u16(v: Int) = byteArrayOf((v ushr 8).toByte(), v.toByte()) + private fun u32(v: Int) = byteArrayOf( + (v ushr 24).toByte(), (v ushr 16).toByte(), (v ushr 8).toByte(), v.toByte() + ) + + private fun encode(r: NameRecord): ByteArray = + if (r.platformId == 1) r.value.encodeToByteArray() + else r.value.flatMap { listOf((it.code ushr 8).toByte(), it.code.toByte()) }.toByteArray() + + private fun sfntWithNames(vararg records: NameRecord): ByteArray { + val encoded = records.map { encode(it) } + val count = records.size + val storageStart = 6 + 12 * count + + val nameTable = ArrayList() + nameTable += u16(0).toList() // format + nameTable += u16(count).toList() // count + nameTable += u16(storageStart).toList() // stringOffset + + var strOffset = 0 + records.forEachIndexed { i, r -> + nameTable += u16(r.platformId).toList() + nameTable += u16(r.encodingId).toList() + nameTable += u16(r.languageId).toList() + nameTable += u16(r.nameId).toList() + nameTable += u16(encoded[i].size).toList() + nameTable += u16(strOffset).toList() + strOffset += encoded[i].size + } + encoded.forEach { nameTable += it.toList() } + val nameBytes = nameTable.toByteArray() + + val sfnt = ArrayList() + sfnt += u32(0x00010000).toList() // sfnt version + sfnt += u16(1).toList() // numTables + sfnt += u16(16).toList() // searchRange + sfnt += u16(0).toList() // entrySelector + sfnt += u16(0).toList() // rangeShift + // table record for "name" + sfnt += "name".encodeToByteArray().toList() + sfnt += u32(0).toList() // checksum + sfnt += u32(28).toList() // offset (12 + 16) + sfnt += u32(nameBytes.size).toList() + sfnt += nameBytes.toList() + return sfnt.toByteArray() + } +} diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/core/TypographyTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/core/TypographyTest.kt new file mode 100644 index 0000000..4adea8c --- /dev/null +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/core/TypographyTest.kt @@ -0,0 +1,71 @@ +package org.bibletranslationtools.writer.unit.core + +import androidx.compose.ui.text.font.FontFamily +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.bibletranslationtools.writer.core.SystemFont +import org.bibletranslationtools.writer.core.SystemFontProvider +import org.bibletranslationtools.writer.core.Typography +import org.bibletranslationtools.writer.data.Preference +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TypographyTest { + + private val preference: Preference = mockk(relaxed = true) + + private fun typography( + systemFonts: List = emptyList(), + loaded: FontFamily? = null + ): Pair { + val provider: SystemFontProvider = mockk(relaxed = true) + every { provider.listSystemFonts() } returns systemFonts + every { provider.loadFontFamily(any()) } returns loaded + return Typography(preference, provider) to provider + } + + @Test + fun getSystemFonts_delegatesToProvider() { + val fonts = listOf(SystemFont("Arial", "/system/fonts/Arial.ttf")) + val (typography, _) = typography(systemFonts = fonts) + + assertEquals(fonts, typography.getSystemFonts()) + } + + @Test + fun getFontNames_appendsSystemFontPathsAfterBundled() { + val (typography, _) = typography( + systemFonts = listOf(SystemFont("Arial", "/system/fonts/Arial.ttf")) + ) + + val names = typography.getFontNames() + + assertTrue("andika.ttf" in names, "bundled fonts retained") + assertTrue("/system/fonts/Arial.ttf" in names, "system font path appended") + assertTrue( + names.indexOf("/system/fonts/Arial.ttf") > names.indexOf("andika.ttf"), + "system fonts come after bundled" + ) + } + + @Test + fun isBundledFont_trueForBundledKey_falseForSystemPath() { + val (typography, _) = typography() + + assertTrue(typography.isBundledFont("andika.ttf")) + assertFalse(typography.isBundledFont("/system/fonts/Arial.ttf")) + } + + @Test + fun resolveSystemFontFamily_delegatesToProvider() { + val (typography, provider) = typography(loaded = FontFamily.Default) + + val result = typography.resolveSystemFontFamily("/system/fonts/Arial.ttf") + + assertEquals(FontFamily.Default, result) + verify { provider.loadFontFamily("/system/fonts/Arial.ttf") } + } +} diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/settings/SettingsComponentTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/settings/SettingsComponentTest.kt index c59f225..f482c73 100644 --- a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/settings/SettingsComponentTest.kt +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/settings/SettingsComponentTest.kt @@ -163,6 +163,117 @@ class SettingsComponentTest : BaseComponentTest() { } } + @Test + fun testLoadsSystemFontsWithDisplayNames() { + runBlocking { + every { typography.getFontNames() } returns listOf("font1.ttf", "/sys/Arial.ttf") + every { typography.getSystemFonts() } returns listOf( + org.bibletranslationtools.writer.core.SystemFont("Arial", "/sys/Arial.ttf") + ) + + val component = createComponent() + component.state.awaitState { it.availableFonts.size > 1 } + + val fonts = component.state.value.availableFonts + val names = component.state.value.availableFontNames + assertTrue("/sys/Arial.ttf" in fonts, "system font path listed") + assertEquals("Arial", names[fonts.indexOf("/sys/Arial.ttf")]) + } + } + + @Test + fun testFontsSortedAlphabeticallyByDisplayName() { + runBlocking { + coEvery { getStringArray(Res.array.pref_typeface_titles) } returns listOf("Zeta", "Alpha") + every { typography.getFontNames() } returns listOf("zfont.ttf", "afont.ttf", "/sys/Mango.ttf") + every { typography.getSystemFonts() } returns listOf( + org.bibletranslationtools.writer.core.SystemFont("Mango", "/sys/Mango.ttf") + ) + + val component = createComponent() + component.state.awaitState { it.availableFonts.size > 2 } + + assertEquals( + listOf("Alpha", "Mango", "Zeta"), + component.state.value.availableFontNames + ) + assertEquals( + listOf("afont.ttf", "/sys/Mango.ttf", "zfont.ttf"), + component.state.value.availableFonts + ) + } + } + + @Test + fun testImportFontCopiesFileAndRefreshesList() { + runBlocking { + val file: PlatformFile = mockk(relaxed = true) + coEvery { directoryProvider.copyFile(any(), any()) } returns mockk(relaxed = true) + every { typography.getFontNames() } returnsMany listOf( + listOf("font1.ttf"), + listOf("font1.ttf", "/fonts/Custom.ttf") + ) + every { typography.getSystemFonts() } returns listOf( + org.bibletranslationtools.writer.core.SystemFont("Custom", "/fonts/Custom.ttf") + ) + + val component = createComponent() + component.state.awaitState { it.availableFonts.isNotEmpty() } + + component.importFont(file) + component.state.awaitState { state -> state.availableFonts.any { it == "/fonts/Custom.ttf" } } + + coVerify { directoryProvider.copyFile(file, any()) } + assertTrue("/fonts/Custom.ttf" in component.state.value.availableFonts) + } + } + + @Test + fun testImportFontShowsConfirmationDialogWithFontName() { + runBlocking { + val file: PlatformFile = mockk(relaxed = true) + val importedFile = mockk(relaxed = true) + every { importedFile.absolutePath } returns "/fonts/Custom.ttf" + coEvery { directoryProvider.copyFile(any(), any()) } returns importedFile + every { typography.getFontNames() } returnsMany listOf( + listOf("font1.ttf"), + listOf("font1.ttf", "/fonts/Custom.ttf") + ) + every { typography.getSystemFonts() } returns listOf( + org.bibletranslationtools.writer.core.SystemFont("Custom", "/fonts/Custom.ttf") + ) + + val component = createComponent() + component.state.awaitState { it.availableFonts.isNotEmpty() } + + component.importFont(file) + component.state.awaitState { it.importedFontName != null } + assertEquals("Custom", component.state.value.importedFontName) + + component.dismissImportFontDialog() + assertEquals(null, component.state.value.importedFontName) + } + } + + @Test + fun testUpdateTypefaceShowsDisplayName() { + runBlocking { + every { typography.getFontNames() } returns listOf("font1.ttf", "/sys/Arial.ttf") + every { typography.getSystemFonts() } returns listOf( + org.bibletranslationtools.writer.core.SystemFont("Arial", "/sys/Arial.ttf") + ) + + val component = createComponent() + component.state.awaitState { it.availableFonts.size > 1 } + + component.updateTranslationTypeface("/sys/Arial.ttf") + assertEquals("Arial", component.state.value.currentTranslationFontName) + + component.updateSourceTypeface("/sys/Arial.ttf") + assertEquals("Arial", component.state.value.currentSourceFontName) + } + } + @Test fun testUpdateTranslationFontSize() { runBlocking { diff --git a/shared/src/jvmMain/kotlin/org/bibletranslationtools/writer/core/DesktopSystemFontProvider.kt b/shared/src/jvmMain/kotlin/org/bibletranslationtools/writer/core/DesktopSystemFontProvider.kt new file mode 100644 index 0000000..3c435e3 --- /dev/null +++ b/shared/src/jvmMain/kotlin/org/bibletranslationtools/writer/core/DesktopSystemFontProvider.kt @@ -0,0 +1,77 @@ +package org.bibletranslationtools.writer.core + +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.platform.Font +import org.bibletranslationtools.logger.Logger +import org.bibletranslationtools.writer.DirectoryProvider +import java.io.File + +/** + * Lists fonts installed on the desktop OS by scanning the standard font + * directories for Windows, macOS and Linux, plus user-imported fonts in + * [DirectoryProvider.fontsDir]. + */ +class DesktopSystemFontProvider( + private val directoryProvider: DirectoryProvider +) : SystemFontProvider { + + override fun listSystemFonts(): List { + return runCatching { + fontDirectories() + .asSequence() + .filter { it.isDirectory } + .flatMap { it.walkTopDown().filter { f -> f.isFile } } + .filter { it.extension.lowercase() in FONT_EXTENSIONS } + .distinctBy { it.absolutePath } + .map { SystemFont(displayName = displayName(it), path = it.absolutePath) } + .sortedBy { it.displayName } + .toList() + }.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 + } + + private fun fontDirectories(): List { + val osName = System.getProperty("os.name").orEmpty().lowercase() + val home = System.getProperty("user.home").orEmpty() + return when { + "win" in osName -> listOf( + File(System.getenv("WINDIR") ?: "C:\\Windows", "Fonts"), + File(home, "AppData\\Local\\Microsoft\\Windows\\Fonts") + ) + "mac" in osName || "darwin" in osName -> listOf( + File("/System/Library/Fonts"), + File("/Library/Fonts"), + File(home, "Library/Fonts") + ) + else -> listOf( + File("/usr/share/fonts"), + File("/usr/local/share/fonts"), + File(home, ".fonts"), + File(home, ".local/share/fonts") + ) + } + directoryProvider.fontsDir + } + + /** 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 = "DesktopSystemFontProvider" + private val FONT_EXTENSIONS = setOf("ttf", "otf") + } +} diff --git a/shared/src/jvmMain/kotlin/org/bibletranslationtools/writer/di/Modules.jvm.kt b/shared/src/jvmMain/kotlin/org/bibletranslationtools/writer/di/Modules.jvm.kt index b5e2cfd..f1caf41 100644 --- a/shared/src/jvmMain/kotlin/org/bibletranslationtools/writer/di/Modules.jvm.kt +++ b/shared/src/jvmMain/kotlin/org/bibletranslationtools/writer/di/Modules.jvm.kt @@ -15,6 +15,8 @@ import org.bibletranslationtools.writer.DirectoryProvider import org.bibletranslationtools.writer.Platform import org.bibletranslationtools.writer.core.BackupNotifier import org.bibletranslationtools.writer.core.BackupScheduler +import org.bibletranslationtools.writer.core.DesktopSystemFontProvider +import org.bibletranslationtools.writer.core.SystemFontProvider import org.koin.core.module.dsl.singleOf import org.koin.dsl.bind import org.koin.dsl.module @@ -25,6 +27,7 @@ actual val platformModule = module { singleOf(::DesktopDirectoryProvider).bind() singleOf(::DesktopBackupScheduler).bind() singleOf(::DesktopBackupNotifier).bind() + singleOf(::DesktopSystemFontProvider).bind() @OptIn(ExperimentalSettingsApi::class, ExperimentalSettingsImplementation::class) single {