4 Commits

Author SHA1 Message Date
javara999 fe73aff860 Fix EPUB and TXT document downloads
Release / Extract tag name (push) Waiting to run
Release / Build (push) Blocked by required conditions
Release / Build (FOSS) (push) Blocked by required conditions
Release / Create GitHub Release (push) Blocked by required conditions
Update website / update_website (release) Waiting to run
Build & Test / Build & Test App (push) Has been cancelled
2026-09-21 13:29:23 +09:00
javara999 a347cd10b8 Add release APK artifacts
Update website / update_website (release) Waiting to run
Build & Test / Build & Test App (push) Has been cancelled
Release / Extract tag name (push) Has been cancelled
Release / Build (push) Has been cancelled
Release / Build (FOSS) (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
2026-09-21 11:11:15 +09:00
javara999 849148691a Support EPUB and TXT downloads from Kavita and BookOasis
Build & Test / Build & Test App (push) Has been cancelled
2026-09-21 11:02:48 +09:00
javara999 c0e0ef1575 Improve live text reader repagination
Build & Test / Build & Test App (push) Has been cancelled
2026-09-19 20:12:09 +09:00
21 changed files with 393 additions and 121 deletions
@@ -53,7 +53,7 @@ internal fun ColumnScope.GeneralPage(viewModel: ReaderSettingsViewModel) {
selected = readerTheme == value, selected = readerTheme == value,
onClick = { onClick = {
viewModel.preferences.readerTheme.set(value) viewModel.preferences.readerTheme.set(value)
viewModel.requestTextSettingsReload() viewModel.requestTextAppearanceReload()
}, },
label = { Text(stringResource(labelRes)) }, label = { Text(stringResource(labelRes)) },
) )
@@ -374,10 +374,13 @@ class DownloadCache(
when { when {
// Ignore incomplete downloads // Ignore incomplete downloads
it.name?.endsWith(Downloader.TMP_DIR_SUFFIX) == true -> null it.name?.endsWith(Downloader.TMP_DIR_SUFFIX) == true -> null
it.name?.contains(".download.", ignoreCase = true) == true -> null
// Folder of images // Folder of images
it.isDirectory -> it.name it.isDirectory -> it.name
// CBZ files // Archived or original document files
it.isFile && it.extension == "cbz" -> it.nameWithoutExtension it.isFile && it.extension?.lowercase()?.let { extension ->
extension == "cbz" || extension in DOCUMENT_DOWNLOAD_EXTENSIONS
} == true -> it.nameWithoutExtension
// Anything else is irrelevant // Anything else is irrelevant
else -> null else -> null
} }
@@ -405,8 +405,12 @@ class DownloadManager(
.firstOrNull() ?: return .firstOrNull() ?: return
var newName = provider.getChapterDirName(newChapter.name, newChapter.scanlator, newChapter.url) var newName = provider.getChapterDirName(newChapter.name, newChapter.scanlator, newChapter.url)
if (oldDownload.isFile && oldDownload.extension == "cbz") { if (oldDownload.isFile) {
newName += ".cbz" oldDownload.extension?.lowercase()?.let { extension ->
if (extension == "cbz" || extension in DOCUMENT_DOWNLOAD_EXTENSIONS) {
newName += ".$extension"
}
}
} }
if (oldDownload.name == newName) return if (oldDownload.name == newName) return
@@ -19,6 +19,8 @@ import tachiyomi.domain.storage.service.StorageManager
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import java.io.IOException import java.io.IOException
internal val DOCUMENT_DOWNLOAD_EXTENSIONS = setOf("epub", "txt")
/** /**
* This class is used to provide the directories where the downloads should be saved. * This class is used to provide the directories where the downloads should be saved.
* It uses the following path scheme: /<root downloads dir>/<source name>/<manga>/<chapter> * It uses the following path scheme: /<root downloads dir>/<source name>/<manga>/<chapter>
@@ -250,11 +252,17 @@ class DownloadProvider(
add(chapterDirName) add(chapterDirName)
// Archived chapters // Archived chapters
add("$chapterDirName.cbz") add("$chapterDirName.cbz")
DOCUMENT_DOWNLOAD_EXTENSIONS.forEach { extension ->
add("$chapterDirName.$extension")
}
// any legacy names // any legacy names
legacyChapterDirNames.forEach { legacyChapterDirNames.forEach {
add(it) add(it)
add("$it.cbz") add("$it.cbz")
DOCUMENT_DOWNLOAD_EXTENSIONS.forEach { extension ->
add("$it.$extension")
}
} }
} }
} }
@@ -12,8 +12,16 @@ import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.library.LibraryUpdateNotifier import eu.kanade.tachiyomi.data.library.LibraryUpdateNotifier
import eu.kanade.tachiyomi.data.notification.NotificationHandler import eu.kanade.tachiyomi.data.notification.NotificationHandler
import eu.kanade.tachiyomi.network.HttpException import eu.kanade.tachiyomi.network.HttpException
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.source.UnmeteredSource import eu.kanade.tachiyomi.source.UnmeteredSource
import eu.kanade.tachiyomi.source.bookoasis.bookOasisDocumentDownloadRequest
import eu.kanade.tachiyomi.source.bookoasis.isBookOasisSource
import eu.kanade.tachiyomi.source.bookoasis.toBookOasisChapterRef
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.kavita.detectKavitaDocumentFormat
import eu.kanade.tachiyomi.source.kavita.isKavitaSource
import eu.kanade.tachiyomi.source.kavita.kavitaDocumentDownloadRequest
import eu.kanade.tachiyomi.source.kavita.toKavitaChapterId
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.storage.DiskUtil.NOMEDIA_FILE import eu.kanade.tachiyomi.util.storage.DiskUtil.NOMEDIA_FILE
@@ -44,6 +52,7 @@ import kotlinx.coroutines.supervisorScope
import logcat.LogPriority import logcat.LogPriority
import mihon.core.archive.ZipWriter import mihon.core.archive.ZipWriter
import nl.adaptivity.xmlutil.serialization.XML import nl.adaptivity.xmlutil.serialization.XML
import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.util.lang.launchIO import tachiyomi.core.common.util.lang.launchIO
@@ -339,9 +348,15 @@ class Downloader(
download.chapter.scanlator, download.chapter.scanlator,
download.chapter.url, download.chapter.url,
) )
val tmpDir = mangaDir.createDirectory(chapterDirname + TMP_DIR_SUFFIX)!!
try { try {
val remoteDocument = findRemoteDocument(download)
if (remoteDocument != null) {
downloadDocument(download, mangaDir, chapterDirname, remoteDocument)
return
}
val tmpDir = mangaDir.createDirectory(chapterDirname + TMP_DIR_SUFFIX)!!
// If the page list already exists, start from the file // If the page list already exists, start from the file
val pageList = download.pages ?: run { val pageList = download.pages ?: run {
// Otherwise, pull page list from network and add them to download object // Otherwise, pull page list from network and add them to download object
@@ -415,6 +430,85 @@ class Downloader(
} }
} }
private suspend fun findRemoteDocument(download: Download): RemoteDocument? {
val source = download.source
return when {
source.isBookOasisSource() -> download.chapter.url
.toBookOasisChapterRef()
?.takeIf { it.format in DOCUMENT_DOWNLOAD_EXTENSIONS }
?.let { ref -> RemoteDocument(ref.format, source.bookOasisDocumentDownloadRequest(ref)) }
source.isKavitaSource() -> download.chapter.url
.toKavitaChapterId()
?.let { chapterId ->
source.detectKavitaDocumentFormat(chapterId)?.let { format ->
RemoteDocument(format, source.kavitaDocumentDownloadRequest(chapterId))
}
}
else -> null
}
}
private suspend fun downloadDocument(
download: Download,
mangaDir: UniFile,
chapterDirname: String,
document: RemoteDocument,
) {
val targetName = "$chapterDirname.${document.extension}"
val tempName = "$chapterDirname.download.${document.extension}"
mangaDir.findFile(tempName)?.delete()
val temp = mangaDir.createFile(tempName)
?: error("Failed to create temporary document file")
val page = Page(0).apply {
status = Page.State.DownloadImage
}
download.pages = listOf(page)
download.status = Download.State.DOWNLOADING
notifier.onProgressChange(download)
try {
download.source.client.newCall(document.request).await().use { response ->
if (!response.isSuccessful) {
throw IllegalStateException(
"Document download failed: HTTP ${response.code}",
)
}
temp.openOutputStream().use { output ->
response.body.byteStream().use { input -> input.copyTo(output) }
}
}
if (temp.length() <= 0L) {
throw IllegalStateException("Document download returned an empty file")
}
mangaDir.findFile(targetName)?.delete()
if (!temp.renameTo(targetName)) {
val target = mangaDir.createFile(targetName)
?: error("Failed to create downloaded document file")
temp.openInputStream().use { input ->
target.openOutputStream().use { output ->
input.copyTo(output)
}
}
temp.delete()
}
} catch (error: Throwable) {
temp.delete()
throw error
}
val target = mangaDir.findFile(targetName)
?: error("Downloaded document file is missing")
page.uri = target.uri
page.status = Page.State.Ready
page.progress = 100
notifier.onProgressChange(download)
cache.addChapter(chapterDirname, mangaDir, download.manga)
DiskUtil.createNoMediaFile(mangaDir, context)
download.status = Download.State.DOWNLOADED
}
/** /**
* Gets the image from the filesystem if it exists or downloads it otherwise. * Gets the image from the filesystem if it exists or downloads it otherwise.
* *
@@ -740,5 +834,10 @@ class Downloader(
} }
} }
private data class RemoteDocument(
val extension: String,
val request: Request,
)
// Arbitrary minimum required space to start a download: 200 MB // Arbitrary minimum required space to start a download: 200 MB
private const val MIN_DISK_SPACE = 200L * 1024 * 1024 private const val MIN_DISK_SPACE = 200L * 1024 * 1024
@@ -21,13 +21,40 @@ internal data class BookOasisChapterRef(
val format: String, val format: String,
) )
private fun HttpSource.bookOasisServerBase(): String {
val normalized = baseUrl.trimEnd('/')
return when {
normalized.endsWith("/app-opds-adult", ignoreCase = true) ->
normalized.dropLast("/app-opds-adult".length)
normalized.endsWith("/app-opds", ignoreCase = true) ->
normalized.dropLast("/app-opds".length)
else -> normalized
}
}
internal fun HttpSource.bookOasisDocumentDownloadRequest(ref: BookOasisChapterRef): Request {
val downloadUrl = buildString {
append(bookOasisServerBase())
append("/app-opds/download/")
append(ref.dbType)
append('/')
append(ref.bookId)
}
return Request.Builder()
.url(downloadUrl)
.headers(headers)
.get()
.build()
}
internal fun Source.isBookOasisSource(): Boolean { internal fun Source.isBookOasisSource(): Boolean {
return this::class.java.name.startsWith(BOOKOASIS_PACKAGE_PREFIX) return this::class.java.name.startsWith(BOOKOASIS_PACKAGE_PREFIX)
} }
internal fun String.toBookOasisChapterRef(): BookOasisChapterRef? { internal fun String.toBookOasisChapterRef(): BookOasisChapterRef? {
val path = substringBefore('?') val path = substringBefore('?')
val match = Regex("""^/api/media/books/(\d+)/info$""").matchEntire(path) ?: return null val match = Regex("""^/(?:app-opds(?:-adult)?/)?api/media/books/(\d+)/info$""").matchEntire(path) ?: return null
val params = parseQueryParameters(this) val params = parseQueryParameters(this)
val format = params["bo_format"]?.lowercase() ?: return null val format = params["bo_format"]?.lowercase() ?: return null
if (format !in DOCUMENT_FORMATS) return null if (format !in DOCUMENT_FORMATS) return null
@@ -1,12 +1,61 @@
package eu.kanade.tachiyomi.source.kavita package eu.kanade.tachiyomi.source.kavita
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.online.HttpSource
import okhttp3.Request
import org.json.JSONObject
import java.util.Locale
private const val KAVITA_PACKAGE_PREFIX = "eu.kanade.tachiyomi.extension.all.kavita." private const val KAVITA_PACKAGE_PREFIX = "eu.kanade.tachiyomi.extension.all.kavita."
internal fun Source.isKavitaSource(): Boolean = internal fun Source.isKavitaSource(): Boolean =
this::class.java.name.startsWith(KAVITA_PACKAGE_PREFIX) this::class.java.name.startsWith(KAVITA_PACKAGE_PREFIX)
private fun HttpSource.kavitaApiBase(): String {
val normalized = baseUrl.trimEnd('/')
return if (normalized.endsWith("/api", ignoreCase = true)) normalized else "$normalized/api"
}
internal suspend fun HttpSource.detectKavitaDocumentFormat(chapterId: Int): String? {
val apiBase = kavitaApiBase()
val request = Request.Builder()
.url("$apiBase/Chapter?chapterId=$chapterId")
.headers(headers)
.get()
.build()
client.newCall(request).await().use { response ->
if (!response.isSuccessful) return null
val body = response.body.string()
if (body.isBlank()) return null
val files = JSONObject(body).optJSONArray("files") ?: return null
for (i in 0 until files.length()) {
val file = files.optJSONObject(i) ?: continue
val extension = file.optString("extension")
.ifBlank { file.optString("filePath") }
.substringAfterLast('.')
.trim()
.trimStart('.')
.lowercase(Locale.ROOT)
if (extension == "epub" || extension == "txt") return extension
if (file.optInt("format", -1) == 3) return "epub"
}
}
return null
}
internal fun HttpSource.kavitaDocumentDownloadRequest(chapterId: Int): Request {
val apiBase = kavitaApiBase()
return Request.Builder()
.url("$apiBase/Download/chapter?chapterId=$chapterId")
.headers(headers)
.get()
.build()
}
/** /**
* Kavita extension chapter URLs are normally /Chapter/{id}. Keep compatibility * Kavita extension chapter URLs are normally /Chapter/{id}. Keep compatibility
* with older/alternate forms used by reading lists and previous releases. * with older/alternate forms used by reading lists and previous releases.
@@ -57,6 +57,7 @@ import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.storage.cacheImageDir import eu.kanade.tachiyomi.util.storage.cacheImageDir
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -186,6 +187,12 @@ class ReaderViewModel(
*/ */
private var loader: ChapterLoader? = null private var loader: ChapterLoader? = null
/**
* Only the newest text repagination request is allowed to finish. Slider
* changes can arrive faster than a large TXT/EPUB chapter can be rebuilt.
*/
private var textRepaginationJob: Job? = null
/** /**
* The time the chapter was started reading * The time the chapter was started reading
*/ */
@@ -841,17 +848,26 @@ class ReaderViewModel(
if (chapter.pageLoader?.usesTextReaderStyle != true) return if (chapter.pageLoader?.usesTextReaderStyle != true) return
if (chapter.pages?.any { it is TextReaderPage } != true) return if (chapter.pages?.any { it is TextReaderPage } != true) return
val chapterLoader = loader ?: 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 { val oldPageCount = chapter.pages?.size?.coerceAtLeast(1) ?: 1
val requestedPage = chapter.requestedPage.coerceIn(0, oldPageCount - 1)
// Anchor at the middle of the visible page so position remains stable
// when font size/spacing changes the total page count.
val readingProgress = ((requestedPage + 0.5f) / oldPageCount)
.coerceIn(0f, 0.999999f)
textRepaginationJob?.cancel()
textRepaginationJob = viewModelScope.launchIO {
try { try {
val oldLoader = chapterLoader.reloadTextChapter(chapter, readingProgress) ?: return@launchIO val oldLoader = chapterLoader.reloadTextChapter(chapter, readingProgress) ?: return@launchIO
eventChannel.send(Event.ReloadTextViewerChapters)
// Give old visible holders time to detach before closing resources such as EpubReader. // Resource cleanup must survive cancellation by the next slider value.
delay(2000) viewModelScope.launchNonCancellable {
oldLoader.recycle() delay(750)
oldLoader.recycle()
}
eventChannel.trySend(Event.ReloadTextViewerChapters)
} catch (e: Throwable) { } catch (e: Throwable) {
if (e is CancellationException) throw e if (e is CancellationException) throw e
logcat(LogPriority.ERROR, e) { "Failed to repaginate text reader style" } logcat(LogPriority.ERROR, e) { "Failed to repaginate text reader style" }
@@ -4,11 +4,11 @@ import android.content.Context
import com.hippo.unifile.UniFile import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.network.await import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.source.bookoasis.BookOasisChapterRef import eu.kanade.tachiyomi.source.bookoasis.BookOasisChapterRef
import eu.kanade.tachiyomi.source.bookoasis.bookOasisDocumentDownloadRequest
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
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 okhttp3.Request
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
@@ -68,19 +68,7 @@ internal class BookOasisFilePageLoader(
val temp = File(cacheDir, "${target.name}.part") val temp = File(cacheDir, "${target.name}.part")
if (temp.exists()) temp.delete() if (temp.exists()) temp.delete()
val downloadUrl = buildString { val request = source.bookOasisDocumentDownloadRequest(ref)
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 -> source.client.newCall(request).await().use { response ->
if (!response.isSuccessful) { if (!response.isSuccessful) {
@@ -22,7 +22,8 @@ import tachiyomi.domain.source.model.StubSource
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.source.local.LocalSource import tachiyomi.source.local.LocalSource
import tachiyomi.source.local.io.Format import tachiyomi.source.local.io.Format
import kotlin.math.roundToInt import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
/** /**
* Loader used to retrieve the [PageLoader] for a given chapter. * Loader used to retrieve the [PageLoader] for a given chapter.
@@ -90,10 +91,14 @@ class ChapterLoader(
throw Exception(context.stringResource(MR.strings.page_list_empty_error)) throw Exception(context.stringResource(MR.strings.page_list_empty_error))
} }
// If a newer slider value cancelled this rebuild while getPages()
// was working, do not publish stale pages into the active chapter.
currentCoroutineContext().ensureActive()
val oldLoader = chapter.pageLoader val oldLoader = chapter.pageLoader
chapter.pageLoader = newLoader chapter.pageLoader = newLoader
chapter.requestedPage = (readingProgress * pages.lastIndex) chapter.requestedPage = (readingProgress.coerceIn(0f, 0.999999f) * pages.size)
.roundToInt() .toInt()
.coerceIn(0, pages.lastIndex) .coerceIn(0, pages.lastIndex)
chapter.state = ReaderChapter.State.Loaded(pages) chapter.state = ReaderChapter.State.Loaded(pages)
oldLoader oldLoader
@@ -11,7 +11,10 @@ import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import mihon.core.archive.archiveReader import mihon.core.archive.archiveReader
import mihon.core.archive.epubReader
import tachiyomi.core.common.storage.extension
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
/** /**
@@ -27,9 +30,12 @@ internal class DownloadPageLoader(
private val context: Context by injectLazy() private val context: Context by injectLazy()
private var archivePageLoader: ArchivePageLoader? = null private var pageLoader: PageLoader? = null
private val readerPreferences: ReaderPreferences by injectLazy()
override var isLocal: Boolean = true override var isLocal: Boolean = true
override val usesTextReaderStyle: Boolean
get() = pageLoader?.usesTextReaderStyle == true
override suspend fun getPages(): List<ReaderPage> { override suspend fun getPages(): List<ReaderPage> {
val dbChapter = chapter.chapter val dbChapter = chapter.chapter
@@ -41,7 +47,11 @@ internal class DownloadPageLoader(
source, source,
) )
return if (chapterPath?.isFile == true) { return if (chapterPath?.isFile == true) {
getPagesFromArchive(chapterPath) when (chapterPath.extension?.lowercase()) {
"epub" -> getPagesFromEpub(chapterPath)
"txt" -> getPagesFromText(chapterPath)
else -> getPagesFromArchive(chapterPath)
}
} else { } else {
getPagesFromDirectory() getPagesFromDirectory()
} }
@@ -49,11 +59,30 @@ internal class DownloadPageLoader(
override fun recycle() { override fun recycle() {
super.recycle() super.recycle()
archivePageLoader?.recycle() pageLoader?.recycle()
pageLoader = null
} }
private suspend fun getPagesFromArchive(file: UniFile): List<ReaderPage> { private suspend fun getPagesFromArchive(file: UniFile): List<ReaderPage> {
val loader = ArchivePageLoader(file.archiveReader(context)).also { archivePageLoader = it } val loader = ArchivePageLoader(file.archiveReader(context)).also { pageLoader = it }
return loader.getPages()
}
private suspend fun getPagesFromEpub(file: UniFile): List<ReaderPage> {
val loader = EpubPageLoader(
file.epubReader(context),
readerPreferences.textReaderStyle(context),
context.cacheDir,
).also { pageLoader = it }
return loader.getPages()
}
private suspend fun getPagesFromText(file: UniFile): List<ReaderPage> {
val loader = TextPageLoader(
file,
readerPreferences.textReaderStyle(context),
context.cacheDir,
).also { pageLoader = it }
return loader.getPages() return loader.getPages()
} }
@@ -69,6 +98,6 @@ internal class DownloadPageLoader(
} }
override suspend fun loadPage(page: ReaderPage) { override suspend fun loadPage(page: ReaderPage) {
archivePageLoader?.loadPage(page) pageLoader?.loadPage(page)
} }
} }
@@ -4,16 +4,15 @@ import android.content.Context
import com.hippo.unifile.UniFile import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.data.cache.ChapterCache import eu.kanade.tachiyomi.data.cache.ChapterCache
import eu.kanade.tachiyomi.network.await import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.source.kavita.detectKavitaDocumentFormat
import eu.kanade.tachiyomi.source.kavita.kavitaDocumentDownloadRequest
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
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 okhttp3.Request
import org.json.JSONObject
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.util.Locale
/** /**
* Compatibility loader for Kavita document chapters. * Compatibility loader for Kavita document chapters.
@@ -42,7 +41,7 @@ internal class KavitaCompatPageLoader(
override suspend fun getPages(): List<ReaderPage> { override suspend fun getPages(): List<ReaderPage> {
check(!isRecycled) check(!isRecycled)
val format = detectDocumentFormat() val format = source.detectKavitaDocumentFormat(chapterId)
val loader = when (format) { val loader = when (format) {
"epub", "txt" -> createDocumentLoader(format) "epub", "txt" -> createDocumentLoader(format)
else -> HttpPageLoader(chapter, source, chapterCache) else -> HttpPageLoader(chapter, source, chapterCache)
@@ -67,51 +66,6 @@ internal class KavitaCompatPageLoader(
super.recycle() super.recycle()
} }
private suspend fun detectDocumentFormat(): String? {
val apiBase = source.baseUrl.trimEnd('/') + "/api"
val request = Request.Builder()
.url("$apiBase/Chapter?chapterId=$chapterId")
.headers(source.headers)
.get()
.build()
source.client.newCall(request).await().use { response ->
if (!response.isSuccessful) {
// Preserve the extension's normal behavior when chapter metadata
// cannot be queried. It may still be readable as image content.
return null
}
val body = response.body.string()
if (body.isBlank()) return null
val chapterJson = JSONObject(body)
val files = chapterJson.optJSONArray("files") ?: return null
for (i in 0 until files.length()) {
val file = files.optJSONObject(i) ?: continue
val extension = normalizeExtension(
file.optString("extension").ifBlank { file.optString("filePath") },
)
if (extension == "epub" || extension == "txt") {
return extension
}
// Current Kavita format enum: EPUB == 3. TXT has no dedicated
// enum value in upstream Kavita, so it is detected by extension.
if (file.optInt("format", -1) == 3) {
return "epub"
}
}
}
return null
}
private fun normalizeExtension(value: String): String =
value.substringAfterLast('.', value)
.trim()
.trimStart('.')
.lowercase(Locale.ROOT)
private suspend fun createDocumentLoader(format: String): PageLoader { private suspend fun createDocumentLoader(format: String): PageLoader {
val file = getOrDownloadDocument(format) val file = getOrDownloadDocument(format)
val uniFile = UniFile.fromFile(file) val uniFile = UniFile.fromFile(file)
@@ -132,12 +86,7 @@ internal class KavitaCompatPageLoader(
val temp = File(cacheDir, "${target.name}.part") val temp = File(cacheDir, "${target.name}.part")
if (temp.exists()) temp.delete() if (temp.exists()) temp.delete()
val apiBase = source.baseUrl.trimEnd('/') + "/api" val request = source.kavitaDocumentDownloadRequest(chapterId)
val request = Request.Builder()
.url("$apiBase/Download/chapter?chapterId=$chapterId")
.headers(source.headers)
.get()
.build()
source.client.newCall(request).await().use { response -> source.client.newCall(request).await().use { response ->
if (!response.isSuccessful) { if (!response.isSuccessful) {
@@ -16,13 +16,10 @@ import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.InputStream import java.io.InputStream
import java.io.Reader import java.io.Reader
import kotlin.math.roundToInt
internal object TextPageRenderer { internal object TextPageRenderer {
private const val PAGE_WIDTH = 1440 private const val MARGIN_DP = 32f
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)
private const val STREAM_BUFFER_CHARS = 64 * 1024 private const val STREAM_BUFFER_CHARS = 64 * 1024
private const val STREAM_READ_CHARS = 8 * 1024 private const val STREAM_READ_CHARS = 8 * 1024
@@ -101,7 +98,8 @@ internal object TextPageRenderer {
private fun calculatePageSlices(text: String, style: TextReaderStyle): List<PageSlice> { private fun calculatePageSlices(text: String, style: TextReaderStyle): List<PageSlice> {
if (text.isEmpty()) return emptyList() if (text.isEmpty()) return emptyList()
val layout = createLayout(text, style) val metrics = resolveLayoutMetrics(style)
val layout = createLayout(text, style, metrics.contentWidth)
if (layout.lineCount == 0) return listOf(PageSlice(0, text.length)) if (layout.lineCount == 0) return listOf(PageSlice(0, text.length))
val pages = mutableListOf<PageSlice>() val pages = mutableListOf<PageSlice>()
@@ -111,7 +109,7 @@ internal object TextPageRenderer {
var endLine = startLine var endLine = startLine
while ( while (
endLine + 1 < layout.lineCount && endLine + 1 < layout.lineCount &&
layout.getLineBottom(endLine + 1) - startTop <= CONTENT_HEIGHT layout.getLineBottom(endLine + 1) - startTop <= metrics.contentHeight
) { ) {
endLine++ endLine++
} }
@@ -125,13 +123,14 @@ internal object TextPageRenderer {
} }
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 metrics = resolveLayoutMetrics(style)
val bitmap = Bitmap.createBitmap(metrics.pageWidth, metrics.pageHeight, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap) val canvas = Canvas(bitmap)
val (backgroundColor, _) = resolveColors(style) val (backgroundColor, _) = resolveColors(style)
canvas.drawColor(backgroundColor) canvas.drawColor(backgroundColor)
canvas.save() canvas.save()
canvas.translate(MARGIN.toFloat(), MARGIN.toFloat()) canvas.translate(metrics.horizontalMargin.toFloat(), metrics.verticalMargin.toFloat())
createLayout(text, style).draw(canvas) createLayout(text, style, metrics.contentWidth).draw(canvas)
canvas.restore() canvas.restore()
val output = ByteArrayOutputStream() val output = ByteArrayOutputStream()
@@ -140,25 +139,46 @@ internal object TextPageRenderer {
return ByteArrayInputStream(output.toByteArray()) return ByteArrayInputStream(output.toByteArray())
} }
private fun createLayout(text: String, style: TextReaderStyle): StaticLayout { private fun createLayout(
text: String,
style: TextReaderStyle,
contentWidth: Int,
): StaticLayout {
val (_, textColor) = resolveColors(style) val (_, textColor) = resolveColors(style)
val paint = TextPaint().apply { val paint = TextPaint().apply {
isAntiAlias = true isAntiAlias = true
color = textColor color = textColor
textSize = style.fontSize * 3f textSize = style.fontSizePx.coerceAtLeast(1f)
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 * 3) val styledText = addParagraphSpacing(text, style.paragraphSpacingPx)
return StaticLayout.Builder return StaticLayout.Builder
.obtain(styledText, 0, styledText.length, paint, CONTENT_WIDTH) .obtain(styledText, 0, styledText.length, paint, contentWidth)
.setAlignment(Layout.Alignment.ALIGN_NORMAL) .setAlignment(Layout.Alignment.ALIGN_NORMAL)
.setIncludePad(true) .setIncludePad(true)
.setLineSpacing(0f, style.lineSpacingPercent / 100f) .setLineSpacing(0f, style.lineSpacingPercent / 100f)
.build() .build()
} }
private fun resolveLayoutMetrics(style: TextReaderStyle): LayoutMetrics {
val pageWidth = style.pageWidthPx.coerceAtLeast(1)
val pageHeight = style.pageHeightPx.coerceAtLeast(1)
val requestedMargin = (MARGIN_DP * style.density).roundToInt().coerceAtLeast(1)
val horizontalMargin = requestedMargin.coerceAtMost(((pageWidth - 1) / 2).coerceAtLeast(0))
val verticalMargin = requestedMargin.coerceAtMost(((pageHeight - 1) / 2).coerceAtLeast(0))
return LayoutMetrics(
pageWidth = pageWidth,
pageHeight = pageHeight,
horizontalMargin = horizontalMargin,
verticalMargin = verticalMargin,
contentWidth = (pageWidth - horizontalMargin * 2).coerceAtLeast(1),
contentHeight = (pageHeight - verticalMargin * 2).coerceAtLeast(1),
)
}
private fun resolveColors(style: TextReaderStyle): Pair<Int, Int> { private fun resolveColors(style: TextReaderStyle): Pair<Int, Int> {
val useDark = when (style.readerTheme) { val useDark = when (style.readerTheme) {
0 -> false 0 -> false
@@ -205,6 +225,15 @@ internal object TextPageRenderer {
} }
} }
private data class LayoutMetrics(
val pageWidth: Int,
val pageHeight: Int,
val horizontalMargin: Int,
val verticalMargin: Int,
val contentWidth: Int,
val contentHeight: Int,
)
private data class PageSlice( private data class PageSlice(
val start: Int, val start: Int,
val endExclusive: Int, val endExclusive: Int,
@@ -5,9 +5,8 @@ import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle
/** /**
* A rendered text page whose style can be changed without rebuilding the chapter. * A rendered text page whose visual style can be refreshed cheaply while layout-affecting
* Page boundaries remain stable during the live preview; a full repagination is done * changes trigger a debounced chapter repagination to rebuild page boundaries.
* once when the settings dialog is dismissed.
*/ */
internal class TextReaderPage( internal class TextReaderPage(
index: Int, index: Int,
@@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.ui.reader.setting
import android.content.Context import android.content.Context
import android.content.res.Configuration import android.content.res.Configuration
import android.os.Build import android.os.Build
import android.util.TypedValue
import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.BlendMode
import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.StringResource
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
@@ -137,15 +138,44 @@ class ReaderPreferences(
} }
} }
fun textReaderStyle(context: Context) = TextReaderStyle( fun textReaderStyle(context: Context): TextReaderStyle {
fontSize = textFontSize.get(), val displayMetrics = context.resources.displayMetrics
lineSpacingPercent = textLineSpacing.get(), val rawWidth = displayMetrics.widthPixels.coerceAtLeast(1)
paragraphSpacing = textParagraphSpacing.get(), val rawHeight = displayMetrics.heightPixels.coerceAtLeast(1)
fontFamily = textFontFamily.get(),
readerTheme = readerTheme.get(), // Keep the same physical page proportions as the device while capping the
automaticDark = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == // raster size so high-resolution phones do not create oversized bitmaps.
Configuration.UI_MODE_NIGHT_YES, val renderScale = minOf(
) 1f,
TEXT_READER_MAX_RENDER_WIDTH / rawWidth.toFloat(),
TEXT_READER_MAX_RENDER_HEIGHT / rawHeight.toFloat(),
)
return 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,
pageWidthPx = (rawWidth * renderScale).roundToInt().coerceAtLeast(1),
pageHeightPx = (rawHeight * renderScale).roundToInt().coerceAtLeast(1),
density = displayMetrics.density * renderScale,
fontSizePx = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_SP,
textFontSize.get().toFloat(),
displayMetrics,
) * renderScale,
paragraphSpacingPx = (
TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
textParagraphSpacing.get().toFloat(),
displayMetrics,
) * renderScale
).roundToInt(),
)
}
// endregion // endregion
@@ -336,6 +366,9 @@ class ReaderPreferences(
const val WEBTOON_PADDING_MIN = 0 const val WEBTOON_PADDING_MIN = 0
const val WEBTOON_PADDING_MAX = 25 const val WEBTOON_PADDING_MAX = 25
private const val TEXT_READER_MAX_RENDER_WIDTH = 1440
private const val TEXT_READER_MAX_RENDER_HEIGHT = 2400
const val MILLI_CONVERSION = 100 const val MILLI_CONVERSION = 100
val TapZones = listOf( val TapZones = listOf(
@@ -399,4 +432,9 @@ data class TextReaderStyle(
val fontFamily: ReaderPreferences.TextFontFamily, val fontFamily: ReaderPreferences.TextFontFamily,
val readerTheme: Int, val readerTheme: Int,
val automaticDark: Boolean, val automaticDark: Boolean,
val pageWidthPx: Int,
val pageHeightPx: Int,
val density: Float,
val fontSizePx: Float,
val paragraphSpacingPx: Int,
) )
@@ -32,20 +32,49 @@ class ReaderSettingsViewModel(
.stateIn(viewModelScope, SharingStarted.Lazily, null) .stateIn(viewModelScope, SharingStarted.Lazily, null)
private var textSettingsReloadJob: Job? = null private var textSettingsReloadJob: Job? = null
private var textAppearanceReloadJob: Job? = null
private var textSettingsDirty = false private var textSettingsDirty = false
/**
* Layout-affecting text settings are previewed quickly, then the chapter is
* repaginated after a short debounce. This keeps the slider responsive while
* ensuring the number of characters/lines on a page follows the new style.
*/
fun requestTextSettingsReload() { fun requestTextSettingsReload() {
textSettingsDirty = true textSettingsDirty = true
textSettingsReloadJob?.cancel() textSettingsReloadJob?.cancel()
textSettingsReloadJob = viewModelScope.launch { textSettingsReloadJob = viewModelScope.launch {
delay(200) delay(250)
onTextSettingsPreview() onTextSettingsPreview()
textSettingsDirty = false
onTextSettingsCommit()
textSettingsReloadJob = null textSettingsReloadJob = null
} }
} }
/**
* Theme-only changes do not affect page boundaries, so avoid an unnecessary
* full repagination.
*/
fun requestTextAppearanceReload() {
textAppearanceReloadJob?.cancel()
textAppearanceReloadJob = viewModelScope.launch {
delay(50)
onTextSettingsPreview()
textAppearanceReloadJob = null
}
}
fun flushTextSettingsReload() { fun flushTextSettingsReload() {
if (!textSettingsDirty) return val hadPendingAppearanceReload = textAppearanceReloadJob != null
textAppearanceReloadJob?.cancel()
textAppearanceReloadJob = null
if (!textSettingsDirty) {
if (hadPendingAppearanceReload) {
onTextSettingsPreview()
}
return
}
textSettingsReloadJob?.cancel() textSettingsReloadJob?.cancel()
textSettingsReloadJob = null textSettingsReloadJob = null
onTextSettingsPreview() onTextSettingsPreview()
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.