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.foundation.verticalScroll
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalView
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.compose.ui.window.DialogWindowProvider
|
import androidx.compose.ui.window.DialogWindowProvider
|
||||||
@@ -29,13 +30,18 @@ fun ReaderSettingsDialog(
|
|||||||
stringResource(MR.strings.pref_category_reading_mode),
|
stringResource(MR.strings.pref_category_reading_mode),
|
||||||
stringResource(MR.strings.pref_category_general),
|
stringResource(MR.strings.pref_category_general),
|
||||||
stringResource(MR.strings.custom_filter),
|
stringResource(MR.strings.custom_filter),
|
||||||
|
stringResource(MR.strings.pref_category_text_reader),
|
||||||
)
|
)
|
||||||
val pagerState = rememberPagerState { tabTitles.size }
|
val pagerState = rememberPagerState { tabTitles.size }
|
||||||
|
val initialTextStyle = remember { viewModel.preferences.textReaderStyle() }
|
||||||
|
|
||||||
BoxWithConstraints {
|
BoxWithConstraints {
|
||||||
TabbedDialog(
|
TabbedDialog(
|
||||||
modifier = Modifier.heightIn(max = maxHeight * 0.75f),
|
modifier = Modifier.heightIn(max = maxHeight * 0.75f),
|
||||||
onDismissRequest = {
|
onDismissRequest = {
|
||||||
|
if (viewModel.preferences.textReaderStyle() != initialTextStyle) {
|
||||||
|
viewModel.onTextSettingsChanged()
|
||||||
|
}
|
||||||
onDismissRequest()
|
onDismissRequest()
|
||||||
onShowMenus()
|
onShowMenus()
|
||||||
},
|
},
|
||||||
@@ -63,6 +69,7 @@ fun ReaderSettingsDialog(
|
|||||||
0 -> ReadingModePage(viewModel)
|
0 -> ReadingModePage(viewModel)
|
||||||
1 -> GeneralPage(viewModel)
|
1 -> GeneralPage(viewModel)
|
||||||
2 -> ColorFilterPage(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,
|
readerState = viewModel.state,
|
||||||
onChangeReadingMode = viewModel::setMangaReadingMode,
|
onChangeReadingMode = viewModel::setMangaReadingMode,
|
||||||
onChangeOrientation = viewModel::setMangaOrientationType,
|
onChangeOrientation = viewModel::setMangaOrientationType,
|
||||||
|
onTextSettingsChanged = viewModel::reloadTextReaderStyle,
|
||||||
preferences = readerPreferences,
|
preferences = readerPreferences,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ import tachiyomi.domain.source.service.SourceManager
|
|||||||
import tachiyomi.source.local.image.LocalCoverManager
|
import tachiyomi.source.local.image.LocalCoverManager
|
||||||
import tachiyomi.source.local.isLocal
|
import tachiyomi.source.local.isLocal
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
import kotlin.math.roundToInt
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -326,7 +327,7 @@ class ReaderViewModel(
|
|||||||
mutableState.update { it.copy(manga = manga, source = source) }
|
mutableState.update { it.copy(manga = manga, source = source) }
|
||||||
if (chapterId == -1L) chapterId = initialChapterId
|
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 })
|
loadChapter(loader!!, chapterList.first { chapterId == it.chapter.id })
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
@@ -822,6 +823,31 @@ class ReaderViewModel(
|
|||||||
mutableState.update { it.copy(dialog = null) }
|
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) {
|
fun setBrightnessOverlayValue(value: Int) {
|
||||||
mutableState.update { it.copy(brightnessOverlayValue = value) }
|
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.DownloadManager
|
||||||
import eu.kanade.tachiyomi.data.download.DownloadProvider
|
import eu.kanade.tachiyomi.data.download.DownloadProvider
|
||||||
import eu.kanade.tachiyomi.source.Source
|
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.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.setting.ReaderPreferences
|
||||||
import mihon.core.archive.archiveReader
|
import mihon.core.archive.archiveReader
|
||||||
import mihon.core.archive.epubReader
|
import mihon.core.archive.epubReader
|
||||||
import tachiyomi.core.common.i18n.stringResource
|
import tachiyomi.core.common.i18n.stringResource
|
||||||
@@ -28,6 +31,7 @@ class ChapterLoader(
|
|||||||
private val chapterCache: ChapterCache,
|
private val chapterCache: ChapterCache,
|
||||||
private val manga: Manga,
|
private val manga: Manga,
|
||||||
private val source: Source,
|
private val source: Source,
|
||||||
|
private val readerPreferences: ReaderPreferences,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -86,6 +90,8 @@ class ChapterLoader(
|
|||||||
manga.title,
|
manga.title,
|
||||||
source,
|
source,
|
||||||
)
|
)
|
||||||
|
val bookOasisRef = dbChapter.url.toBookOasisChapterRef()
|
||||||
|
|
||||||
return when {
|
return when {
|
||||||
isDownloaded -> DownloadPageLoader(
|
isDownloaded -> DownloadPageLoader(
|
||||||
chapter,
|
chapter,
|
||||||
@@ -98,9 +104,14 @@ 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))
|
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 HttpSource -> HttpPageLoader(chapter, source, chapterCache)
|
||||||
source is StubSource -> error(context.stringResource(MR.strings.source_not_installed, source.toString()))
|
source is StubSource -> error(context.stringResource(MR.strings.source_not_installed, source.toString()))
|
||||||
else -> error(context.stringResource(MR.strings.loader_not_implemented_error))
|
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.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 mihon.core.archive.EpubReader
|
import mihon.core.archive.EpubReader
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
|
* 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 var isLocal: Boolean = true
|
||||||
|
override val usesTextReaderStyle: Boolean = true
|
||||||
|
|
||||||
override suspend fun getPages(): List<ReaderPage> {
|
override suspend fun getPages(): List<ReaderPage> {
|
||||||
return reader.getImagesFromPages().mapIndexed { i, path ->
|
val textPages = reader.getTextFromPages()
|
||||||
ReaderPage(i).apply {
|
.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)!! }
|
stream = { reader.getInputStream(path)!! }
|
||||||
status = Page.State.Ready
|
status = Page.State.Ready
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ abstract class PageLoader {
|
|||||||
|
|
||||||
abstract var isLocal: Boolean
|
abstract var isLocal: Boolean
|
||||||
|
|
||||||
|
open val usesTextReaderStyle: Boolean = false
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the list of pages of a chapter.
|
* 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)
|
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
|
// endregion
|
||||||
|
|
||||||
// region Split two-page spread
|
// region Split two-page spread
|
||||||
@@ -217,6 +239,13 @@ class ReaderPreferences(
|
|||||||
|
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
|
enum class TextFontFamily(val androidFamilyName: String?) {
|
||||||
|
SYSTEM(null),
|
||||||
|
SANS_SERIF("sans-serif"),
|
||||||
|
SERIF("serif"),
|
||||||
|
MONOSPACE("monospace"),
|
||||||
|
}
|
||||||
|
|
||||||
enum class FlashColor {
|
enum class FlashColor {
|
||||||
BLACK,
|
BLACK,
|
||||||
WHITE,
|
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>,
|
readerState: StateFlow<ReaderViewModel.State>,
|
||||||
val onChangeReadingMode: (ReadingMode) -> Unit,
|
val onChangeReadingMode: (ReadingMode) -> Unit,
|
||||||
val onChangeOrientation: (ReaderOrientation) -> Unit,
|
val onChangeOrientation: (ReaderOrientation) -> Unit,
|
||||||
|
val onTextSettingsChanged: () -> Unit,
|
||||||
val preferences: ReaderPreferences,
|
val preferences: ReaderPreferences,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ import eu.kanade.domain.manga.model.toSManga
|
|||||||
import eu.kanade.tachiyomi.data.cache.CoverCache
|
import eu.kanade.tachiyomi.data.cache.CoverCache
|
||||||
import eu.kanade.tachiyomi.data.download.DownloadManager
|
import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||||
import eu.kanade.tachiyomi.source.Source
|
import eu.kanade.tachiyomi.source.Source
|
||||||
|
import eu.kanade.tachiyomi.source.bookoasis.fetchBookOasisChapters
|
||||||
|
import eu.kanade.tachiyomi.source.bookoasis.isBookOasisSource
|
||||||
import eu.kanade.tachiyomi.source.model.SManga
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import mihon.domain.source.models.RemoteMangaUpdate
|
import mihon.domain.source.models.RemoteMangaUpdate
|
||||||
import tachiyomi.core.common.util.lang.withIOContext
|
import tachiyomi.core.common.util.lang.withIOContext
|
||||||
@@ -70,8 +73,28 @@ class UpdateMangaFromRemote(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
awaitUpdateFromSource(manga, update.manga, manualFetch)
|
awaitUpdateFromSource(manga, update.manga, manualFetch)
|
||||||
|
|
||||||
|
val rawSourceChapters = if (
|
||||||
|
fetchChapters &&
|
||||||
|
source is HttpSource &&
|
||||||
|
source.isBookOasisSource()
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
withIOContext {
|
||||||
|
source.fetchBookOasisChapters(manga.toSManga())
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logcat(LogPriority.ERROR, e) {
|
||||||
|
"BookOasis document chapter compatibility fallback failed"
|
||||||
|
}
|
||||||
|
update.chapters
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
update.chapters
|
||||||
|
}
|
||||||
|
|
||||||
val newChapters = syncChaptersWithSource.await(
|
val newChapters = syncChaptersWithSource.await(
|
||||||
rawSourceChapters = update.chapters,
|
rawSourceChapters = rawSourceChapters,
|
||||||
manga = manga,
|
manga = manga,
|
||||||
source = source,
|
source = source,
|
||||||
manualFetch = manualFetch,
|
manualFetch = manualFetch,
|
||||||
|
|||||||
@@ -34,6 +34,27 @@ class EpubReader(private val reader: ArchiveReader) : Closeable by reader {
|
|||||||
return getImagesFromPages(pages, ref)
|
return getImagesFromPages(pages, ref)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns readable text for each spine document in reading order.
|
||||||
|
*/
|
||||||
|
fun getTextFromPages(): List<String> {
|
||||||
|
val ref = getPackageHref()
|
||||||
|
val doc = getPackageDocument(ref)
|
||||||
|
val basePath = getParentDirectory(ref)
|
||||||
|
return getPagesFromDocument(doc).mapNotNull { page ->
|
||||||
|
val entryPath = resolveZipPath(basePath, page)
|
||||||
|
getInputStream(entryPath)?.use { input ->
|
||||||
|
val pageDoc = Jsoup.parse(input, null, "")
|
||||||
|
pageDoc.select("br").append("\n")
|
||||||
|
pageDoc.select("p,div,li,h1,h2,h3,h4,h5,h6,blockquote").append("\n")
|
||||||
|
pageDoc.body()?.wholeText()
|
||||||
|
?.replace(Regex("[ \\t]+\\n"), "\n")
|
||||||
|
?.replace(Regex("\\n{3,}"), "\n\n")
|
||||||
|
?.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the path to the package document.
|
* Returns the path to the package document.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1099,4 +1099,16 @@
|
|||||||
<string name="donationCampaign.paragraph3">Regardless, Mihon will continue to be maintained by me and the volunteers, and it will stay open source.</string>
|
<string name="donationCampaign.paragraph3">Regardless, Mihon will continue to be maintained by me and the volunteers, and it will stay open source.</string>
|
||||||
<string name="donationCampaign.contactPlatform">Discord</string>
|
<string name="donationCampaign.contactPlatform">Discord</string>
|
||||||
<string name="donationCampaign.dismiss">Dismiss</string>
|
<string name="donationCampaign.dismiss">Dismiss</string>
|
||||||
|
|
||||||
|
<!-- Text reader -->
|
||||||
|
<string name="pref_category_text_reader">Text</string>
|
||||||
|
<string name="pref_text_reader_font_size">Font size</string>
|
||||||
|
<string name="pref_text_reader_line_spacing">Line spacing</string>
|
||||||
|
<string name="pref_text_reader_paragraph_spacing">Paragraph spacing</string>
|
||||||
|
<string name="pref_text_reader_font_family">Font</string>
|
||||||
|
<string name="pref_text_reader_font_system">System</string>
|
||||||
|
<string name="pref_text_reader_font_sans">Sans-serif</string>
|
||||||
|
<string name="pref_text_reader_font_serif">Serif</string>
|
||||||
|
<string name="pref_text_reader_font_mono">Monospace</string>
|
||||||
|
<string name="pref_text_reader_pdf_note">EPUB and TXT use these typography settings. PDF keeps its original page layout.</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -907,4 +907,16 @@
|
|||||||
<string name="pref_apply_content_warnings_to_installed_summary">비활성화하면 이미 설치한 확장 앱은 등급과 무관하게 컨텐츠를 불러오고 업데이트합니다</string>
|
<string name="pref_apply_content_warnings_to_installed_summary">비활성화하면 이미 설치한 확장 앱은 등급과 무관하게 컨텐츠를 불러오고 업데이트합니다</string>
|
||||||
<string name="content_warnings_info">허용하지 않은 등급의 확장 앱은 이미 설치된 경우 목록에 남지만 소스는 표시되지 않으며, 설치하지 않은 경우 목록에서 숨겨집니다. 이 설정으로도 비공식 확장 앱이나 등급이 잘못 지정된 확장 앱이 앱에 18+ 콘텐츠를 표시하는 것을 완전히 막을 수는 없습니다.</string>
|
<string name="content_warnings_info">허용하지 않은 등급의 확장 앱은 이미 설치된 경우 목록에 남지만 소스는 표시되지 않으며, 설치하지 않은 경우 목록에서 숨겨집니다. 이 설정으로도 비공식 확장 앱이나 등급이 잘못 지정된 확장 앱이 앱에 18+ 콘텐츠를 표시하는 것을 완전히 막을 수는 없습니다.</string>
|
||||||
<string name="ext_filtered">필터링됨</string>
|
<string name="ext_filtered">필터링됨</string>
|
||||||
|
|
||||||
|
<!-- Text reader -->
|
||||||
|
<string name="pref_category_text_reader">텍스트</string>
|
||||||
|
<string name="pref_text_reader_font_size">글자 크기</string>
|
||||||
|
<string name="pref_text_reader_line_spacing">행간</string>
|
||||||
|
<string name="pref_text_reader_paragraph_spacing">문단 간격</string>
|
||||||
|
<string name="pref_text_reader_font_family">글꼴</string>
|
||||||
|
<string name="pref_text_reader_font_system">시스템</string>
|
||||||
|
<string name="pref_text_reader_font_sans">고딕</string>
|
||||||
|
<string name="pref_text_reader_font_serif">명조</string>
|
||||||
|
<string name="pref_text_reader_font_mono">고정폭</string>
|
||||||
|
<string name="pref_text_reader_pdf_note">EPUB/TXT에는 이 글꼴 설정이 적용됩니다. PDF는 원본 페이지 레이아웃을 유지합니다.</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -290,7 +290,13 @@ class LocalSource(
|
|||||||
val chapters = fileSystem.getFilesInMangaDirectory(manga.url)
|
val chapters = fileSystem.getFilesInMangaDirectory(manga.url)
|
||||||
// Only keep supported formats
|
// Only keep supported formats
|
||||||
.filterNot { it.name.orEmpty().startsWith('.') }
|
.filterNot { it.name.orEmpty().startsWith('.') }
|
||||||
.filter { it.isDirectory || Archive.isSupported(it) || it.extension.equals("epub", true) }
|
.filter {
|
||||||
|
it.isDirectory ||
|
||||||
|
Archive.isSupported(it) ||
|
||||||
|
it.extension.equals("epub", true) ||
|
||||||
|
it.extension.equals("pdf", true) ||
|
||||||
|
it.extension.equals("txt", true)
|
||||||
|
}
|
||||||
.map { chapterFile ->
|
.map { chapterFile ->
|
||||||
SChapter.create().apply {
|
SChapter.create().apply {
|
||||||
url = "${manga.url}/${chapterFile.name}"
|
url = "${manga.url}/${chapterFile.name}"
|
||||||
@@ -385,6 +391,9 @@ class LocalSource(
|
|||||||
entry?.let { coverManager.update(manga, epub.getInputStream(it)!!) }
|
entry?.let { coverManager.update(manga, epub.getInputStream(it)!!) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
is Format.Pdf,
|
||||||
|
is Format.Text,
|
||||||
|
-> null
|
||||||
}
|
}
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
logcat(LogPriority.ERROR, e) { "Error updating cover for ${manga.title}" }
|
logcat(LogPriority.ERROR, e) { "Error updating cover for ${manga.title}" }
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ sealed interface Format {
|
|||||||
data class Directory(val file: UniFile) : Format
|
data class Directory(val file: UniFile) : Format
|
||||||
data class Archive(val file: UniFile) : Format
|
data class Archive(val file: UniFile) : Format
|
||||||
data class Epub(val file: UniFile) : Format
|
data class Epub(val file: UniFile) : Format
|
||||||
|
data class Pdf(val file: UniFile) : Format
|
||||||
|
data class Text(val file: UniFile) : Format
|
||||||
|
|
||||||
class UnknownFormatException : Exception()
|
class UnknownFormatException : Exception()
|
||||||
|
|
||||||
@@ -16,6 +18,8 @@ sealed interface Format {
|
|||||||
fun valueOf(file: UniFile) = when {
|
fun valueOf(file: UniFile) = when {
|
||||||
file.isDirectory -> Directory(file)
|
file.isDirectory -> Directory(file)
|
||||||
file.extension.equals("epub", true) -> Epub(file)
|
file.extension.equals("epub", true) -> Epub(file)
|
||||||
|
file.extension.equals("pdf", true) -> Pdf(file)
|
||||||
|
file.extension.equals("txt", true) -> Text(file)
|
||||||
isArchiveSupported(file) -> Archive(file)
|
isArchiveSupported(file) -> Archive(file)
|
||||||
else -> throw UnknownFormatException()
|
else -> throw UnknownFormatException()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user