feat: add BookOasis document reader and text settings
Update website / update_website (release) Waiting to run
Build & Test / Build & Test App (push) Has been cancelled

This commit is contained in:
2026-09-19 10:58:14 +09:00
parent 5848ffb4c4
commit 4900efbca5
20 changed files with 746 additions and 7 deletions
@@ -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() {
@@ -8,7 +8,10 @@ import eu.kanade.domain.manga.model.toSManga
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.download.DownloadManager
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.online.HttpSource
import logcat.LogPriority
import mihon.domain.source.models.RemoteMangaUpdate
import tachiyomi.core.common.util.lang.withIOContext
@@ -70,8 +73,28 @@ class UpdateMangaFromRemote(
)
}
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(
rawSourceChapters = update.chapters,
rawSourceChapters = rawSourceChapters,
manga = manga,
source = source,
manualFetch = manualFetch,
@@ -34,6 +34,27 @@ class EpubReader(private val reader: ArchiveReader) : Closeable by reader {
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.
*/
@@ -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.contactPlatform">Discord</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>
@@ -907,4 +907,16 @@
<string name="pref_apply_content_warnings_to_installed_summary">비활성화하면 이미 설치한 확장 앱은 등급과 무관하게 컨텐츠를 불러오고 업데이트합니다</string>
<string name="content_warnings_info">허용하지 않은 등급의 확장 앱은 이미 설치된 경우 목록에 남지만 소스는 표시되지 않으며, 설치하지 않은 경우 목록에서 숨겨집니다. 이 설정으로도 비공식 확장 앱이나 등급이 잘못 지정된 확장 앱이 앱에 18+ 콘텐츠를 표시하는 것을 완전히 막을 수는 없습니다.</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>
@@ -290,7 +290,13 @@ class LocalSource(
val chapters = fileSystem.getFilesInMangaDirectory(manga.url)
// Only keep supported formats
.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 ->
SChapter.create().apply {
url = "${manga.url}/${chapterFile.name}"
@@ -385,6 +391,9 @@ class LocalSource(
entry?.let { coverManager.update(manga, epub.getInputStream(it)!!) }
}
}
is Format.Pdf,
is Format.Text,
-> null
}
} catch (e: Throwable) {
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 Archive(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()
@@ -16,6 +18,8 @@ sealed interface Format {
fun valueOf(file: UniFile) = when {
file.isDirectory -> Directory(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)
else -> throw UnknownFormatException()
}