diff --git a/app/src/main/java/eu/kanade/presentation/reader/settings/ReaderSettingsDialog.kt b/app/src/main/java/eu/kanade/presentation/reader/settings/ReaderSettingsDialog.kt index e6196b2d3..0e08c964d 100644 --- a/app/src/main/java/eu/kanade/presentation/reader/settings/ReaderSettingsDialog.kt +++ b/app/src/main/java/eu/kanade/presentation/reader/settings/ReaderSettingsDialog.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalView import androidx.compose.ui.window.DialogWindowProvider @@ -29,13 +30,18 @@ fun ReaderSettingsDialog( stringResource(MR.strings.pref_category_reading_mode), stringResource(MR.strings.pref_category_general), stringResource(MR.strings.custom_filter), + stringResource(MR.strings.pref_category_text_reader), ) val pagerState = rememberPagerState { tabTitles.size } + val initialTextStyle = remember { viewModel.preferences.textReaderStyle() } BoxWithConstraints { TabbedDialog( modifier = Modifier.heightIn(max = maxHeight * 0.75f), onDismissRequest = { + if (viewModel.preferences.textReaderStyle() != initialTextStyle) { + viewModel.onTextSettingsChanged() + } onDismissRequest() onShowMenus() }, @@ -63,6 +69,7 @@ fun ReaderSettingsDialog( 0 -> ReadingModePage(viewModel) 1 -> GeneralPage(viewModel) 2 -> ColorFilterPage(viewModel) + 3 -> TextSettingsPage(viewModel) } } } diff --git a/app/src/main/java/eu/kanade/presentation/reader/settings/TextSettingsPage.kt b/app/src/main/java/eu/kanade/presentation/reader/settings/TextSettingsPage.kt new file mode 100644 index 000000000..b19cc4fce --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/reader/settings/TextSettingsPage.kt @@ -0,0 +1,83 @@ +package eu.kanade.presentation.reader.settings + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences +import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsViewModel +import tachiyomi.i18n.MR +import tachiyomi.presentation.core.components.SettingsChipRow +import tachiyomi.presentation.core.components.SliderItem +import tachiyomi.presentation.core.i18n.stringResource +import tachiyomi.presentation.core.util.collectAsState + +private val fontFamilies = listOf( + MR.strings.pref_text_reader_font_system to ReaderPreferences.TextFontFamily.SYSTEM, + MR.strings.pref_text_reader_font_sans to ReaderPreferences.TextFontFamily.SANS_SERIF, + MR.strings.pref_text_reader_font_serif to ReaderPreferences.TextFontFamily.SERIF, + MR.strings.pref_text_reader_font_mono to ReaderPreferences.TextFontFamily.MONOSPACE, +) + +@Composable +internal fun ColumnScope.TextSettingsPage(viewModel: ReaderSettingsViewModel) { + val fontSizePref = viewModel.preferences.textFontSize + val fontSize by fontSizePref.collectAsState() + SliderItem( + label = stringResource(MR.strings.pref_text_reader_font_size), + value = fontSize, + valueRange = 32..72, + steps = 19, + valueString = fontSize.toString(), + onChange = fontSizePref::set, + pillColor = MaterialTheme.colorScheme.surfaceContainerHighest, + ) + + val lineSpacingPref = viewModel.preferences.textLineSpacing + val lineSpacing by lineSpacingPref.collectAsState() + SliderItem( + label = stringResource(MR.strings.pref_text_reader_line_spacing), + value = lineSpacing, + valueRange = 90..180, + steps = 17, + valueString = "$lineSpacing%", + onChange = lineSpacingPref::set, + pillColor = MaterialTheme.colorScheme.surfaceContainerHighest, + ) + + val paragraphSpacingPref = viewModel.preferences.textParagraphSpacing + val paragraphSpacing by paragraphSpacingPref.collectAsState() + SliderItem( + label = stringResource(MR.strings.pref_text_reader_paragraph_spacing), + value = paragraphSpacing, + valueRange = 0..48, + steps = 11, + valueString = paragraphSpacing.toString(), + onChange = paragraphSpacingPref::set, + pillColor = MaterialTheme.colorScheme.surfaceContainerHighest, + ) + + val fontFamilyPref = viewModel.preferences.textFontFamily + val fontFamily by fontFamilyPref.collectAsState() + SettingsChipRow(MR.strings.pref_text_reader_font_family) { + fontFamilies.forEach { (label, value) -> + FilterChip( + selected = fontFamily == value, + onClick = { fontFamilyPref.set(value) }, + label = { Text(stringResource(label)) }, + ) + } + } + + Text( + text = stringResource(MR.strings.pref_text_reader_pdf_note), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp), + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/bookoasis/BookOasisCompat.kt b/app/src/main/java/eu/kanade/tachiyomi/source/bookoasis/BookOasisCompat.kt new file mode 100644 index 000000000..726d99079 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/source/bookoasis/BookOasisCompat.kt @@ -0,0 +1,131 @@ +package eu.kanade.tachiyomi.source.bookoasis + +import eu.kanade.tachiyomi.network.await +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.source.online.HttpSource +import okhttp3.Request +import org.json.JSONObject +import java.net.URI +import java.net.URLDecoder +import java.nio.charset.StandardCharsets + +private const val BOOKOASIS_PACKAGE_PREFIX = "eu.kanade.tachiyomi.extension.all.bookoasis." +private val SUPPORTED_FORMATS = setOf("zip", "cbz", "imgdir", "epub", "pdf", "txt") +private val DOCUMENT_FORMATS = setOf("epub", "pdf", "txt") + +internal data class BookOasisChapterRef( + val bookId: Long, + val dbType: String, + val format: String, +) + +internal fun Source.isBookOasisSource(): Boolean { + return this::class.java.name.startsWith(BOOKOASIS_PACKAGE_PREFIX) +} + +internal fun String.toBookOasisChapterRef(): BookOasisChapterRef? { + val path = substringBefore('?') + val match = Regex("""^/api/media/books/(\d+)/info$""").matchEntire(path) ?: return null + val params = parseQueryParameters(this) + val format = params["bo_format"]?.lowercase() ?: return null + if (format !in DOCUMENT_FORMATS) return null + + return BookOasisChapterRef( + bookId = match.groupValues[1].toLongOrNull() ?: return null, + dbType = params["type"].orEmpty().ifBlank { "general" }, + format = format, + ) +} + +/** + * BookOasis extension v1.4.x filters its chapter list to ZIP/CBZ. + * Fetch the same detail JSON with the extension's authenticated client and + * rebuild the chapter list so EPUB/PDF/TXT remain visible to the host app. + */ +internal suspend fun HttpSource.fetchBookOasisChapters(manga: SManga): List { + val detailUrl = manga.url.toAbsoluteUrl(baseUrl) + val dbType = parseQueryParameters(detailUrl)["type"].orEmpty().ifBlank { "general" } + + val request = Request.Builder() + .url(detailUrl) + .headers(headers) + .get() + .build() + + val root = client.newCall(request).await().use { response -> + if (!response.isSuccessful) { + error("BookOasis detail request failed: HTTP ${response.code}") + } + JSONObject(response.body.string()) + } + + val rawBooks = root.optJSONArray("books") ?: return emptyList() + val books = buildList { + for (index in 0 until rawBooks.length()) { + val item = rawBooks.optJSONObject(index) ?: continue + val id = item.optLong("id", -1L) + val format = item.optString("file_format", "").lowercase() + if (id <= 0L || format !in SUPPORTED_FORMATS) continue + + add( + Triple( + id, + item.optString("title", "").ifBlank { "Book $id" }, + format, + ), + ) + } + } + + return books.mapIndexed { index, (bookId, title, format) -> + SChapter.create().apply { + url = buildString { + append("/api/media/books/") + append(bookId) + append("/info?type=") + append(dbType) + if (format in DOCUMENT_FORMATS) { + append("&bo_format=") + append(format) + } + } + name = title + chapter_number = (books.size - index).toFloat() + } + }.reversed() +} + +private fun String.toAbsoluteUrl(baseUrl: String): String { + return if (startsWith("http://") || startsWith("https://")) { + this + } else { + baseUrl.trimEnd('/') + "/" + trimStart('/') + } +} + +private fun parseQueryParameters(urlOrPath: String): Map { + val rawQuery = runCatching { + if (urlOrPath.startsWith("http://") || urlOrPath.startsWith("https://")) { + URI(urlOrPath).rawQuery + } else { + urlOrPath.substringAfter('?', "") + } + }.getOrDefault(urlOrPath.substringAfter('?', "")) + + if (rawQuery.isNullOrBlank()) return emptyMap() + + return rawQuery.split('&') + .mapNotNull { part -> + if (part.isBlank()) return@mapNotNull null + val key = part.substringBefore('=') + val value = part.substringAfter('=', "") + decodeQuery(key) to decodeQuery(value) + } + .toMap() +} + +private fun decodeQuery(value: String): String { + return URLDecoder.decode(value, StandardCharsets.UTF_8.name()) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt index 385fa85d7..8af3700ca 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt @@ -254,6 +254,7 @@ class ReaderActivity : BaseActivity() { readerState = viewModel.state, onChangeReadingMode = viewModel::setMangaReadingMode, onChangeOrientation = viewModel::setMangaOrientationType, + onTextSettingsChanged = viewModel::reloadTextReaderStyle, preferences = readerPreferences, ) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt index 914c6cf77..fed108460 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt @@ -90,6 +90,7 @@ import tachiyomi.domain.source.service.SourceManager import tachiyomi.source.local.image.LocalCoverManager import tachiyomi.source.local.isLocal import java.util.Date +import kotlin.math.roundToInt import kotlin.time.Clock /** @@ -326,7 +327,7 @@ class ReaderViewModel( mutableState.update { it.copy(manga = manga, source = source) } if (chapterId == -1L) chapterId = initialChapterId - loader = ChapterLoader(context, downloadManager, downloadProvider, chapterCache, manga, source) + loader = ChapterLoader(context, downloadManager, downloadProvider, chapterCache, manga, source, readerPreferences) loadChapter(loader!!, chapterList.first { chapterId == it.chapter.id }) } catch (e: Throwable) { @@ -822,6 +823,31 @@ class ReaderViewModel( mutableState.update { it.copy(dialog = null) } } + fun reloadTextReaderStyle() { + val chapter = state.value.currentChapter ?: return + if (chapter.pageLoader?.usesTextReaderStyle != true) return + val chapterLoader = loader ?: return + val requestedPage = (state.value.currentPage - 1).coerceAtLeast(0) + val oldLastIndex = (chapter.pages?.lastIndex ?: 0).coerceAtLeast(0) + val readingProgress = if (oldLastIndex == 0) 0f else requestedPage.toFloat() / oldLastIndex + + viewModelScope.launchIO { + try { + chapter.pageLoader?.recycle() + chapter.pageLoader = null + chapter.state = ReaderChapter.State.Wait + chapter.requestedPage = requestedPage + chapterLoader.loadChapter(chapter) + val newLastIndex = (chapter.pages?.lastIndex ?: 0).coerceAtLeast(0) + chapter.requestedPage = (readingProgress * newLastIndex).roundToInt().coerceIn(0, newLastIndex) + eventChannel.send(Event.ReloadViewerChapters) + } catch (e: Throwable) { + if (e is CancellationException) throw e + logcat(LogPriority.ERROR, e) { "Failed to reload text reader style" } + } + } + } + fun setBrightnessOverlayValue(value: Int) { mutableState.update { it.copy(brightnessOverlayValue = value) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/BookOasisFilePageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/BookOasisFilePageLoader.kt new file mode 100644 index 000000000..4c0564305 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/BookOasisFilePageLoader.kt @@ -0,0 +1,110 @@ +package eu.kanade.tachiyomi.ui.reader.loader + +import android.content.Context +import com.hippo.unifile.UniFile +import eu.kanade.tachiyomi.network.await +import eu.kanade.tachiyomi.source.bookoasis.BookOasisChapterRef +import eu.kanade.tachiyomi.source.online.HttpSource +import eu.kanade.tachiyomi.ui.reader.model.ReaderPage +import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle +import mihon.core.archive.epubReader +import okhttp3.Request +import java.io.File +import java.io.IOException + +/** + * Opens BookOasis EPUB/PDF/TXT as native Mihon reader pages. + */ +internal class BookOasisFilePageLoader( + private val context: Context, + private val source: HttpSource, + private val ref: BookOasisChapterRef, + private val textStyle: TextReaderStyle, +) : PageLoader() { + + override var isLocal: Boolean = true + override val usesTextReaderStyle: Boolean = ref.format == "epub" || ref.format == "txt" + + private var delegate: PageLoader? = null + + override suspend fun getPages(): List { + check(!isRecycled) + + val file = getOrDownloadFile() + val uniFile = UniFile.fromFile(file) + ?: error("Unable to open cached BookOasis file: ${file.absolutePath}") + val loader = when (ref.format) { + "epub" -> EpubPageLoader(uniFile.epubReader(context), textStyle) + "pdf" -> PdfPageLoader(context, uniFile) + "txt" -> TextPageLoader(uniFile, textStyle) + else -> error("Unsupported BookOasis document format: ${ref.format}") + } + + delegate = loader + return loader.getPages() + } + + override suspend fun loadPage(page: ReaderPage) { + delegate?.loadPage(page) + } + + override fun retryPage(page: ReaderPage) { + delegate?.retryPage(page) + } + + override fun recycle() { + delegate?.recycle() + delegate = null + super.recycle() + } + + private suspend fun getOrDownloadFile(): File { + val cacheDir = File(context.cacheDir, "bookoasis_reader").apply { mkdirs() } + val target = File(cacheDir, "${ref.dbType}-${ref.bookId}.${ref.format}") + if (target.isFile && target.length() > 0L) { + return target + } + + val temp = File(cacheDir, "${target.name}.part") + if (temp.exists()) temp.delete() + + val downloadUrl = buildString { + append(source.baseUrl.trimEnd('/')) + append("/api/media/books/") + append(ref.bookId) + append("/download?type=") + append(ref.dbType) + } + + val request = Request.Builder() + .url(downloadUrl) + .headers(source.headers) + .get() + .build() + + source.client.newCall(request).await().use { response -> + if (!response.isSuccessful) { + throw IOException( + "BookOasis ${ref.format.uppercase()} download failed: HTTP ${response.code}", + ) + } + + temp.outputStream().use { output -> + response.body.byteStream().use { input -> + input.copyTo(output) + } + } + } + + if (temp.length() <= 0L) { + temp.delete() + throw IOException("BookOasis returned an empty ${ref.format.uppercase()} file") + } + + if (!temp.renameTo(target)) { + temp.copyTo(target, overwrite = true) + temp.delete() + } + return target + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt index 0aa597ec7..cab4e4931 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt @@ -5,8 +5,11 @@ import eu.kanade.tachiyomi.data.cache.ChapterCache import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadProvider import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.bookoasis.isBookOasisSource +import eu.kanade.tachiyomi.source.bookoasis.toBookOasisChapterRef import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter +import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences import mihon.core.archive.archiveReader import mihon.core.archive.epubReader import tachiyomi.core.common.i18n.stringResource @@ -28,6 +31,7 @@ class ChapterLoader( private val chapterCache: ChapterCache, private val manga: Manga, private val source: Source, + private val readerPreferences: ReaderPreferences, ) { /** @@ -86,6 +90,8 @@ class ChapterLoader( manga.title, source, ) + val bookOasisRef = dbChapter.url.toBookOasisChapterRef() + return when { isDownloaded -> DownloadPageLoader( chapter, @@ -98,9 +104,14 @@ class ChapterLoader( when (format) { is Format.Directory -> DirectoryPageLoader(format.file) is Format.Archive -> ArchivePageLoader(format.file.archiveReader(context)) - is Format.Epub -> EpubPageLoader(format.file.epubReader(context)) + is Format.Epub -> EpubPageLoader(format.file.epubReader(context), readerPreferences.textReaderStyle()) + is Format.Pdf -> PdfPageLoader(context, format.file) + is Format.Text -> TextPageLoader(format.file, readerPreferences.textReaderStyle()) } } + source is HttpSource && source.isBookOasisSource() && bookOasisRef != null -> { + BookOasisFilePageLoader(context, source, bookOasisRef, readerPreferences.textReaderStyle()) + } source is HttpSource -> HttpPageLoader(chapter, source, chapterCache) source is StubSource -> error(context.stringResource(MR.strings.source_not_installed, source.toString())) else -> error(context.stringResource(MR.strings.loader_not_implemented_error)) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/EpubPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/EpubPageLoader.kt index 5139fe2b7..0e7d12a92 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/EpubPageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/EpubPageLoader.kt @@ -2,18 +2,39 @@ package eu.kanade.tachiyomi.ui.reader.loader import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.ui.reader.model.ReaderPage +import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle import mihon.core.archive.EpubReader /** * Loader used to load a chapter from a .epub file. + * + * Text-based EPUBs are rendered as regular Mihon pages. Image-only EPUBs + * keep the original image extraction behavior. */ -internal class EpubPageLoader(private val reader: EpubReader) : PageLoader() { +internal class EpubPageLoader( + private val reader: EpubReader, + private val style: TextReaderStyle, +) : PageLoader() { override var isLocal: Boolean = true + override val usesTextReaderStyle: Boolean = true override suspend fun getPages(): List { - return reader.getImagesFromPages().mapIndexed { i, path -> - ReaderPage(i).apply { + val textPages = reader.getTextFromPages() + .filter { it.isNotBlank() } + .flatMap { TextPageRenderer.paginate(it, style) } + + if (textPages.isNotEmpty()) { + return textPages.mapIndexed { index, text -> + ReaderPage(index).apply { + stream = { TextPageRenderer.render(text, style) } + status = Page.State.Ready + } + } + } + + return reader.getImagesFromPages().mapIndexed { index, path -> + ReaderPage(index).apply { stream = { reader.getInputStream(path)!! } status = Page.State.Ready } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/PageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/PageLoader.kt index 164de6bda..b1cc12f2b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/PageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/PageLoader.kt @@ -17,6 +17,8 @@ abstract class PageLoader { abstract var isLocal: Boolean + open val usesTextReaderStyle: Boolean = false + /** * Returns the list of pages of a chapter. */ diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/PdfPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/PdfPageLoader.kt new file mode 100644 index 000000000..5770dcf2b --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/PdfPageLoader.kt @@ -0,0 +1,63 @@ +package eu.kanade.tachiyomi.ui.reader.loader + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Color +import android.graphics.pdf.PdfRenderer +import android.os.ParcelFileDescriptor +import com.hippo.unifile.UniFile +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.ui.reader.model.ReaderPage +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.InputStream + +internal class PdfPageLoader( + context: Context, + file: UniFile, +) : PageLoader() { + + override var isLocal: Boolean = true + + private val descriptor: ParcelFileDescriptor = + context.contentResolver.openFileDescriptor(file.uri, "r") + ?: error("Unable to open PDF: ${file.name}") + private val renderer = PdfRenderer(descriptor) + private val renderLock = Any() + + override suspend fun getPages(): List { + return (0 until renderer.pageCount).map { index -> + ReaderPage(index).apply { + stream = { renderPage(index) } + status = Page.State.Ready + } + } + } + + private fun renderPage(index: Int): InputStream = synchronized(renderLock) { + check(!isRecycled) + renderer.openPage(index).use { page -> + val scale = (1600f / page.width.coerceAtLeast(1)).coerceIn(1f, 2.5f) + val width = (page.width * scale).toInt().coerceAtLeast(1) + val height = (page.height * scale).toInt().coerceAtLeast(1) + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + bitmap.eraseColor(Color.WHITE) + page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) + + val output = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.PNG, 100, output) + bitmap.recycle() + ByteArrayInputStream(output.toByteArray()) + } + } + + override fun recycle() { + if (!isRecycled) { + synchronized(renderLock) { + renderer.close() + descriptor.close() + } + } + super.recycle() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/TextPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/TextPageLoader.kt new file mode 100644 index 000000000..37e0dca6d --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/TextPageLoader.kt @@ -0,0 +1,51 @@ +package eu.kanade.tachiyomi.ui.reader.loader + +import com.hippo.unifile.UniFile +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.ui.reader.model.ReaderPage +import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle +import java.nio.ByteBuffer +import java.nio.charset.CharacterCodingException +import java.nio.charset.Charset +import java.nio.charset.CodingErrorAction +import java.nio.charset.StandardCharsets + +internal class TextPageLoader( + private val file: UniFile, + private val style: TextReaderStyle, +) : PageLoader() { + + override var isLocal: Boolean = true + override val usesTextReaderStyle: Boolean = true + + override suspend fun getPages(): List { + val bytes = file.openInputStream().use { it.readBytes() } + val text = decodeText(bytes) + return TextPageRenderer.paginate(text, style).mapIndexed { index, pageText -> + ReaderPage(index).apply { + stream = { TextPageRenderer.render(pageText, style) } + status = Page.State.Ready + } + } + } + + private fun decodeText(bytes: ByteArray): String { + if (bytes.size >= 3 && + bytes[0] == 0xEF.toByte() && + bytes[1] == 0xBB.toByte() && + bytes[2] == 0xBF.toByte() + ) { + return String(bytes, 3, bytes.size - 3, StandardCharsets.UTF_8) + } + + return try { + StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(bytes)) + .toString() + } catch (_: CharacterCodingException) { + String(bytes, Charset.forName("MS949")) + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/TextPageRenderer.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/TextPageRenderer.kt new file mode 100644 index 000000000..b55d00825 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/TextPageRenderer.kt @@ -0,0 +1,114 @@ +package eu.kanade.tachiyomi.ui.reader.loader + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Typeface +import android.text.Layout +import android.text.Spannable +import android.text.SpannableString +import android.text.StaticLayout +import android.text.TextPaint +import android.text.style.LineHeightSpan +import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.InputStream + +internal object TextPageRenderer { + private const val PAGE_WIDTH = 1440 + private const val PAGE_HEIGHT = 2160 + private const val MARGIN = 96 + private const val CONTENT_WIDTH = PAGE_WIDTH - (MARGIN * 2) + private const val CONTENT_HEIGHT = PAGE_HEIGHT - (MARGIN * 2) + + fun paginate(text: String, style: TextReaderStyle): List { + if (text.isBlank()) return emptyList() + + val layout = createLayout(text, style) + if (layout.lineCount == 0) return listOf(text) + + val pages = mutableListOf() + var startLine = 0 + while (startLine < layout.lineCount) { + val startTop = layout.getLineTop(startLine) + var endLine = startLine + while ( + endLine + 1 < layout.lineCount && + layout.getLineBottom(endLine + 1) - startTop <= CONTENT_HEIGHT + ) { + endLine++ + } + + val start = layout.getLineStart(startLine) + val end = layout.getLineEnd(endLine).coerceAtLeast(start + 1).coerceAtMost(text.length) + text.substring(start, end).trim().takeIf { it.isNotEmpty() }?.let(pages::add) + startLine = endLine + 1 + } + return pages.ifEmpty { listOf(text) } + } + + fun render(text: String, style: TextReaderStyle): InputStream { + val bitmap = Bitmap.createBitmap(PAGE_WIDTH, PAGE_HEIGHT, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + canvas.drawColor(Color.WHITE) + canvas.save() + canvas.translate(MARGIN.toFloat(), MARGIN.toFloat()) + createLayout(text, style).draw(canvas) + canvas.restore() + + val output = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.PNG, 100, output) + bitmap.recycle() + return ByteArrayInputStream(output.toByteArray()) + } + + private fun createLayout(text: String, style: TextReaderStyle): StaticLayout { + val paint = TextPaint().apply { + isAntiAlias = true + color = Color.BLACK + textSize = style.fontSize.toFloat() + typeface = style.fontFamily.androidFamilyName + ?.let { Typeface.create(it, Typeface.NORMAL) } + ?: Typeface.DEFAULT + } + val styledText = addParagraphSpacing(text, style.paragraphSpacing) + return StaticLayout.Builder + .obtain(styledText, 0, styledText.length, paint, CONTENT_WIDTH) + .setAlignment(Layout.Alignment.ALIGN_NORMAL) + .setIncludePad(true) + .setLineSpacing(0f, style.lineSpacingPercent / 100f) + .build() + } + + private fun addParagraphSpacing(text: String, spacing: Int): CharSequence { + if (spacing <= 0 || '\n' !in text) return text + val spannable = SpannableString(text) + text.forEachIndexed { index, char -> + if (char == '\n') { + spannable.setSpan( + ParagraphSpacingSpan(spacing), + index, + (index + 1).coerceAtMost(text.length), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + } + } + return spannable + } + + private class ParagraphSpacingSpan(private val spacing: Int) : LineHeightSpan { + override fun chooseHeight( + text: CharSequence?, + start: Int, + end: Int, + spanstartv: Int, + v: Int, + fm: Paint.FontMetricsInt, + ) { + fm.descent += spacing + fm.bottom += spacing + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderPreferences.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderPreferences.kt index f2b3f9243..75824cfd7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderPreferences.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderPreferences.kt @@ -108,6 +108,28 @@ class ReaderPreferences( val webtoonDisableZoomOut: Preference = preferenceStore.getBoolean("webtoon_disable_zoom_out", false) + // region Text reader + + val textFontSize: Preference = preferenceStore.getInt("reader_text_font_size", 44) + + val textLineSpacing: Preference = preferenceStore.getInt("reader_text_line_spacing", 115) + + val textParagraphSpacing: Preference = preferenceStore.getInt("reader_text_paragraph_spacing", 12) + + val textFontFamily: Preference = preferenceStore.getEnum( + "reader_text_font_family", + TextFontFamily.SYSTEM, + ) + + fun textReaderStyle() = TextReaderStyle( + fontSize = textFontSize.get(), + lineSpacingPercent = textLineSpacing.get(), + paragraphSpacing = textParagraphSpacing.get(), + fontFamily = textFontFamily.get(), + ) + + // endregion + // endregion // region Split two-page spread @@ -217,6 +239,13 @@ class ReaderPreferences( // endregion + enum class TextFontFamily(val androidFamilyName: String?) { + SYSTEM(null), + SANS_SERIF("sans-serif"), + SERIF("serif"), + MONOSPACE("monospace"), + } + enum class FlashColor { BLACK, WHITE, @@ -342,3 +371,11 @@ class ReaderPreferences( } } } + + +data class TextReaderStyle( + val fontSize: Int, + val lineSpacingPercent: Int, + val paragraphSpacing: Int, + val fontFamily: ReaderPreferences.TextFontFamily, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderSettingsViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderSettingsViewModel.kt index 2ceca6db4..0927f738a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderSettingsViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderSettingsViewModel.kt @@ -13,6 +13,7 @@ class ReaderSettingsViewModel( readerState: StateFlow, val onChangeReadingMode: (ReadingMode) -> Unit, val onChangeOrientation: (ReaderOrientation) -> Unit, + val onTextSettingsChanged: () -> Unit, val preferences: ReaderPreferences, ) : ViewModel() { diff --git a/app/src/main/java/mihon/domain/source/interactor/UpdateMangaFromRemote.kt b/app/src/main/java/mihon/domain/source/interactor/UpdateMangaFromRemote.kt index cb047d69f..5d62e5935 100644 --- a/app/src/main/java/mihon/domain/source/interactor/UpdateMangaFromRemote.kt +++ b/app/src/main/java/mihon/domain/source/interactor/UpdateMangaFromRemote.kt @@ -8,7 +8,10 @@ import eu.kanade.domain.manga.model.toSManga import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.bookoasis.fetchBookOasisChapters +import eu.kanade.tachiyomi.source.bookoasis.isBookOasisSource import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.source.online.HttpSource import logcat.LogPriority import mihon.domain.source.models.RemoteMangaUpdate import tachiyomi.core.common.util.lang.withIOContext @@ -70,8 +73,28 @@ class UpdateMangaFromRemote( ) } awaitUpdateFromSource(manga, update.manga, manualFetch) + + val rawSourceChapters = if ( + fetchChapters && + source is HttpSource && + source.isBookOasisSource() + ) { + try { + withIOContext { + source.fetchBookOasisChapters(manga.toSManga()) + } + } catch (e: Exception) { + logcat(LogPriority.ERROR, e) { + "BookOasis document chapter compatibility fallback failed" + } + update.chapters + } + } else { + update.chapters + } + val newChapters = syncChaptersWithSource.await( - rawSourceChapters = update.chapters, + rawSourceChapters = rawSourceChapters, manga = manga, source = source, manualFetch = manualFetch, diff --git a/core/archive/src/main/kotlin/mihon/core/archive/EpubReader.kt b/core/archive/src/main/kotlin/mihon/core/archive/EpubReader.kt index b6b0c79f5..5f1d56499 100644 --- a/core/archive/src/main/kotlin/mihon/core/archive/EpubReader.kt +++ b/core/archive/src/main/kotlin/mihon/core/archive/EpubReader.kt @@ -34,6 +34,27 @@ class EpubReader(private val reader: ArchiveReader) : Closeable by reader { return getImagesFromPages(pages, ref) } + /** + * Returns readable text for each spine document in reading order. + */ + fun getTextFromPages(): List { + val ref = getPackageHref() + val doc = getPackageDocument(ref) + val basePath = getParentDirectory(ref) + return getPagesFromDocument(doc).mapNotNull { page -> + val entryPath = resolveZipPath(basePath, page) + getInputStream(entryPath)?.use { input -> + val pageDoc = Jsoup.parse(input, null, "") + pageDoc.select("br").append("\n") + pageDoc.select("p,div,li,h1,h2,h3,h4,h5,h6,blockquote").append("\n") + pageDoc.body()?.wholeText() + ?.replace(Regex("[ \\t]+\\n"), "\n") + ?.replace(Regex("\\n{3,}"), "\n\n") + ?.trim() + } + } + } + /** * Returns the path to the package document. */ diff --git a/i18n/src/commonMain/moko-resources/base/strings.xml b/i18n/src/commonMain/moko-resources/base/strings.xml index 9cfe0ffc1..e26f90702 100644 --- a/i18n/src/commonMain/moko-resources/base/strings.xml +++ b/i18n/src/commonMain/moko-resources/base/strings.xml @@ -1099,4 +1099,16 @@ Regardless, Mihon will continue to be maintained by me and the volunteers, and it will stay open source. Discord Dismiss + + + Text + Font size + Line spacing + Paragraph spacing + Font + System + Sans-serif + Serif + Monospace + EPUB and TXT use these typography settings. PDF keeps its original page layout. diff --git a/i18n/src/commonMain/moko-resources/ko/strings.xml b/i18n/src/commonMain/moko-resources/ko/strings.xml index 9990dfb18..37e61e38d 100644 --- a/i18n/src/commonMain/moko-resources/ko/strings.xml +++ b/i18n/src/commonMain/moko-resources/ko/strings.xml @@ -907,4 +907,16 @@ 비활성화하면 이미 설치한 확장 앱은 등급과 무관하게 컨텐츠를 불러오고 업데이트합니다 허용하지 않은 등급의 확장 앱은 이미 설치된 경우 목록에 남지만 소스는 표시되지 않으며, 설치하지 않은 경우 목록에서 숨겨집니다. 이 설정으로도 비공식 확장 앱이나 등급이 잘못 지정된 확장 앱이 앱에 18+ 콘텐츠를 표시하는 것을 완전히 막을 수는 없습니다. 필터링됨 + + + 텍스트 + 글자 크기 + 행간 + 문단 간격 + 글꼴 + 시스템 + 고딕 + 명조 + 고정폭 + EPUB/TXT에는 이 글꼴 설정이 적용됩니다. PDF는 원본 페이지 레이아웃을 유지합니다. diff --git a/source-local/src/main/kotlin/tachiyomi/source/local/LocalSource.kt b/source-local/src/main/kotlin/tachiyomi/source/local/LocalSource.kt index 79e876eec..09f0a7320 100644 --- a/source-local/src/main/kotlin/tachiyomi/source/local/LocalSource.kt +++ b/source-local/src/main/kotlin/tachiyomi/source/local/LocalSource.kt @@ -290,7 +290,13 @@ class LocalSource( val chapters = fileSystem.getFilesInMangaDirectory(manga.url) // Only keep supported formats .filterNot { it.name.orEmpty().startsWith('.') } - .filter { it.isDirectory || Archive.isSupported(it) || it.extension.equals("epub", true) } + .filter { + it.isDirectory || + Archive.isSupported(it) || + it.extension.equals("epub", true) || + it.extension.equals("pdf", true) || + it.extension.equals("txt", true) + } .map { chapterFile -> SChapter.create().apply { url = "${manga.url}/${chapterFile.name}" @@ -385,6 +391,9 @@ class LocalSource( entry?.let { coverManager.update(manga, epub.getInputStream(it)!!) } } } + is Format.Pdf, + is Format.Text, + -> null } } catch (e: Throwable) { logcat(LogPriority.ERROR, e) { "Error updating cover for ${manga.title}" } diff --git a/source-local/src/main/kotlin/tachiyomi/source/local/io/Format.kt b/source-local/src/main/kotlin/tachiyomi/source/local/io/Format.kt index ad53d407c..c27c70f98 100644 --- a/source-local/src/main/kotlin/tachiyomi/source/local/io/Format.kt +++ b/source-local/src/main/kotlin/tachiyomi/source/local/io/Format.kt @@ -8,6 +8,8 @@ sealed interface Format { data class Directory(val file: UniFile) : Format data class Archive(val file: UniFile) : Format data class Epub(val file: UniFile) : Format + data class Pdf(val file: UniFile) : Format + data class Text(val file: UniFile) : Format class UnknownFormatException : Exception() @@ -16,6 +18,8 @@ sealed interface Format { fun valueOf(file: UniFile) = when { file.isDirectory -> Directory(file) file.extension.equals("epub", true) -> Epub(file) + file.extension.equals("pdf", true) -> Pdf(file) + file.extension.equals("txt", true) -> Text(file) isArchiveSupported(file) -> Archive(file) else -> throw UnknownFormatException() }