Improve text reader streaming and theming
Build & Test / Build & Test App (push) Has been cancelled
Update website / update_website (release) Has been cancelled

This commit is contained in:
2026-09-19 19:39:48 +09:00
parent dcec339477
commit 6391c9376c
14 changed files with 419 additions and 63 deletions
+1
View File
@@ -216,6 +216,7 @@ dependencies {
implementation(projects.coreMetadata) implementation(projects.coreMetadata)
implementation(projects.sourceApi) implementation(projects.sourceApi)
implementation(projects.sourceLocal) implementation(projects.sourceLocal)
implementation(libs.jsoup)
implementation(projects.data) implementation(projects.data)
implementation(projects.domain) implementation(projects.domain)
implementation(projects.presentationCore) implementation(projects.presentationCore)
@@ -51,7 +51,10 @@ internal fun ColumnScope.GeneralPage(viewModel: ReaderSettingsViewModel) {
themes.map { (labelRes, value) -> themes.map { (labelRes, value) ->
FilterChip( FilterChip(
selected = readerTheme == value, selected = readerTheme == value,
onClick = { viewModel.preferences.readerTheme.set(value) }, onClick = {
viewModel.preferences.readerTheme.set(value)
viewModel.requestTextSettingsReload()
},
label = { Text(stringResource(labelRes)) }, label = { Text(stringResource(labelRes)) },
) )
} }
@@ -31,9 +31,9 @@ internal fun ColumnScope.TextSettingsPage(viewModel: ReaderSettingsViewModel) {
SliderItem( SliderItem(
label = stringResource(MR.strings.pref_text_reader_font_size), label = stringResource(MR.strings.pref_text_reader_font_size),
value = fontSize, value = fontSize,
valueRange = 32..72, valueRange = 10..30,
steps = 19, steps = 19,
valueString = fontSize.toString(), valueString = "$fontSize sp",
onChange = { value -> onChange = { value ->
fontSizePref.set(value) fontSizePref.set(value)
viewModel.requestTextSettingsReload() viewModel.requestTextSettingsReload()
@@ -48,7 +48,7 @@ internal fun ColumnScope.TextSettingsPage(viewModel: ReaderSettingsViewModel) {
value = lineSpacing, value = lineSpacing,
valueRange = 90..180, valueRange = 90..180,
steps = 17, steps = 17,
valueString = "$lineSpacing%", valueString = String.format("%.2fx", lineSpacing / 100f),
onChange = { value -> onChange = { value ->
lineSpacingPref.set(value) lineSpacingPref.set(value)
viewModel.requestTextSettingsReload() viewModel.requestTextSettingsReload()
@@ -61,9 +61,9 @@ internal fun ColumnScope.TextSettingsPage(viewModel: ReaderSettingsViewModel) {
SliderItem( SliderItem(
label = stringResource(MR.strings.pref_text_reader_paragraph_spacing), label = stringResource(MR.strings.pref_text_reader_paragraph_spacing),
value = paragraphSpacing, value = paragraphSpacing,
valueRange = 0..48, valueRange = 0..16,
steps = 11, steps = 15,
valueString = paragraphSpacing.toString(), valueString = "$paragraphSpacing dp",
onChange = { value -> onChange = { value ->
paragraphSpacingPref.set(value) paragraphSpacingPref.set(value)
viewModel.requestTextSettingsReload() viewModel.requestTextSettingsReload()
@@ -828,7 +828,7 @@ class ReaderViewModel(
fun previewTextReaderStyle() { fun previewTextReaderStyle() {
val chapter = state.value.currentChapter ?: return val chapter = state.value.currentChapter ?: return
if (chapter.pageLoader?.usesTextReaderStyle != true) return if (chapter.pageLoader?.usesTextReaderStyle != true) return
val style = readerPreferences.textReaderStyle() val style = readerPreferences.textReaderStyle(context)
val textPages = chapter.pages?.filterIsInstance<TextReaderPage>().orEmpty() val textPages = chapter.pages?.filterIsInstance<TextReaderPage>().orEmpty()
if (textPages.isEmpty()) return if (textPages.isEmpty()) return
@@ -34,9 +34,9 @@ internal class BookOasisFilePageLoader(
val uniFile = UniFile.fromFile(file) val uniFile = UniFile.fromFile(file)
?: error("Unable to open cached BookOasis file: ${file.absolutePath}") ?: error("Unable to open cached BookOasis file: ${file.absolutePath}")
val loader = when (ref.format) { val loader = when (ref.format) {
"epub" -> EpubPageLoader(uniFile.epubReader(context), textStyle) "epub" -> EpubPageLoader(uniFile.epubReader(context), textStyle, context.cacheDir)
"pdf" -> PdfPageLoader(context, uniFile) "pdf" -> PdfPageLoader(context, uniFile)
"txt" -> TextPageLoader(uniFile, textStyle) "txt" -> TextPageLoader(uniFile, textStyle, context.cacheDir)
else -> error("Unsupported BookOasis document format: ${ref.format}") else -> error("Unsupported BookOasis document format: ${ref.format}")
} }
@@ -138,13 +138,13 @@ class ChapterLoader(
when (format) { when (format) {
is Format.Directory -> DirectoryPageLoader(format.file) is Format.Directory -> DirectoryPageLoader(format.file)
is Format.Archive -> ArchivePageLoader(format.file.archiveReader(context)) 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.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 -> { 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 -> { source is HttpSource && source.isKavitaSource() && kavitaChapterId != null -> {
KavitaCompatPageLoader( KavitaCompatPageLoader(
@@ -153,7 +153,7 @@ class ChapterLoader(
source = source, source = source,
chapterCache = chapterCache, chapterCache = chapterCache,
chapterId = kavitaChapterId, chapterId = kavitaChapterId,
textStyle = readerPreferences.textReaderStyle(), textStyle = readerPreferences.textReaderStyle(context),
) )
} }
source is HttpSource -> HttpPageLoader(chapter, source, chapterCache) source is HttpSource -> HttpPageLoader(chapter, source, chapterCache)
@@ -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.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle
import mihon.core.archive.EpubReader 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. * Loader used to load a chapter from a .epub file.
* *
* Text-based EPUBs are rendered as regular Mihon pages. Image-only EPUBs * Text-based EPUBs are rendered as regular Mihon pages. EPUB spine documents
* keep the original image extraction behavior. * 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( internal class EpubPageLoader(
private val reader: EpubReader, private val reader: EpubReader,
private val style: TextReaderStyle, private val style: TextReaderStyle,
private val cacheDir: File,
) : PageLoader() { ) : PageLoader() {
override var isLocal: Boolean = true override var isLocal: Boolean = true
override val usesTextReaderStyle: Boolean = true override val usesTextReaderStyle: Boolean = true
private var pageStore: TextPageStore? = null
override suspend fun getPages(): List<ReaderPage> { override suspend fun getPages(): List<ReaderPage> {
val textPages = reader.getTextFromPages() check(!isRecycled)
.filter { it.isNotBlank() }
.flatMap { TextPageRenderer.paginate(it, style) }
if (textPages.isNotEmpty()) { pageStore?.close()
return textPages.mapIndexed { index, text -> pageStore = null
TextReaderPage(index, text, style)
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<ReaderPage> {
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<ReaderPage>()
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 -> private fun extractText(input: InputStream): String {
ReaderPage(index).apply { val pageDoc = Jsoup.parse(input, null, "")
stream = { reader.getInputStream(path)!! } pageDoc.select("br").append("\n")
status = Page.State.Ready 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<String>()
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) { override suspend fun loadPage(page: ReaderPage) {
@@ -43,6 +140,8 @@ internal class EpubPageLoader(
} }
override fun recycle() { override fun recycle() {
pageStore?.close()
pageStore = null
super.recycle() super.recycle()
reader.close() reader.close()
} }
@@ -118,8 +118,8 @@ internal class KavitaCompatPageLoader(
?: throw IOException("Unable to open cached Kavita ${format.uppercase()} file") ?: throw IOException("Unable to open cached Kavita ${format.uppercase()} file")
return when (format) { return when (format) {
"epub" -> EpubPageLoader(uniFile.epubReader(context), textStyle) "epub" -> EpubPageLoader(uniFile.epubReader(context), textStyle, context.cacheDir)
"txt" -> TextPageLoader(uniFile, textStyle) "txt" -> TextPageLoader(uniFile, textStyle, context.cacheDir)
else -> error("Unsupported Kavita document format: $format") else -> error("Unsupported Kavita document format: $format")
} }
} }
@@ -3,8 +3,9 @@ package eu.kanade.tachiyomi.ui.reader.loader
import com.hippo.unifile.UniFile import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle
import java.nio.ByteBuffer import java.io.File
import java.nio.charset.CharacterCodingException import java.io.InputStreamReader
import java.io.Reader
import java.nio.charset.Charset import java.nio.charset.Charset
import java.nio.charset.CodingErrorAction import java.nio.charset.CodingErrorAction
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
@@ -12,36 +13,94 @@ import java.nio.charset.StandardCharsets
internal class TextPageLoader( internal class TextPageLoader(
private val file: UniFile, private val file: UniFile,
private val style: TextReaderStyle, private val style: TextReaderStyle,
private val cacheDir: File,
) : PageLoader() { ) : PageLoader() {
override var isLocal: Boolean = true override var isLocal: Boolean = true
override val usesTextReaderStyle: Boolean = true override val usesTextReaderStyle: Boolean = true
private var pageStore: TextPageStore? = null
override suspend fun getPages(): List<ReaderPage> { override suspend fun getPages(): List<ReaderPage> {
val bytes = file.openInputStream().use { it.readBytes() } pageStore?.close()
val text = decodeText(bytes) pageStore = null
return TextPageRenderer.paginate(text, style).mapIndexed { index, pageText ->
TextReaderPage(index, pageText, style) val store = TextPageStore(cacheDir)
return try {
val pages = mutableListOf<ReaderPage>()
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 { override fun recycle() {
if (bytes.size >= 3 && pageStore?.close()
bytes[0] == 0xEF.toByte() && pageStore = null
bytes[1] == 0xBB.toByte() && super.recycle()
bytes[2] == 0xBF.toByte() }
) {
return String(bytes, 3, bytes.size - 3, StandardCharsets.UTF_8) 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 { return try {
StandardCharsets.UTF_8.newDecoder() InputStreamReader(file.openInputStream().buffered(), decoder).use { reader ->
.onMalformedInput(CodingErrorAction.REPORT) val buffer = CharArray(8 * 1024)
.onUnmappableCharacter(CodingErrorAction.REPORT) while (reader.read(buffer) >= 0) {
.decode(ByteBuffer.wrap(bytes)) // Validation only. Decoded characters are intentionally discarded.
.toString() }
} catch (_: CharacterCodingException) { }
String(bytes, Charset.forName("MS949")) true
} catch (_: Exception) {
false
} }
} }
} }
@@ -15,6 +15,7 @@ import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.InputStream import java.io.InputStream
import java.io.Reader
internal object TextPageRenderer { internal object TextPageRenderer {
private const val PAGE_WIDTH = 1440 private const val PAGE_WIDTH = 1440
@@ -22,14 +23,88 @@ internal object TextPageRenderer {
private const val MARGIN = 96 private const val MARGIN = 96
private const val CONTENT_WIDTH = PAGE_WIDTH - (MARGIN * 2) private const val CONTENT_WIDTH = PAGE_WIDTH - (MARGIN * 2)
private const val CONTENT_HEIGHT = PAGE_HEIGHT - (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<String> { fun paginate(text: String, style: TextReaderStyle): List<String> {
if (text.isBlank()) return emptyList() if (text.isBlank()) return emptyList()
val layout = createLayout(text, style) return calculatePageSlices(text, style)
if (layout.lineCount == 0) return listOf(text) .mapNotNull { slice ->
text.substring(slice.start, slice.endExclusive)
.trim()
.takeIf { it.isNotEmpty() }
}
.ifEmpty { listOf(text) }
}
val pages = mutableListOf<String>() /**
* 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<PageSlice> {
if (text.isEmpty()) return emptyList()
val layout = createLayout(text, style)
if (layout.lineCount == 0) return listOf(PageSlice(0, text.length))
val pages = mutableListOf<PageSlice>()
var startLine = 0 var startLine = 0
while (startLine < layout.lineCount) { while (startLine < layout.lineCount) {
val startTop = layout.getLineTop(startLine) val startTop = layout.getLineTop(startLine)
@@ -43,16 +118,17 @@ internal object TextPageRenderer {
val start = layout.getLineStart(startLine) val start = layout.getLineStart(startLine)
val end = layout.getLineEnd(endLine).coerceAtLeast(start + 1).coerceAtMost(text.length) 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 startLine = endLine + 1
} }
return pages.ifEmpty { listOf(text) } return pages.ifEmpty { listOf(PageSlice(0, text.length)) }
} }
fun render(text: String, style: TextReaderStyle): InputStream { fun render(text: String, style: TextReaderStyle): InputStream {
val bitmap = Bitmap.createBitmap(PAGE_WIDTH, PAGE_HEIGHT, Bitmap.Config.ARGB_8888) val bitmap = Bitmap.createBitmap(PAGE_WIDTH, PAGE_HEIGHT, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap) val canvas = Canvas(bitmap)
canvas.drawColor(Color.WHITE) val (backgroundColor, _) = resolveColors(style)
canvas.drawColor(backgroundColor)
canvas.save() canvas.save()
canvas.translate(MARGIN.toFloat(), MARGIN.toFloat()) canvas.translate(MARGIN.toFloat(), MARGIN.toFloat())
createLayout(text, style).draw(canvas) createLayout(text, style).draw(canvas)
@@ -65,15 +141,16 @@ internal object TextPageRenderer {
} }
private fun createLayout(text: String, style: TextReaderStyle): StaticLayout { private fun createLayout(text: String, style: TextReaderStyle): StaticLayout {
val (_, textColor) = resolveColors(style)
val paint = TextPaint().apply { val paint = TextPaint().apply {
isAntiAlias = true isAntiAlias = true
color = Color.BLACK color = textColor
textSize = style.fontSize.toFloat() textSize = style.fontSize * 3f
typeface = style.fontFamily.androidFamilyName typeface = style.fontFamily.androidFamilyName
?.let { Typeface.create(it, Typeface.NORMAL) } ?.let { Typeface.create(it, Typeface.NORMAL) }
?: Typeface.DEFAULT ?: Typeface.DEFAULT
} }
val styledText = addParagraphSpacing(text, style.paragraphSpacing) val styledText = addParagraphSpacing(text, style.paragraphSpacing * 3)
return StaticLayout.Builder return StaticLayout.Builder
.obtain(styledText, 0, styledText.length, paint, CONTENT_WIDTH) .obtain(styledText, 0, styledText.length, paint, CONTENT_WIDTH)
.setAlignment(Layout.Alignment.ALIGN_NORMAL) .setAlignment(Layout.Alignment.ALIGN_NORMAL)
@@ -82,6 +159,22 @@ internal object TextPageRenderer {
.build() .build()
} }
private fun resolveColors(style: TextReaderStyle): Pair<Int, Int> {
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 { private fun addParagraphSpacing(text: String, spacing: Int): CharSequence {
if (spacing <= 0 || '\n' !in text) return text if (spacing <= 0 || '\n' !in text) return text
val spannable = SpannableString(text) val spannable = SpannableString(text)
@@ -111,4 +204,9 @@ internal object TextPageRenderer {
fm.bottom += spacing fm.bottom += spacing
} }
} }
private data class PageSlice(
val start: Int,
val endExclusive: Int,
)
} }
@@ -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
}
}
@@ -11,7 +11,7 @@ import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle
*/ */
internal class TextReaderPage( internal class TextReaderPage(
index: Int, index: Int,
private val pageText: String, private val pageTextProvider: () -> String,
initialStyle: TextReaderStyle, initialStyle: TextReaderStyle,
) : ReaderPage(index) { ) : ReaderPage(index) {
@@ -19,10 +19,16 @@ internal class TextReaderPage(
private var style: TextReaderStyle = initialStyle private var style: TextReaderStyle = initialStyle
init { init {
stream = { TextPageRenderer.render(pageText, style) } stream = { TextPageRenderer.render(pageTextProvider(), style) }
status = Page.State.Ready status = Page.State.Ready
} }
constructor(
index: Int,
pageText: String,
initialStyle: TextReaderStyle,
) : this(index, { pageText }, initialStyle)
fun updateStyle(newStyle: TextReaderStyle) { fun updateStyle(newStyle: TextReaderStyle) {
style = newStyle style = newStyle
} }
@@ -1,5 +1,7 @@
package eu.kanade.tachiyomi.ui.reader.setting package eu.kanade.tachiyomi.ui.reader.setting
import android.content.Context
import android.content.res.Configuration
import android.os.Build import android.os.Build
import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.BlendMode
import dev.icerock.moko.resources.StringResource 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.getEnum
import tachiyomi.core.common.preference.getEnumSet import tachiyomi.core.common.preference.getEnumSet
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import kotlin.math.roundToInt
@Inject @Inject
@SingleIn(AppScope::class) @SingleIn(AppScope::class)
@@ -110,22 +113,38 @@ class ReaderPreferences(
// region Text reader // region Text reader
val textFontSize: Preference<Int> = preferenceStore.getInt("reader_text_font_size", 44) val textFontSize: Preference<Int> = preferenceStore.getInt("reader_text_font_size", 16)
val textLineSpacing: Preference<Int> = preferenceStore.getInt("reader_text_line_spacing", 115) val textLineSpacing: Preference<Int> = preferenceStore.getInt("reader_text_line_spacing", 115)
val textParagraphSpacing: Preference<Int> = preferenceStore.getInt("reader_text_paragraph_spacing", 12) val textParagraphSpacing: Preference<Int> = preferenceStore.getInt("reader_text_paragraph_spacing", 4)
val textFontFamily: Preference<TextFontFamily> = preferenceStore.getEnum( val textFontFamily: Preference<TextFontFamily> = preferenceStore.getEnum(
"reader_text_font_family", "reader_text_font_family",
TextFontFamily.SYSTEM, 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(), fontSize = textFontSize.get(),
lineSpacingPercent = textLineSpacing.get(), lineSpacingPercent = textLineSpacing.get(),
paragraphSpacing = textParagraphSpacing.get(), paragraphSpacing = textParagraphSpacing.get(),
fontFamily = textFontFamily.get(), fontFamily = textFontFamily.get(),
readerTheme = readerTheme.get(),
automaticDark = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) ==
Configuration.UI_MODE_NIGHT_YES,
) )
// endregion // endregion
@@ -378,4 +397,6 @@ data class TextReaderStyle(
val lineSpacingPercent: Int, val lineSpacingPercent: Int,
val paragraphSpacing: Int, val paragraphSpacing: Int,
val fontFamily: ReaderPreferences.TextFontFamily, val fontFamily: ReaderPreferences.TextFontFamily,
val readerTheme: Int,
val automaticDark: Boolean,
) )
@@ -38,7 +38,7 @@ class ReaderSettingsViewModel(
textSettingsDirty = true textSettingsDirty = true
textSettingsReloadJob?.cancel() textSettingsReloadJob?.cancel()
textSettingsReloadJob = viewModelScope.launch { textSettingsReloadJob = viewModelScope.launch {
delay(100) delay(200)
onTextSettingsPreview() onTextSettingsPreview()
textSettingsReloadJob = null textSettingsReloadJob = null
} }