🚀 Images in Reader

* Added Images in Reader
* Alternative(caption) text below Image

Resolves: #69
This commit is contained in:
Acclorite 2025-01-07 20:04:26 +02:00
parent a407e54c56
commit c9832c3183
7 changed files with 147 additions and 10 deletions

View file

@ -1,11 +1,16 @@
package ua.acclorite.book_story.data.parser package ua.acclorite.book_story.data.parser
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.compose.ui.graphics.asImageBitmap
import kotlinx.coroutines.yield import kotlinx.coroutines.yield
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
import ua.acclorite.book_story.domain.reader.ReaderText import ua.acclorite.book_story.domain.reader.ReaderText
import ua.acclorite.book_story.presentation.core.util.clearAllMarkdown import ua.acclorite.book_story.presentation.core.util.clearAllMarkdown
import ua.acclorite.book_story.presentation.core.util.clearMarkdown import ua.acclorite.book_story.presentation.core.util.clearMarkdown
import ua.acclorite.book_story.presentation.core.util.containsVisibleText import ua.acclorite.book_story.presentation.core.util.containsVisibleText
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
import javax.inject.Inject import javax.inject.Inject
class DocumentParser @Inject constructor( class DocumentParser @Inject constructor(
@ -18,7 +23,11 @@ class DocumentParser @Inject constructor(
* *
* @return Parsed text line by line with Markdown(all lines are not blank). * @return Parsed text line by line with Markdown(all lines are not blank).
*/ */
suspend fun Document.parseDocument(includeChapter: Boolean = true): List<ReaderText> { suspend fun Document.parseDocument(
zipFile: ZipFile? = null,
imageEntries: List<ZipEntry>? = null,
includeChapter: Boolean = true
): List<ReaderText> {
yield() yield()
val readerText = mutableListOf<ReaderText>() val readerText = mutableListOf<ReaderText>()
@ -53,7 +62,26 @@ class DocumentParser @Inject constructor(
element.prepend("[") element.prepend("[")
element.append("]($link)") element.append("]($link)")
} }
}.wholeText().lines().forEachIndexed { index, line ->
// Image
select("img").forEach { element ->
val src = element.attr("src")
.trim()
.substringAfterLast("/")
.lowercase()
.takeIf {
it.containsVisibleText() && imageEntries?.any { image ->
it == image.name.substringAfterLast('/').lowercase()
} == true
} ?: return@forEach
val alt = element.attr("alt").trim().takeIf {
it.clearMarkdown().containsVisibleText()
} ?: src.substringBeforeLast(".")
element.append("\n[[$src|$alt]]\n")
}
}.wholeText().lines().forEach { line ->
yield() yield()
val formattedLine = line.replace( val formattedLine = line.replace(
@ -64,9 +92,54 @@ class DocumentParser @Inject constructor(
Regex("""_\s*(.*?)\s*_"""), "_$1_" Regex("""_\s*(.*?)\s*_"""), "_$1_"
).trim() ).trim()
val imageRegex = Regex("""\[\[(.*?)\|(.*?)]]""")
if (line.containsVisibleText()) { if (line.containsVisibleText()) {
when (line) { when {
"***", "---" -> readerText.add(ReaderText.Separator) imageRegex.matches(line) -> {
val trimmedLine = line.removeSurrounding("[[", "]]")
val src = trimmedLine.substringBefore("|")
val alt = "_${trimmedLine.substringAfter("|")}_"
val image = try {
val imageEntry = imageEntries?.find { image ->
src == image.name.substringAfterLast('/').lowercase()
} ?: return@forEach
zipFile?.getInputStream(imageEntry)?.use { inputStream ->
val options = BitmapFactory.Options().apply {
inSampleSize = 2
inPreferredConfig = Bitmap.Config.RGB_565
}
BitmapFactory.decodeStream(
/* is = */ inputStream,
/* outPadding = */ null,
/* opts = */ options
)?.asImageBitmap()
}
} catch (e: Exception) {
e.printStackTrace()
return@forEach
}
if (image == null) return@forEach
// Optimize
image.prepareToDraw()
readerText.add( // Adding image
ReaderText.Image(
imageBitmap = image
)
)
readerText.add( // Adding alternative text (caption) for image
ReaderText.Text(
markdownParser.parse(alt)
)
)
}
line == "---" || line == "***" -> readerText.add(ReaderText.Separator)
else -> { else -> {
if ( if (

View file

@ -15,6 +15,8 @@ import org.jsoup.Jsoup
import ua.acclorite.book_story.data.parser.DocumentParser import ua.acclorite.book_story.data.parser.DocumentParser
import ua.acclorite.book_story.data.parser.TextParser import ua.acclorite.book_story.data.parser.TextParser
import ua.acclorite.book_story.domain.reader.ReaderText import ua.acclorite.book_story.domain.reader.ReaderText
import ua.acclorite.book_story.presentation.core.constants.Constants
import ua.acclorite.book_story.presentation.core.constants.provideImageExtensions
import ua.acclorite.book_story.presentation.core.util.addAll import ua.acclorite.book_story.presentation.core.util.addAll
import ua.acclorite.book_story.presentation.core.util.containsVisibleText import ua.acclorite.book_story.presentation.core.util.containsVisibleText
import java.io.File import java.io.File
@ -50,6 +52,11 @@ class EpubTextParser @Inject constructor(
} }
val chapterEntries = zip.getChapterEntries(opfEntry) val chapterEntries = zip.getChapterEntries(opfEntry)
val imageEntries = zip.entries().toList().filter {
Constants.provideImageExtensions().any { format ->
it.name.endsWith(format, ignoreCase = true)
}
}
val chapterTitleEntries = zip.getChapterTitleMapFromToc(tocEntry) val chapterTitleEntries = zip.getChapterTitleMapFromToc(tocEntry)
Log.i(EPUB_TAG, "TOC Entry: ${tocEntry?.name ?: "no toc.ncx"}") Log.i(EPUB_TAG, "TOC Entry: ${tocEntry?.name ?: "no toc.ncx"}")
@ -59,6 +66,7 @@ class EpubTextParser @Inject constructor(
readerText = zip.parseEpub( readerText = zip.parseEpub(
chapterEntries = chapterEntries, chapterEntries = chapterEntries,
imageEntries = imageEntries,
chapterTitleEntries = chapterTitleEntries chapterTitleEntries = chapterTitleEntries
) )
} }
@ -94,6 +102,7 @@ class EpubTextParser @Inject constructor(
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
private suspend fun ZipFile.parseEpub( private suspend fun ZipFile.parseEpub(
chapterEntries: List<ZipEntry>, chapterEntries: List<ZipEntry>,
imageEntries: List<ZipEntry>,
chapterTitleEntries: Map<Title, List<String>>? chapterTitleEntries: Map<Title, List<String>>?
): List<ReaderText> { ): List<ReaderText> {
@ -110,6 +119,7 @@ class EpubTextParser @Inject constructor(
zip = this@parseEpub, zip = this@parseEpub,
index = index, index = index,
entry = entry, entry = entry,
imageEntries = imageEntries,
chapterTitleMap = chapterTitleEntries chapterTitleMap = chapterTitleEntries
) )
@ -143,12 +153,17 @@ class EpubTextParser @Inject constructor(
zip: ZipFile, zip: ZipFile,
index: Int, index: Int,
entry: ZipEntry, entry: ZipEntry,
imageEntries: List<ZipEntry>,
chapterTitleMap: Map<Title, List<String>>? chapterTitleMap: Map<Title, List<String>>?
) { ) {
// Getting all text // Getting all text
val content = zip.getInputStream(entry).bufferedReader().use { it.readText() } val content = zip.getInputStream(entry).bufferedReader().use { it.readText() }
var readerText = documentParser.run { var readerText = documentParser.run {
Jsoup.parse(content).parseDocument(includeChapter = false) Jsoup.parse(content).parseDocument(
zipFile = zip,
imageEntries = imageEntries,
includeChapter = false
)
}.toMutableList() }.toMutableList()
// Adding chapter title from TOC if found // Adding chapter title from TOC if found

View file

@ -1,6 +1,7 @@
package ua.acclorite.book_story.domain.reader package ua.acclorite.book_story.domain.reader
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
@Immutable @Immutable
@ -13,4 +14,9 @@ sealed class ReaderText {
@Immutable @Immutable
data object Separator : ReaderText() data object Separator : ReaderText()
@Immutable
data class Image(
val imageBitmap: ImageBitmap
) : ReaderText()
} }

View file

@ -10,3 +10,9 @@ fun Constants.provideExtensions() = listOf(
".htm", ".htm",
".md" ".md"
) )
fun Constants.provideImageExtensions() = listOf(
".png",
".jpg",
".jpeg"
)

View file

@ -156,7 +156,7 @@ fun ReaderLayout(
ReaderLayoutText( ReaderLayoutText(
activity = activity, activity = activity,
showMenu = showMenu, showMenu = showMenu,
textEntry = textEntry, entry = textEntry,
fontFamily = fontFamily, fontFamily = fontFamily,
fontColor = fontColor, fontColor = fontColor,
lineHeight = lineHeight, lineHeight = lineHeight,

View file

@ -18,7 +18,7 @@ import ua.acclorite.book_story.ui.reader.ReaderEvent
fun LazyItemScope.ReaderLayoutText( fun LazyItemScope.ReaderLayoutText(
activity: ComponentActivity, activity: ComponentActivity,
showMenu: Boolean, showMenu: Boolean,
textEntry: ReaderText, entry: ReaderText,
fontFamily: FontWithName, fontFamily: FontWithName,
fontColor: Color, fontColor: Color,
lineHeight: TextUnit, lineHeight: TextUnit,
@ -38,7 +38,14 @@ fun LazyItemScope.ReaderLayoutText(
openTranslator: (ReaderEvent.OnOpenTranslator) -> Unit, openTranslator: (ReaderEvent.OnOpenTranslator) -> Unit,
menuVisibility: (ReaderEvent.OnMenuVisibility) -> Unit menuVisibility: (ReaderEvent.OnMenuVisibility) -> Unit
) { ) {
when (textEntry) { when (entry) {
is ReaderText.Image -> {
ReaderLayoutTextImage(
entry = entry,
sidePadding = sidePadding
)
}
is ReaderText.Separator -> { is ReaderText.Separator -> {
ReaderLayoutTextSeparator( ReaderLayoutTextSeparator(
sidePadding = sidePadding, sidePadding = sidePadding,
@ -48,7 +55,7 @@ fun LazyItemScope.ReaderLayoutText(
is ReaderText.Chapter -> { is ReaderText.Chapter -> {
ReaderLayoutTextChapter( ReaderLayoutTextChapter(
chapter = textEntry, chapter = entry,
chapterTitleAlignment = chapterTitleAlignment, chapterTitleAlignment = chapterTitleAlignment,
fontColor = fontColor, fontColor = fontColor,
sidePadding = sidePadding, sidePadding = sidePadding,
@ -59,7 +66,7 @@ fun LazyItemScope.ReaderLayoutText(
is ReaderText.Text -> { is ReaderText.Text -> {
ReaderLayoutTextParagraph( ReaderLayoutTextParagraph(
paragraph = textEntry, paragraph = entry,
activity = activity, activity = activity,
showMenu = showMenu, showMenu = showMenu,
fontFamily = fontFamily, fontFamily = fontFamily,

View file

@ -0,0 +1,30 @@
package ua.acclorite.book_story.presentation.reader
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.Dp
import ua.acclorite.book_story.domain.reader.ReaderText
@Composable
fun LazyItemScope.ReaderLayoutTextImage(
entry: ReaderText.Image,
sidePadding: Dp
) {
Image(
modifier = Modifier
.animateItem(
fadeInSpec = null,
fadeOutSpec = null
)
.padding(horizontal = sidePadding)
.fillMaxWidth(),
bitmap = entry.imageBitmap,
contentDescription = null,
contentScale = ContentScale.FillWidth
)
}