From 6391c9376c02bccdd07c813ce57f40252039348f Mon Sep 17 00:00:00 2001 From: javara999 Date: Sat, 19 Sep 2026 19:39:48 +0900 Subject: [PATCH] Improve text reader streaming and theming --- app/build.gradle.kts | 1 + .../reader/settings/GeneralSettingsPage.kt | 5 +- .../reader/settings/TextSettingsPage.kt | 12 +- .../tachiyomi/ui/reader/ReaderViewModel.kt | 2 +- .../reader/loader/BookOasisFilePageLoader.kt | 4 +- .../ui/reader/loader/ChapterLoader.kt | 8 +- .../ui/reader/loader/EpubPageLoader.kt | 123 ++++++++++++++++-- .../reader/loader/KavitaCompatPageLoader.kt | 4 +- .../ui/reader/loader/TextPageLoader.kt | 99 +++++++++++--- .../ui/reader/loader/TextPageRenderer.kt | 116 +++++++++++++++-- .../ui/reader/loader/TextPageStore.kt | 69 ++++++++++ .../ui/reader/loader/TextReaderPage.kt | 10 +- .../ui/reader/setting/ReaderPreferences.kt | 27 +++- .../reader/setting/ReaderSettingsViewModel.kt | 2 +- 14 files changed, 419 insertions(+), 63 deletions(-) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/TextPageStore.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 380c4a708..34dbe522b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -216,6 +216,7 @@ dependencies { implementation(projects.coreMetadata) implementation(projects.sourceApi) implementation(projects.sourceLocal) + implementation(libs.jsoup) implementation(projects.data) implementation(projects.domain) implementation(projects.presentationCore) diff --git a/app/src/main/java/eu/kanade/presentation/reader/settings/GeneralSettingsPage.kt b/app/src/main/java/eu/kanade/presentation/reader/settings/GeneralSettingsPage.kt index 28948767f..bec343237 100644 --- a/app/src/main/java/eu/kanade/presentation/reader/settings/GeneralSettingsPage.kt +++ b/app/src/main/java/eu/kanade/presentation/reader/settings/GeneralSettingsPage.kt @@ -51,7 +51,10 @@ internal fun ColumnScope.GeneralPage(viewModel: ReaderSettingsViewModel) { themes.map { (labelRes, value) -> FilterChip( selected = readerTheme == value, - onClick = { viewModel.preferences.readerTheme.set(value) }, + onClick = { + viewModel.preferences.readerTheme.set(value) + viewModel.requestTextSettingsReload() + }, label = { Text(stringResource(labelRes)) }, ) } 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 index e192278d8..9c832a569 100644 --- a/app/src/main/java/eu/kanade/presentation/reader/settings/TextSettingsPage.kt +++ b/app/src/main/java/eu/kanade/presentation/reader/settings/TextSettingsPage.kt @@ -31,9 +31,9 @@ internal fun ColumnScope.TextSettingsPage(viewModel: ReaderSettingsViewModel) { SliderItem( label = stringResource(MR.strings.pref_text_reader_font_size), value = fontSize, - valueRange = 32..72, + valueRange = 10..30, steps = 19, - valueString = fontSize.toString(), + valueString = "$fontSize sp", onChange = { value -> fontSizePref.set(value) viewModel.requestTextSettingsReload() @@ -48,7 +48,7 @@ internal fun ColumnScope.TextSettingsPage(viewModel: ReaderSettingsViewModel) { value = lineSpacing, valueRange = 90..180, steps = 17, - valueString = "$lineSpacing%", + valueString = String.format("%.2fx", lineSpacing / 100f), onChange = { value -> lineSpacingPref.set(value) viewModel.requestTextSettingsReload() @@ -61,9 +61,9 @@ internal fun ColumnScope.TextSettingsPage(viewModel: ReaderSettingsViewModel) { SliderItem( label = stringResource(MR.strings.pref_text_reader_paragraph_spacing), value = paragraphSpacing, - valueRange = 0..48, - steps = 11, - valueString = paragraphSpacing.toString(), + valueRange = 0..16, + steps = 15, + valueString = "$paragraphSpacing dp", onChange = { value -> paragraphSpacingPref.set(value) viewModel.requestTextSettingsReload() 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 5149301f9..9ef474a14 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 @@ -828,7 +828,7 @@ class ReaderViewModel( fun previewTextReaderStyle() { val chapter = state.value.currentChapter ?: return if (chapter.pageLoader?.usesTextReaderStyle != true) return - val style = readerPreferences.textReaderStyle() + val style = readerPreferences.textReaderStyle(context) val textPages = chapter.pages?.filterIsInstance().orEmpty() if (textPages.isEmpty()) return 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 index 4c0564305..2a928c5e8 100644 --- 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 @@ -34,9 +34,9 @@ internal class BookOasisFilePageLoader( 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) + "epub" -> EpubPageLoader(uniFile.epubReader(context), textStyle, context.cacheDir) "pdf" -> PdfPageLoader(context, uniFile) - "txt" -> TextPageLoader(uniFile, textStyle) + "txt" -> TextPageLoader(uniFile, textStyle, context.cacheDir) else -> error("Unsupported BookOasis document format: ${ref.format}") } 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 b824be5d8..c839c1c7d 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 @@ -138,13 +138,13 @@ 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), readerPreferences.textReaderStyle()) + is Format.Epub -> EpubPageLoader(format.file.epubReader(context), readerPreferences.textReaderStyle(context), context.cacheDir) is Format.Pdf -> PdfPageLoader(context, format.file) - is Format.Text -> TextPageLoader(format.file, readerPreferences.textReaderStyle()) + is Format.Text -> TextPageLoader(format.file, readerPreferences.textReaderStyle(context), context.cacheDir) } } source is HttpSource && source.isBookOasisSource() && bookOasisRef != null -> { - BookOasisFilePageLoader(context, source, bookOasisRef, readerPreferences.textReaderStyle()) + BookOasisFilePageLoader(context, source, bookOasisRef, readerPreferences.textReaderStyle(context)) } source is HttpSource && source.isKavitaSource() && kavitaChapterId != null -> { KavitaCompatPageLoader( @@ -153,7 +153,7 @@ class ChapterLoader( source = source, chapterCache = chapterCache, chapterId = kavitaChapterId, - textStyle = readerPreferences.textReaderStyle(), + textStyle = readerPreferences.textReaderStyle(context), ) } source is HttpSource -> HttpPageLoader(chapter, source, chapterCache) 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 87f0c2028..1b6f820a4 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 @@ -4,38 +4,135 @@ 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 +import org.jsoup.Jsoup +import java.io.File +import java.io.InputStream +import java.io.StringReader /** * 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. + * Text-based EPUBs are rendered as regular Mihon pages. EPUB spine documents + * are processed one at a time so the full book text is never retained in heap. + * Image-only EPUBs keep the original image extraction behavior. */ internal class EpubPageLoader( private val reader: EpubReader, private val style: TextReaderStyle, + private val cacheDir: File, ) : PageLoader() { override var isLocal: Boolean = true override val usesTextReaderStyle: Boolean = true + private var pageStore: TextPageStore? = null + override suspend fun getPages(): List { - val textPages = reader.getTextFromPages() - .filter { it.isNotBlank() } - .flatMap { TextPageRenderer.paginate(it, style) } + check(!isRecycled) - if (textPages.isNotEmpty()) { - return textPages.mapIndexed { index, text -> - TextReaderPage(index, text, style) + pageStore?.close() + pageStore = null + + val store = TextPageStore(cacheDir) + return try { + val textPages = buildTextPages(store) + if (textPages.isNotEmpty()) { + store.finishWriting() + pageStore = store + textPages + } else { + store.close() + reader.getImagesFromPages().mapIndexed { index, path -> + ReaderPage(index).apply { + stream = { reader.getInputStream(path)!! } + status = Page.State.Ready + } + } + } + } catch (e: Throwable) { + store.close() + throw e + } + } + + private fun buildTextPages(store: TextPageStore): MutableList { + val packageHref = reader.getPackageHref() + val packageDocument = reader.getPackageDocument(packageHref) + val manifest = packageDocument.select("manifest > item") + .filter { item -> + val mediaType = item.attr("media-type") + mediaType == "application/xhtml+xml" || mediaType == "text/html" + } + .associateBy { it.attr("id") } + val spine = packageDocument.select("spine > itemref") + .map { it.attr("idref") } + + val result = mutableListOf() + spine.forEach { id -> + val href = manifest[id]?.attr("href").orEmpty() + if (href.isBlank()) return@forEach + + openEntry(packageHref, href)?.use { input -> + val text = extractText(input) + if (text.isBlank()) return@use + + StringReader(text).use { textReader -> + TextPageRenderer.paginate(textReader, style) { pageText -> + val ref = store.append(pageText) + result += TextReaderPage( + index = result.size, + pageTextProvider = { store.read(ref) }, + initialStyle = style, + ) + } + } } } + return result + } - return reader.getImagesFromPages().mapIndexed { index, path -> - ReaderPage(index).apply { - stream = { reader.getInputStream(path)!! } - status = Page.State.Ready + private fun extractText(input: InputStream): String { + 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") + return pageDoc.body().wholeText() + ?.replace(Regex("[ \\t]+\\n"), "\n") + ?.replace(Regex("\\n{3,}"), "\n\n") + ?.trim() + .orEmpty() + } + + private fun openEntry(packageHref: String, href: String): InputStream? { + val resolved = resolveEntryPath(packageHref, href) + val candidates = linkedSetOf( + resolved, + resolved.replace('\\', '/'), + resolved.replace('/', '\\'), + ) + return candidates.firstNotNullOfOrNull { path -> + reader.getInputStream(path) + } + } + + private fun resolveEntryPath(packageHref: String, href: String): String { + val relative = href + .substringBefore('#') + .substringBefore('?') + .trimStart('/', '\\') + val separator = if ('\\' in packageHref) '\\' else '/' + val base = packageHref.substringBeforeLast(separator, "") + val combined = if (base.isBlank()) relative else "$base$separator$relative" + val parts = combined.split('/', '\\') + val normalized = ArrayDeque() + + parts.forEach { part -> + when (part) { + "", "." -> Unit + ".." -> if (normalized.isNotEmpty()) normalized.removeLast() + else -> normalized.addLast(part) } } + return normalized.joinToString(separator.toString()) } override suspend fun loadPage(page: ReaderPage) { @@ -43,6 +140,8 @@ internal class EpubPageLoader( } override fun recycle() { + pageStore?.close() + pageStore = null super.recycle() reader.close() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/KavitaCompatPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/KavitaCompatPageLoader.kt index c2af325aa..588659212 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/KavitaCompatPageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/KavitaCompatPageLoader.kt @@ -118,8 +118,8 @@ internal class KavitaCompatPageLoader( ?: throw IOException("Unable to open cached Kavita ${format.uppercase()} file") return when (format) { - "epub" -> EpubPageLoader(uniFile.epubReader(context), textStyle) - "txt" -> TextPageLoader(uniFile, textStyle) + "epub" -> EpubPageLoader(uniFile.epubReader(context), textStyle, context.cacheDir) + "txt" -> TextPageLoader(uniFile, textStyle, context.cacheDir) else -> error("Unsupported Kavita document format: $format") } } 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 index ec162c821..b12a23d48 100644 --- 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 @@ -3,8 +3,9 @@ package eu.kanade.tachiyomi.ui.reader.loader import com.hippo.unifile.UniFile 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.io.File +import java.io.InputStreamReader +import java.io.Reader import java.nio.charset.Charset import java.nio.charset.CodingErrorAction import java.nio.charset.StandardCharsets @@ -12,36 +13,94 @@ import java.nio.charset.StandardCharsets internal class TextPageLoader( private val file: UniFile, private val style: TextReaderStyle, + private val cacheDir: File, ) : PageLoader() { override var isLocal: Boolean = true override val usesTextReaderStyle: Boolean = true + private var pageStore: TextPageStore? = null + override suspend fun getPages(): List { - val bytes = file.openInputStream().use { it.readBytes() } - val text = decodeText(bytes) - return TextPageRenderer.paginate(text, style).mapIndexed { index, pageText -> - TextReaderPage(index, pageText, style) + pageStore?.close() + pageStore = null + + val store = TextPageStore(cacheDir) + return try { + val pages = mutableListOf() + openDecodedReader().use { reader -> + TextPageRenderer.paginate(reader, style) { pageText -> + val ref = store.append(pageText) + pages += TextReaderPage( + index = pages.size, + pageTextProvider = { store.read(ref) }, + initialStyle = style, + ) + } + } + store.finishWriting() + pageStore = store + pages + } catch (e: Throwable) { + store.close() + throw e } } - 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) + override fun recycle() { + pageStore?.close() + pageStore = null + super.recycle() + } + + private fun openDecodedReader(): Reader { + val charset = detectCharset() + return InputStreamReader(file.openInputStream().buffered(), charset) + } + + private fun detectCharset(): Charset { + val prefix = ByteArray(3) + val prefixSize = file.openInputStream().buffered().use { input -> + input.read(prefix) } + if ( + prefixSize >= 3 && + prefix[0] == 0xEF.toByte() && + prefix[1] == 0xBB.toByte() && + prefix[2] == 0xBF.toByte() + ) { + return StandardCharsets.UTF_8 + } + if (prefixSize >= 2 && prefix[0] == 0xFF.toByte() && prefix[1] == 0xFE.toByte()) { + return StandardCharsets.UTF_16LE + } + if (prefixSize >= 2 && prefix[0] == 0xFE.toByte() && prefix[1] == 0xFF.toByte()) { + return StandardCharsets.UTF_16BE + } + + return if (isValidUtf8()) { + StandardCharsets.UTF_8 + } else { + Charset.forName("MS949") + } + } + + private fun isValidUtf8(): Boolean { + val decoder = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + return try { - StandardCharsets.UTF_8.newDecoder() - .onMalformedInput(CodingErrorAction.REPORT) - .onUnmappableCharacter(CodingErrorAction.REPORT) - .decode(ByteBuffer.wrap(bytes)) - .toString() - } catch (_: CharacterCodingException) { - String(bytes, Charset.forName("MS949")) + InputStreamReader(file.openInputStream().buffered(), decoder).use { reader -> + val buffer = CharArray(8 * 1024) + while (reader.read(buffer) >= 0) { + // Validation only. Decoded characters are intentionally discarded. + } + } + true + } catch (_: Exception) { + false } } } 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 index b55d00825..f97aab33d 100644 --- 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 @@ -15,6 +15,7 @@ import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.InputStream +import java.io.Reader internal object TextPageRenderer { private const val PAGE_WIDTH = 1440 @@ -22,14 +23,88 @@ internal object TextPageRenderer { private const val MARGIN = 96 private const val CONTENT_WIDTH = PAGE_WIDTH - (MARGIN * 2) private const val CONTENT_HEIGHT = PAGE_HEIGHT - (MARGIN * 2) + private const val STREAM_BUFFER_CHARS = 64 * 1024 + private const val STREAM_READ_CHARS = 8 * 1024 fun paginate(text: String, style: TextReaderStyle): List { if (text.isBlank()) return emptyList() - val layout = createLayout(text, style) - if (layout.lineCount == 0) return listOf(text) + return calculatePageSlices(text, style) + .mapNotNull { slice -> + text.substring(slice.start, slice.endExclusive) + .trim() + .takeIf { it.isNotEmpty() } + } + .ifEmpty { listOf(text) } + } - val pages = mutableListOf() + /** + * Incrementally paginates a text stream while keeping only a bounded text + * window in memory. The final incomplete page is retained between windows + * so normal page filling is preserved across chunk boundaries. + */ + fun paginate( + reader: Reader, + style: TextReaderStyle, + onPage: (String) -> Unit, + ) { + val working = StringBuilder(STREAM_BUFFER_CHARS + STREAM_READ_CHARS) + val chars = CharArray(STREAM_READ_CHARS) + var firstChunk = true + + while (true) { + val read = reader.read(chars) + if (read < 0) break + if (read == 0) continue + + var offset = 0 + if (firstChunk) { + firstChunk = false + if (chars[0] == '\uFEFF') { + offset = 1 + } + } + if (offset < read) { + working.append(chars, offset, read - offset) + } + + if (working.length >= STREAM_BUFFER_CHARS) { + emitCompletePages(working, style, onPage) + } + } + + if (working.isNotEmpty()) { + paginate(working.toString(), style).forEach(onPage) + } + } + + private fun emitCompletePages( + working: StringBuilder, + style: TextReaderStyle, + onPage: (String) -> Unit, + ) { + val text = working.toString() + val slices = calculatePageSlices(text, style) + if (slices.size <= 1) return + + for (index in 0 until slices.lastIndex) { + val slice = slices[index] + text.substring(slice.start, slice.endExclusive) + .trim() + .takeIf { it.isNotEmpty() } + ?.let(onPage) + } + + working.delete(0, slices.last().start) + } + + private fun calculatePageSlices(text: String, style: TextReaderStyle): List { + if (text.isEmpty()) return emptyList() + + val layout = createLayout(text, style) + if (layout.lineCount == 0) return listOf(PageSlice(0, text.length)) + + val pages = mutableListOf() var startLine = 0 while (startLine < layout.lineCount) { val startTop = layout.getLineTop(startLine) @@ -43,16 +118,17 @@ internal object TextPageRenderer { 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) + pages += PageSlice(start, end) startLine = endLine + 1 } - return pages.ifEmpty { listOf(text) } + return pages.ifEmpty { listOf(PageSlice(0, text.length)) } } 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) + val (backgroundColor, _) = resolveColors(style) + canvas.drawColor(backgroundColor) canvas.save() canvas.translate(MARGIN.toFloat(), MARGIN.toFloat()) createLayout(text, style).draw(canvas) @@ -65,15 +141,16 @@ internal object TextPageRenderer { } private fun createLayout(text: String, style: TextReaderStyle): StaticLayout { + val (_, textColor) = resolveColors(style) val paint = TextPaint().apply { isAntiAlias = true - color = Color.BLACK - textSize = style.fontSize.toFloat() + color = textColor + textSize = style.fontSize * 3f typeface = style.fontFamily.androidFamilyName ?.let { Typeface.create(it, Typeface.NORMAL) } ?: Typeface.DEFAULT } - val styledText = addParagraphSpacing(text, style.paragraphSpacing) + val styledText = addParagraphSpacing(text, style.paragraphSpacing * 3) return StaticLayout.Builder .obtain(styledText, 0, styledText.length, paint, CONTENT_WIDTH) .setAlignment(Layout.Alignment.ALIGN_NORMAL) @@ -82,6 +159,22 @@ internal object TextPageRenderer { .build() } + private fun resolveColors(style: TextReaderStyle): Pair { + val useDark = when (style.readerTheme) { + 0 -> false + 1 -> true + 2 -> true + 3 -> style.automaticDark + else -> style.automaticDark + } + val background = when (style.readerTheme) { + 2 -> Color.rgb(48, 48, 48) + else -> if (useDark) Color.BLACK else Color.WHITE + } + val foreground = if (useDark) Color.WHITE else Color.BLACK + return background to foreground + } + private fun addParagraphSpacing(text: String, spacing: Int): CharSequence { if (spacing <= 0 || '\n' !in text) return text val spannable = SpannableString(text) @@ -111,4 +204,9 @@ internal object TextPageRenderer { fm.bottom += spacing } } + + private data class PageSlice( + val start: Int, + val endExclusive: Int, + ) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/TextPageStore.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/TextPageStore.kt new file mode 100644 index 000000000..08a99ede5 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/TextPageStore.kt @@ -0,0 +1,69 @@ +package eu.kanade.tachiyomi.ui.reader.loader + +import java.io.Closeable +import java.io.File +import java.io.RandomAccessFile +import java.nio.charset.StandardCharsets + +/** + * Disk-backed storage for text reader pages. + * + * Keeping page strings here avoids retaining an entire large TXT/EPUB in the + * Java heap. Individual pages are loaded only when the viewer renders them. + */ +internal class TextPageStore(cacheRoot: File) : Closeable { + + private val cacheDir = File(cacheRoot, "text_reader_pages").apply { + mkdirs() + pruneStaleFiles() + } + private val file = File.createTempFile("pages-", ".utf8", cacheDir) + private var writer: RandomAccessFile? = RandomAccessFile(file, "rw") + + @Synchronized + fun append(text: String): PageRef { + val output = checkNotNull(writer) { "Text page store is already finalized" } + val bytes = text.toByteArray(StandardCharsets.UTF_8) + val offset = output.filePointer + output.write(bytes) + return PageRef(offset, bytes.size) + } + + @Synchronized + fun finishWriting() { + writer?.fd?.sync() + writer?.close() + writer = null + } + + fun read(ref: PageRef): String { + val bytes = ByteArray(ref.byteLength) + RandomAccessFile(file, "r").use { input -> + input.seek(ref.offset) + input.readFully(bytes) + } + return String(bytes, StandardCharsets.UTF_8) + } + + override fun close() { + writer?.close() + writer = null + file.delete() + } + + internal data class PageRef( + val offset: Long, + val byteLength: Int, + ) + + private fun File.pruneStaleFiles() { + val cutoff = System.currentTimeMillis() - STALE_FILE_AGE_MS + listFiles() + ?.filter { it.isFile && it.lastModified() < cutoff } + ?.forEach { it.delete() } + } + + private companion object { + const val STALE_FILE_AGE_MS = 24L * 60L * 60L * 1000L + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/TextReaderPage.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/TextReaderPage.kt index dbf552bd3..c878128ed 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/TextReaderPage.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/TextReaderPage.kt @@ -11,7 +11,7 @@ import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle */ internal class TextReaderPage( index: Int, - private val pageText: String, + private val pageTextProvider: () -> String, initialStyle: TextReaderStyle, ) : ReaderPage(index) { @@ -19,10 +19,16 @@ internal class TextReaderPage( private var style: TextReaderStyle = initialStyle init { - stream = { TextPageRenderer.render(pageText, style) } + stream = { TextPageRenderer.render(pageTextProvider(), style) } status = Page.State.Ready } + constructor( + index: Int, + pageText: String, + initialStyle: TextReaderStyle, + ) : this(index, { pageText }, initialStyle) + fun updateStyle(newStyle: TextReaderStyle) { style = newStyle } 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 75824cfd7..74e122c94 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 @@ -1,5 +1,7 @@ package eu.kanade.tachiyomi.ui.reader.setting +import android.content.Context +import android.content.res.Configuration import android.os.Build import androidx.compose.ui.graphics.BlendMode import dev.icerock.moko.resources.StringResource @@ -11,6 +13,7 @@ import tachiyomi.core.common.preference.PreferenceStore import tachiyomi.core.common.preference.getEnum import tachiyomi.core.common.preference.getEnumSet import tachiyomi.i18n.MR +import kotlin.math.roundToInt @Inject @SingleIn(AppScope::class) @@ -110,22 +113,38 @@ class ReaderPreferences( // region Text reader - val textFontSize: Preference = preferenceStore.getInt("reader_text_font_size", 44) + val textFontSize: Preference = preferenceStore.getInt("reader_text_font_size", 16) val textLineSpacing: Preference = preferenceStore.getInt("reader_text_line_spacing", 115) - val textParagraphSpacing: Preference = preferenceStore.getInt("reader_text_paragraph_spacing", 12) + val textParagraphSpacing: Preference = preferenceStore.getInt("reader_text_paragraph_spacing", 4) val textFontFamily: Preference = preferenceStore.getEnum( "reader_text_font_family", TextFontFamily.SYSTEM, ) - fun textReaderStyle() = TextReaderStyle( + private val textUnitsMigrated = preferenceStore.getBoolean("reader_text_units_v2_migrated", false) + + init { + if (!textUnitsMigrated.get()) { + val legacyFontSize = textFontSize.get() + if (legacyFontSize > 30) { + textFontSize.set((legacyFontSize / 3f).roundToInt().coerceIn(10, 30)) + textParagraphSpacing.set((textParagraphSpacing.get() / 3f).roundToInt().coerceIn(0, 16)) + } + textUnitsMigrated.set(true) + } + } + + fun textReaderStyle(context: Context) = TextReaderStyle( fontSize = textFontSize.get(), lineSpacingPercent = textLineSpacing.get(), paragraphSpacing = textParagraphSpacing.get(), fontFamily = textFontFamily.get(), + readerTheme = readerTheme.get(), + automaticDark = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == + Configuration.UI_MODE_NIGHT_YES, ) // endregion @@ -378,4 +397,6 @@ data class TextReaderStyle( val lineSpacingPercent: Int, val paragraphSpacing: Int, val fontFamily: ReaderPreferences.TextFontFamily, + val readerTheme: Int, + val automaticDark: Boolean, ) 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 6f907a921..f9c23f1db 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 @@ -38,7 +38,7 @@ class ReaderSettingsViewModel( textSettingsDirty = true textSettingsReloadJob?.cancel() textSettingsReloadJob = viewModelScope.launch { - delay(100) + delay(200) onTextSettingsPreview() textSettingsReloadJob = null }