feat: add BookOasis document reader and text settings
This commit is contained in:
@@ -9,6 +9,7 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.window.DialogWindowProvider
|
||||
@@ -29,13 +30,18 @@ fun ReaderSettingsDialog(
|
||||
stringResource(MR.strings.pref_category_reading_mode),
|
||||
stringResource(MR.strings.pref_category_general),
|
||||
stringResource(MR.strings.custom_filter),
|
||||
stringResource(MR.strings.pref_category_text_reader),
|
||||
)
|
||||
val pagerState = rememberPagerState { tabTitles.size }
|
||||
val initialTextStyle = remember { viewModel.preferences.textReaderStyle() }
|
||||
|
||||
BoxWithConstraints {
|
||||
TabbedDialog(
|
||||
modifier = Modifier.heightIn(max = maxHeight * 0.75f),
|
||||
onDismissRequest = {
|
||||
if (viewModel.preferences.textReaderStyle() != initialTextStyle) {
|
||||
viewModel.onTextSettingsChanged()
|
||||
}
|
||||
onDismissRequest()
|
||||
onShowMenus()
|
||||
},
|
||||
@@ -63,6 +69,7 @@ fun ReaderSettingsDialog(
|
||||
0 -> ReadingModePage(viewModel)
|
||||
1 -> GeneralPage(viewModel)
|
||||
2 -> ColorFilterPage(viewModel)
|
||||
3 -> TextSettingsPage(viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package eu.kanade.presentation.reader.settings
|
||||
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsViewModel
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.components.SettingsChipRow
|
||||
import tachiyomi.presentation.core.components.SliderItem
|
||||
import tachiyomi.presentation.core.i18n.stringResource
|
||||
import tachiyomi.presentation.core.util.collectAsState
|
||||
|
||||
private val fontFamilies = listOf(
|
||||
MR.strings.pref_text_reader_font_system to ReaderPreferences.TextFontFamily.SYSTEM,
|
||||
MR.strings.pref_text_reader_font_sans to ReaderPreferences.TextFontFamily.SANS_SERIF,
|
||||
MR.strings.pref_text_reader_font_serif to ReaderPreferences.TextFontFamily.SERIF,
|
||||
MR.strings.pref_text_reader_font_mono to ReaderPreferences.TextFontFamily.MONOSPACE,
|
||||
)
|
||||
|
||||
@Composable
|
||||
internal fun ColumnScope.TextSettingsPage(viewModel: ReaderSettingsViewModel) {
|
||||
val fontSizePref = viewModel.preferences.textFontSize
|
||||
val fontSize by fontSizePref.collectAsState()
|
||||
SliderItem(
|
||||
label = stringResource(MR.strings.pref_text_reader_font_size),
|
||||
value = fontSize,
|
||||
valueRange = 32..72,
|
||||
steps = 19,
|
||||
valueString = fontSize.toString(),
|
||||
onChange = fontSizePref::set,
|
||||
pillColor = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
)
|
||||
|
||||
val lineSpacingPref = viewModel.preferences.textLineSpacing
|
||||
val lineSpacing by lineSpacingPref.collectAsState()
|
||||
SliderItem(
|
||||
label = stringResource(MR.strings.pref_text_reader_line_spacing),
|
||||
value = lineSpacing,
|
||||
valueRange = 90..180,
|
||||
steps = 17,
|
||||
valueString = "$lineSpacing%",
|
||||
onChange = lineSpacingPref::set,
|
||||
pillColor = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
)
|
||||
|
||||
val paragraphSpacingPref = viewModel.preferences.textParagraphSpacing
|
||||
val paragraphSpacing by paragraphSpacingPref.collectAsState()
|
||||
SliderItem(
|
||||
label = stringResource(MR.strings.pref_text_reader_paragraph_spacing),
|
||||
value = paragraphSpacing,
|
||||
valueRange = 0..48,
|
||||
steps = 11,
|
||||
valueString = paragraphSpacing.toString(),
|
||||
onChange = paragraphSpacingPref::set,
|
||||
pillColor = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
)
|
||||
|
||||
val fontFamilyPref = viewModel.preferences.textFontFamily
|
||||
val fontFamily by fontFamilyPref.collectAsState()
|
||||
SettingsChipRow(MR.strings.pref_text_reader_font_family) {
|
||||
fontFamilies.forEach { (label, value) ->
|
||||
FilterChip(
|
||||
selected = fontFamily == value,
|
||||
onClick = { fontFamilyPref.set(value) },
|
||||
label = { Text(stringResource(label)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(MR.strings.pref_text_reader_pdf_note),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package eu.kanade.tachiyomi.source.bookoasis
|
||||
|
||||
import eu.kanade.tachiyomi.network.await
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
import java.net.URI
|
||||
import java.net.URLDecoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
private const val BOOKOASIS_PACKAGE_PREFIX = "eu.kanade.tachiyomi.extension.all.bookoasis."
|
||||
private val SUPPORTED_FORMATS = setOf("zip", "cbz", "imgdir", "epub", "pdf", "txt")
|
||||
private val DOCUMENT_FORMATS = setOf("epub", "pdf", "txt")
|
||||
|
||||
internal data class BookOasisChapterRef(
|
||||
val bookId: Long,
|
||||
val dbType: String,
|
||||
val format: String,
|
||||
)
|
||||
|
||||
internal fun Source.isBookOasisSource(): Boolean {
|
||||
return this::class.java.name.startsWith(BOOKOASIS_PACKAGE_PREFIX)
|
||||
}
|
||||
|
||||
internal fun String.toBookOasisChapterRef(): BookOasisChapterRef? {
|
||||
val path = substringBefore('?')
|
||||
val match = Regex("""^/api/media/books/(\d+)/info$""").matchEntire(path) ?: return null
|
||||
val params = parseQueryParameters(this)
|
||||
val format = params["bo_format"]?.lowercase() ?: return null
|
||||
if (format !in DOCUMENT_FORMATS) return null
|
||||
|
||||
return BookOasisChapterRef(
|
||||
bookId = match.groupValues[1].toLongOrNull() ?: return null,
|
||||
dbType = params["type"].orEmpty().ifBlank { "general" },
|
||||
format = format,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* BookOasis extension v1.4.x filters its chapter list to ZIP/CBZ.
|
||||
* Fetch the same detail JSON with the extension's authenticated client and
|
||||
* rebuild the chapter list so EPUB/PDF/TXT remain visible to the host app.
|
||||
*/
|
||||
internal suspend fun HttpSource.fetchBookOasisChapters(manga: SManga): List<SChapter> {
|
||||
val detailUrl = manga.url.toAbsoluteUrl(baseUrl)
|
||||
val dbType = parseQueryParameters(detailUrl)["type"].orEmpty().ifBlank { "general" }
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(detailUrl)
|
||||
.headers(headers)
|
||||
.get()
|
||||
.build()
|
||||
|
||||
val root = client.newCall(request).await().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
error("BookOasis detail request failed: HTTP ${response.code}")
|
||||
}
|
||||
JSONObject(response.body.string())
|
||||
}
|
||||
|
||||
val rawBooks = root.optJSONArray("books") ?: return emptyList()
|
||||
val books = buildList {
|
||||
for (index in 0 until rawBooks.length()) {
|
||||
val item = rawBooks.optJSONObject(index) ?: continue
|
||||
val id = item.optLong("id", -1L)
|
||||
val format = item.optString("file_format", "").lowercase()
|
||||
if (id <= 0L || format !in SUPPORTED_FORMATS) continue
|
||||
|
||||
add(
|
||||
Triple(
|
||||
id,
|
||||
item.optString("title", "").ifBlank { "Book $id" },
|
||||
format,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return books.mapIndexed { index, (bookId, title, format) ->
|
||||
SChapter.create().apply {
|
||||
url = buildString {
|
||||
append("/api/media/books/")
|
||||
append(bookId)
|
||||
append("/info?type=")
|
||||
append(dbType)
|
||||
if (format in DOCUMENT_FORMATS) {
|
||||
append("&bo_format=")
|
||||
append(format)
|
||||
}
|
||||
}
|
||||
name = title
|
||||
chapter_number = (books.size - index).toFloat()
|
||||
}
|
||||
}.reversed()
|
||||
}
|
||||
|
||||
private fun String.toAbsoluteUrl(baseUrl: String): String {
|
||||
return if (startsWith("http://") || startsWith("https://")) {
|
||||
this
|
||||
} else {
|
||||
baseUrl.trimEnd('/') + "/" + trimStart('/')
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseQueryParameters(urlOrPath: String): Map<String, String> {
|
||||
val rawQuery = runCatching {
|
||||
if (urlOrPath.startsWith("http://") || urlOrPath.startsWith("https://")) {
|
||||
URI(urlOrPath).rawQuery
|
||||
} else {
|
||||
urlOrPath.substringAfter('?', "")
|
||||
}
|
||||
}.getOrDefault(urlOrPath.substringAfter('?', ""))
|
||||
|
||||
if (rawQuery.isNullOrBlank()) return emptyMap()
|
||||
|
||||
return rawQuery.split('&')
|
||||
.mapNotNull { part ->
|
||||
if (part.isBlank()) return@mapNotNull null
|
||||
val key = part.substringBefore('=')
|
||||
val value = part.substringAfter('=', "")
|
||||
decodeQuery(key) to decodeQuery(value)
|
||||
}
|
||||
.toMap()
|
||||
}
|
||||
|
||||
private fun decodeQuery(value: String): String {
|
||||
return URLDecoder.decode(value, StandardCharsets.UTF_8.name())
|
||||
}
|
||||
@@ -254,6 +254,7 @@ class ReaderActivity : BaseActivity() {
|
||||
readerState = viewModel.state,
|
||||
onChangeReadingMode = viewModel::setMangaReadingMode,
|
||||
onChangeOrientation = viewModel::setMangaOrientationType,
|
||||
onTextSettingsChanged = viewModel::reloadTextReaderStyle,
|
||||
preferences = readerPreferences,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ import tachiyomi.domain.source.service.SourceManager
|
||||
import tachiyomi.source.local.image.LocalCoverManager
|
||||
import tachiyomi.source.local.isLocal
|
||||
import java.util.Date
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
@@ -326,7 +327,7 @@ class ReaderViewModel(
|
||||
mutableState.update { it.copy(manga = manga, source = source) }
|
||||
if (chapterId == -1L) chapterId = initialChapterId
|
||||
|
||||
loader = ChapterLoader(context, downloadManager, downloadProvider, chapterCache, manga, source)
|
||||
loader = ChapterLoader(context, downloadManager, downloadProvider, chapterCache, manga, source, readerPreferences)
|
||||
|
||||
loadChapter(loader!!, chapterList.first { chapterId == it.chapter.id })
|
||||
} catch (e: Throwable) {
|
||||
@@ -822,6 +823,31 @@ class ReaderViewModel(
|
||||
mutableState.update { it.copy(dialog = null) }
|
||||
}
|
||||
|
||||
fun reloadTextReaderStyle() {
|
||||
val chapter = state.value.currentChapter ?: return
|
||||
if (chapter.pageLoader?.usesTextReaderStyle != true) return
|
||||
val chapterLoader = loader ?: return
|
||||
val requestedPage = (state.value.currentPage - 1).coerceAtLeast(0)
|
||||
val oldLastIndex = (chapter.pages?.lastIndex ?: 0).coerceAtLeast(0)
|
||||
val readingProgress = if (oldLastIndex == 0) 0f else requestedPage.toFloat() / oldLastIndex
|
||||
|
||||
viewModelScope.launchIO {
|
||||
try {
|
||||
chapter.pageLoader?.recycle()
|
||||
chapter.pageLoader = null
|
||||
chapter.state = ReaderChapter.State.Wait
|
||||
chapter.requestedPage = requestedPage
|
||||
chapterLoader.loadChapter(chapter)
|
||||
val newLastIndex = (chapter.pages?.lastIndex ?: 0).coerceAtLeast(0)
|
||||
chapter.requestedPage = (readingProgress * newLastIndex).roundToInt().coerceIn(0, newLastIndex)
|
||||
eventChannel.send(Event.ReloadViewerChapters)
|
||||
} catch (e: Throwable) {
|
||||
if (e is CancellationException) throw e
|
||||
logcat(LogPriority.ERROR, e) { "Failed to reload text reader style" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setBrightnessOverlayValue(value: Int) {
|
||||
mutableState.update { it.copy(brightnessOverlayValue = value) }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.loader
|
||||
|
||||
import android.content.Context
|
||||
import com.hippo.unifile.UniFile
|
||||
import eu.kanade.tachiyomi.network.await
|
||||
import eu.kanade.tachiyomi.source.bookoasis.BookOasisChapterRef
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle
|
||||
import mihon.core.archive.epubReader
|
||||
import okhttp3.Request
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Opens BookOasis EPUB/PDF/TXT as native Mihon reader pages.
|
||||
*/
|
||||
internal class BookOasisFilePageLoader(
|
||||
private val context: Context,
|
||||
private val source: HttpSource,
|
||||
private val ref: BookOasisChapterRef,
|
||||
private val textStyle: TextReaderStyle,
|
||||
) : PageLoader() {
|
||||
|
||||
override var isLocal: Boolean = true
|
||||
override val usesTextReaderStyle: Boolean = ref.format == "epub" || ref.format == "txt"
|
||||
|
||||
private var delegate: PageLoader? = null
|
||||
|
||||
override suspend fun getPages(): List<ReaderPage> {
|
||||
check(!isRecycled)
|
||||
|
||||
val file = getOrDownloadFile()
|
||||
val uniFile = UniFile.fromFile(file)
|
||||
?: error("Unable to open cached BookOasis file: ${file.absolutePath}")
|
||||
val loader = when (ref.format) {
|
||||
"epub" -> EpubPageLoader(uniFile.epubReader(context), textStyle)
|
||||
"pdf" -> PdfPageLoader(context, uniFile)
|
||||
"txt" -> TextPageLoader(uniFile, textStyle)
|
||||
else -> error("Unsupported BookOasis document format: ${ref.format}")
|
||||
}
|
||||
|
||||
delegate = loader
|
||||
return loader.getPages()
|
||||
}
|
||||
|
||||
override suspend fun loadPage(page: ReaderPage) {
|
||||
delegate?.loadPage(page)
|
||||
}
|
||||
|
||||
override fun retryPage(page: ReaderPage) {
|
||||
delegate?.retryPage(page)
|
||||
}
|
||||
|
||||
override fun recycle() {
|
||||
delegate?.recycle()
|
||||
delegate = null
|
||||
super.recycle()
|
||||
}
|
||||
|
||||
private suspend fun getOrDownloadFile(): File {
|
||||
val cacheDir = File(context.cacheDir, "bookoasis_reader").apply { mkdirs() }
|
||||
val target = File(cacheDir, "${ref.dbType}-${ref.bookId}.${ref.format}")
|
||||
if (target.isFile && target.length() > 0L) {
|
||||
return target
|
||||
}
|
||||
|
||||
val temp = File(cacheDir, "${target.name}.part")
|
||||
if (temp.exists()) temp.delete()
|
||||
|
||||
val downloadUrl = buildString {
|
||||
append(source.baseUrl.trimEnd('/'))
|
||||
append("/api/media/books/")
|
||||
append(ref.bookId)
|
||||
append("/download?type=")
|
||||
append(ref.dbType)
|
||||
}
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(downloadUrl)
|
||||
.headers(source.headers)
|
||||
.get()
|
||||
.build()
|
||||
|
||||
source.client.newCall(request).await().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
throw IOException(
|
||||
"BookOasis ${ref.format.uppercase()} download failed: HTTP ${response.code}",
|
||||
)
|
||||
}
|
||||
|
||||
temp.outputStream().use { output ->
|
||||
response.body.byteStream().use { input ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (temp.length() <= 0L) {
|
||||
temp.delete()
|
||||
throw IOException("BookOasis returned an empty ${ref.format.uppercase()} file")
|
||||
}
|
||||
|
||||
if (!temp.renameTo(target)) {
|
||||
temp.copyTo(target, overwrite = true)
|
||||
temp.delete()
|
||||
}
|
||||
return target
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,11 @@ import eu.kanade.tachiyomi.data.cache.ChapterCache
|
||||
import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||
import eu.kanade.tachiyomi.data.download.DownloadProvider
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.bookoasis.isBookOasisSource
|
||||
import eu.kanade.tachiyomi.source.bookoasis.toBookOasisChapterRef
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
|
||||
import mihon.core.archive.archiveReader
|
||||
import mihon.core.archive.epubReader
|
||||
import tachiyomi.core.common.i18n.stringResource
|
||||
@@ -28,6 +31,7 @@ class ChapterLoader(
|
||||
private val chapterCache: ChapterCache,
|
||||
private val manga: Manga,
|
||||
private val source: Source,
|
||||
private val readerPreferences: ReaderPreferences,
|
||||
) {
|
||||
|
||||
/**
|
||||
@@ -86,6 +90,8 @@ class ChapterLoader(
|
||||
manga.title,
|
||||
source,
|
||||
)
|
||||
val bookOasisRef = dbChapter.url.toBookOasisChapterRef()
|
||||
|
||||
return when {
|
||||
isDownloaded -> DownloadPageLoader(
|
||||
chapter,
|
||||
@@ -98,9 +104,14 @@ class ChapterLoader(
|
||||
when (format) {
|
||||
is Format.Directory -> DirectoryPageLoader(format.file)
|
||||
is Format.Archive -> ArchivePageLoader(format.file.archiveReader(context))
|
||||
is Format.Epub -> EpubPageLoader(format.file.epubReader(context))
|
||||
is Format.Epub -> EpubPageLoader(format.file.epubReader(context), readerPreferences.textReaderStyle())
|
||||
is Format.Pdf -> PdfPageLoader(context, format.file)
|
||||
is Format.Text -> TextPageLoader(format.file, readerPreferences.textReaderStyle())
|
||||
}
|
||||
}
|
||||
source is HttpSource && source.isBookOasisSource() && bookOasisRef != null -> {
|
||||
BookOasisFilePageLoader(context, source, bookOasisRef, readerPreferences.textReaderStyle())
|
||||
}
|
||||
source is HttpSource -> HttpPageLoader(chapter, source, chapterCache)
|
||||
source is StubSource -> error(context.stringResource(MR.strings.source_not_installed, source.toString()))
|
||||
else -> error(context.stringResource(MR.strings.loader_not_implemented_error))
|
||||
|
||||
@@ -2,18 +2,39 @@ package eu.kanade.tachiyomi.ui.reader.loader
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle
|
||||
import mihon.core.archive.EpubReader
|
||||
|
||||
/**
|
||||
* Loader used to load a chapter from a .epub file.
|
||||
*
|
||||
* Text-based EPUBs are rendered as regular Mihon pages. Image-only EPUBs
|
||||
* keep the original image extraction behavior.
|
||||
*/
|
||||
internal class EpubPageLoader(private val reader: EpubReader) : PageLoader() {
|
||||
internal class EpubPageLoader(
|
||||
private val reader: EpubReader,
|
||||
private val style: TextReaderStyle,
|
||||
) : PageLoader() {
|
||||
|
||||
override var isLocal: Boolean = true
|
||||
override val usesTextReaderStyle: Boolean = true
|
||||
|
||||
override suspend fun getPages(): List<ReaderPage> {
|
||||
return reader.getImagesFromPages().mapIndexed { i, path ->
|
||||
ReaderPage(i).apply {
|
||||
val textPages = reader.getTextFromPages()
|
||||
.filter { it.isNotBlank() }
|
||||
.flatMap { TextPageRenderer.paginate(it, style) }
|
||||
|
||||
if (textPages.isNotEmpty()) {
|
||||
return textPages.mapIndexed { index, text ->
|
||||
ReaderPage(index).apply {
|
||||
stream = { TextPageRenderer.render(text, style) }
|
||||
status = Page.State.Ready
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return reader.getImagesFromPages().mapIndexed { index, path ->
|
||||
ReaderPage(index).apply {
|
||||
stream = { reader.getInputStream(path)!! }
|
||||
status = Page.State.Ready
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ abstract class PageLoader {
|
||||
|
||||
abstract var isLocal: Boolean
|
||||
|
||||
open val usesTextReaderStyle: Boolean = false
|
||||
|
||||
/**
|
||||
* Returns the list of pages of a chapter.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.loader
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.graphics.pdf.PdfRenderer
|
||||
import android.os.ParcelFileDescriptor
|
||||
import com.hippo.unifile.UniFile
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.InputStream
|
||||
|
||||
internal class PdfPageLoader(
|
||||
context: Context,
|
||||
file: UniFile,
|
||||
) : PageLoader() {
|
||||
|
||||
override var isLocal: Boolean = true
|
||||
|
||||
private val descriptor: ParcelFileDescriptor =
|
||||
context.contentResolver.openFileDescriptor(file.uri, "r")
|
||||
?: error("Unable to open PDF: ${file.name}")
|
||||
private val renderer = PdfRenderer(descriptor)
|
||||
private val renderLock = Any()
|
||||
|
||||
override suspend fun getPages(): List<ReaderPage> {
|
||||
return (0 until renderer.pageCount).map { index ->
|
||||
ReaderPage(index).apply {
|
||||
stream = { renderPage(index) }
|
||||
status = Page.State.Ready
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderPage(index: Int): InputStream = synchronized(renderLock) {
|
||||
check(!isRecycled)
|
||||
renderer.openPage(index).use { page ->
|
||||
val scale = (1600f / page.width.coerceAtLeast(1)).coerceIn(1f, 2.5f)
|
||||
val width = (page.width * scale).toInt().coerceAtLeast(1)
|
||||
val height = (page.height * scale).toInt().coerceAtLeast(1)
|
||||
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
bitmap.eraseColor(Color.WHITE)
|
||||
page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
|
||||
|
||||
val output = ByteArrayOutputStream()
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, output)
|
||||
bitmap.recycle()
|
||||
ByteArrayInputStream(output.toByteArray())
|
||||
}
|
||||
}
|
||||
|
||||
override fun recycle() {
|
||||
if (!isRecycled) {
|
||||
synchronized(renderLock) {
|
||||
renderer.close()
|
||||
descriptor.close()
|
||||
}
|
||||
}
|
||||
super.recycle()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.loader
|
||||
|
||||
import com.hippo.unifile.UniFile
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.charset.CharacterCodingException
|
||||
import java.nio.charset.Charset
|
||||
import java.nio.charset.CodingErrorAction
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
internal class TextPageLoader(
|
||||
private val file: UniFile,
|
||||
private val style: TextReaderStyle,
|
||||
) : PageLoader() {
|
||||
|
||||
override var isLocal: Boolean = true
|
||||
override val usesTextReaderStyle: Boolean = true
|
||||
|
||||
override suspend fun getPages(): List<ReaderPage> {
|
||||
val bytes = file.openInputStream().use { it.readBytes() }
|
||||
val text = decodeText(bytes)
|
||||
return TextPageRenderer.paginate(text, style).mapIndexed { index, pageText ->
|
||||
ReaderPage(index).apply {
|
||||
stream = { TextPageRenderer.render(pageText, style) }
|
||||
status = Page.State.Ready
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeText(bytes: ByteArray): String {
|
||||
if (bytes.size >= 3 &&
|
||||
bytes[0] == 0xEF.toByte() &&
|
||||
bytes[1] == 0xBB.toByte() &&
|
||||
bytes[2] == 0xBF.toByte()
|
||||
) {
|
||||
return String(bytes, 3, bytes.size - 3, StandardCharsets.UTF_8)
|
||||
}
|
||||
|
||||
return try {
|
||||
StandardCharsets.UTF_8.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
||||
.decode(ByteBuffer.wrap(bytes))
|
||||
.toString()
|
||||
} catch (_: CharacterCodingException) {
|
||||
String(bytes, Charset.forName("MS949"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.loader
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Typeface
|
||||
import android.text.Layout
|
||||
import android.text.Spannable
|
||||
import android.text.SpannableString
|
||||
import android.text.StaticLayout
|
||||
import android.text.TextPaint
|
||||
import android.text.style.LineHeightSpan
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.InputStream
|
||||
|
||||
internal object TextPageRenderer {
|
||||
private const val PAGE_WIDTH = 1440
|
||||
private const val PAGE_HEIGHT = 2160
|
||||
private const val MARGIN = 96
|
||||
private const val CONTENT_WIDTH = PAGE_WIDTH - (MARGIN * 2)
|
||||
private const val CONTENT_HEIGHT = PAGE_HEIGHT - (MARGIN * 2)
|
||||
|
||||
fun paginate(text: String, style: TextReaderStyle): List<String> {
|
||||
if (text.isBlank()) return emptyList()
|
||||
|
||||
val layout = createLayout(text, style)
|
||||
if (layout.lineCount == 0) return listOf(text)
|
||||
|
||||
val pages = mutableListOf<String>()
|
||||
var startLine = 0
|
||||
while (startLine < layout.lineCount) {
|
||||
val startTop = layout.getLineTop(startLine)
|
||||
var endLine = startLine
|
||||
while (
|
||||
endLine + 1 < layout.lineCount &&
|
||||
layout.getLineBottom(endLine + 1) - startTop <= CONTENT_HEIGHT
|
||||
) {
|
||||
endLine++
|
||||
}
|
||||
|
||||
val start = layout.getLineStart(startLine)
|
||||
val end = layout.getLineEnd(endLine).coerceAtLeast(start + 1).coerceAtMost(text.length)
|
||||
text.substring(start, end).trim().takeIf { it.isNotEmpty() }?.let(pages::add)
|
||||
startLine = endLine + 1
|
||||
}
|
||||
return pages.ifEmpty { listOf(text) }
|
||||
}
|
||||
|
||||
fun render(text: String, style: TextReaderStyle): InputStream {
|
||||
val bitmap = Bitmap.createBitmap(PAGE_WIDTH, PAGE_HEIGHT, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bitmap)
|
||||
canvas.drawColor(Color.WHITE)
|
||||
canvas.save()
|
||||
canvas.translate(MARGIN.toFloat(), MARGIN.toFloat())
|
||||
createLayout(text, style).draw(canvas)
|
||||
canvas.restore()
|
||||
|
||||
val output = ByteArrayOutputStream()
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, output)
|
||||
bitmap.recycle()
|
||||
return ByteArrayInputStream(output.toByteArray())
|
||||
}
|
||||
|
||||
private fun createLayout(text: String, style: TextReaderStyle): StaticLayout {
|
||||
val paint = TextPaint().apply {
|
||||
isAntiAlias = true
|
||||
color = Color.BLACK
|
||||
textSize = style.fontSize.toFloat()
|
||||
typeface = style.fontFamily.androidFamilyName
|
||||
?.let { Typeface.create(it, Typeface.NORMAL) }
|
||||
?: Typeface.DEFAULT
|
||||
}
|
||||
val styledText = addParagraphSpacing(text, style.paragraphSpacing)
|
||||
return StaticLayout.Builder
|
||||
.obtain(styledText, 0, styledText.length, paint, CONTENT_WIDTH)
|
||||
.setAlignment(Layout.Alignment.ALIGN_NORMAL)
|
||||
.setIncludePad(true)
|
||||
.setLineSpacing(0f, style.lineSpacingPercent / 100f)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun addParagraphSpacing(text: String, spacing: Int): CharSequence {
|
||||
if (spacing <= 0 || '\n' !in text) return text
|
||||
val spannable = SpannableString(text)
|
||||
text.forEachIndexed { index, char ->
|
||||
if (char == '\n') {
|
||||
spannable.setSpan(
|
||||
ParagraphSpacingSpan(spacing),
|
||||
index,
|
||||
(index + 1).coerceAtMost(text.length),
|
||||
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE,
|
||||
)
|
||||
}
|
||||
}
|
||||
return spannable
|
||||
}
|
||||
|
||||
private class ParagraphSpacingSpan(private val spacing: Int) : LineHeightSpan {
|
||||
override fun chooseHeight(
|
||||
text: CharSequence?,
|
||||
start: Int,
|
||||
end: Int,
|
||||
spanstartv: Int,
|
||||
v: Int,
|
||||
fm: Paint.FontMetricsInt,
|
||||
) {
|
||||
fm.descent += spacing
|
||||
fm.bottom += spacing
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,28 @@ class ReaderPreferences(
|
||||
|
||||
val webtoonDisableZoomOut: Preference<Boolean> = preferenceStore.getBoolean("webtoon_disable_zoom_out", false)
|
||||
|
||||
// region Text reader
|
||||
|
||||
val textFontSize: Preference<Int> = preferenceStore.getInt("reader_text_font_size", 44)
|
||||
|
||||
val textLineSpacing: Preference<Int> = preferenceStore.getInt("reader_text_line_spacing", 115)
|
||||
|
||||
val textParagraphSpacing: Preference<Int> = preferenceStore.getInt("reader_text_paragraph_spacing", 12)
|
||||
|
||||
val textFontFamily: Preference<TextFontFamily> = preferenceStore.getEnum(
|
||||
"reader_text_font_family",
|
||||
TextFontFamily.SYSTEM,
|
||||
)
|
||||
|
||||
fun textReaderStyle() = TextReaderStyle(
|
||||
fontSize = textFontSize.get(),
|
||||
lineSpacingPercent = textLineSpacing.get(),
|
||||
paragraphSpacing = textParagraphSpacing.get(),
|
||||
fontFamily = textFontFamily.get(),
|
||||
)
|
||||
|
||||
// endregion
|
||||
|
||||
// endregion
|
||||
|
||||
// region Split two-page spread
|
||||
@@ -217,6 +239,13 @@ class ReaderPreferences(
|
||||
|
||||
// endregion
|
||||
|
||||
enum class TextFontFamily(val androidFamilyName: String?) {
|
||||
SYSTEM(null),
|
||||
SANS_SERIF("sans-serif"),
|
||||
SERIF("serif"),
|
||||
MONOSPACE("monospace"),
|
||||
}
|
||||
|
||||
enum class FlashColor {
|
||||
BLACK,
|
||||
WHITE,
|
||||
@@ -342,3 +371,11 @@ class ReaderPreferences(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
data class TextReaderStyle(
|
||||
val fontSize: Int,
|
||||
val lineSpacingPercent: Int,
|
||||
val paragraphSpacing: Int,
|
||||
val fontFamily: ReaderPreferences.TextFontFamily,
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ class ReaderSettingsViewModel(
|
||||
readerState: StateFlow<ReaderViewModel.State>,
|
||||
val onChangeReadingMode: (ReadingMode) -> Unit,
|
||||
val onChangeOrientation: (ReaderOrientation) -> Unit,
|
||||
val onTextSettingsChanged: () -> Unit,
|
||||
val preferences: ReaderPreferences,
|
||||
) : ViewModel() {
|
||||
|
||||
|
||||
Reference in New Issue
Block a user