refactor: replace pagination engine with TextLayoutResult-first per-block layout
- New models: BlockMetrics (sealed), LineMetric, PageSpan, ReaderPage2, PaginationResult, ReaderConstraints, FingerprintKey - New engine: PaginationEngine interface + TextMeasurerPaginationEngine with BlockMetricsFactory (getLineTop/Bottom/VisibleEnd) and PagePacker (greedy fill with widow/orphan policy + paragraph splitting) - New infrastructure: PaginationCache (LRU), WindowedPaginationState - UI: ReaderPaginationLayout rewritten for ReaderPage2/PageSpan rendering, ReaderPageContent2 renders blocks from spans with sub-sequence support - ReaderScreen: replaced produceState with LaunchedEffect + mutableStateOf, uses screenModel.paginationEngine directly - ReaderModel: injects TextMeasurerPaginationEngine, uses firstBlockIndex instead of startTextIndex - Removed: PaginateReaderTextUseCase (380 lines), readerChapterTextStyle, TextMeasurerModule (unnecessary, TextMeasurer passed from composable) - Removed: unused ReaderTextAlignment import from BlockMetricsFactory
This commit is contained in:
parent
e78470b96a
commit
e1276fcee1
23 changed files with 876 additions and 489 deletions
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.domain.model.reader.pagination
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
|
||||
@Immutable
|
||||
sealed class BlockMetrics {
|
||||
abstract val blockIndex: Int
|
||||
abstract val height: Int
|
||||
|
||||
@Immutable
|
||||
data class TextMetrics(
|
||||
override val blockIndex: Int,
|
||||
override val height: Int,
|
||||
val lineMetrics: List<LineMetric>,
|
||||
val layoutResult: TextLayoutResult?,
|
||||
) : BlockMetrics()
|
||||
|
||||
@Immutable
|
||||
data class ChapterMetrics(
|
||||
override val blockIndex: Int,
|
||||
override val height: Int,
|
||||
) : BlockMetrics()
|
||||
|
||||
@Immutable
|
||||
data class ImageMetrics(
|
||||
override val blockIndex: Int,
|
||||
override val height: Int,
|
||||
) : BlockMetrics()
|
||||
|
||||
@Immutable
|
||||
data class SeparatorMetrics(
|
||||
override val blockIndex: Int,
|
||||
override val height: Int,
|
||||
) : BlockMetrics()
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.domain.model.reader.pagination
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
@Immutable
|
||||
data class LineMetric(
|
||||
val top: Float,
|
||||
val bottom: Float,
|
||||
val visibleEnd: Int,
|
||||
)
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.domain.model.reader.pagination
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
@Immutable
|
||||
data class PageSpan(
|
||||
val blockIndex: Int,
|
||||
val startChar: Int,
|
||||
val endChar: Int,
|
||||
) {
|
||||
val isAtomic: Boolean get() = startChar == 0 && endChar == 0
|
||||
|
||||
companion object {
|
||||
fun atomic(blockIndex: Int) = PageSpan(
|
||||
blockIndex = blockIndex,
|
||||
startChar = 0,
|
||||
endChar = 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.domain.model.reader.pagination
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
@Immutable
|
||||
data class PaginationResult(
|
||||
val pages: List<ReaderPage2>,
|
||||
val totalBlocks: Int,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
data class FingerprintKey(
|
||||
val contentHash: Int,
|
||||
val styleFingerprint: Int,
|
||||
val constraintsFingerprint: Int,
|
||||
)
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.domain.model.reader.pagination
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
@Immutable
|
||||
data class ReaderConstraints(
|
||||
val contentWidthPx: Int,
|
||||
val contentHeightPx: Int,
|
||||
val paragraphSpacingPx: Int,
|
||||
val safetyMarginPx: Int = 4,
|
||||
val minWidowLines: Int = 2,
|
||||
val minOrphanLines: Int = 2,
|
||||
val chapterKeepLines: Int = 2,
|
||||
val separatorHeightPx: Int = DEFAULT_SEPARATOR_HEIGHT_PX,
|
||||
val defaultImageHeightPx: Int = DEFAULT_IMAGE_HEIGHT_PX,
|
||||
) {
|
||||
val availableHeight: Int get() = (contentHeightPx - safetyMarginPx).coerceAtLeast(1)
|
||||
|
||||
companion object {
|
||||
private const val DEFAULT_SEPARATOR_HEIGHT_PX = 12
|
||||
private const val DEFAULT_IMAGE_HEIGHT_PX = 720
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.domain.model.reader.pagination
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
@Immutable
|
||||
data class ReaderPage2(
|
||||
val index: Int,
|
||||
val spans: List<PageSpan>,
|
||||
) {
|
||||
val firstBlockIndex: Int get() = spans.firstOrNull()?.blockIndex ?: 0
|
||||
val lastBlockIndex: Int get() = spans.lastOrNull()?.blockIndex ?: 0
|
||||
}
|
||||
|
|
@ -1,380 +0,0 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.domain.use_case.reader
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.text.TextMeasurer
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import org.dueattendant149.bookshelf.domain.model.reader.ReaderPage
|
||||
import org.dueattendant149.bookshelf.domain.model.reader.ReaderText
|
||||
import org.dueattendant149.bookshelf.presentation.reader.model.ReaderTextAlignment
|
||||
import org.dueattendant149.bookshelf.ui.reader.readerChapterTextStyle
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class PaginateReaderTextUseCase @Inject constructor() {
|
||||
|
||||
operator fun invoke(
|
||||
text: List<ReaderText>,
|
||||
contentWidthPx: Int,
|
||||
contentHeightPx: Int,
|
||||
paragraphStyle: TextStyle,
|
||||
chapterTitleAlignment: ReaderTextAlignment,
|
||||
paragraphSpacingPx: Int,
|
||||
textMeasurer: TextMeasurer,
|
||||
imageMaxWidthPx: Int,
|
||||
chapterKeepLines: Int = 2,
|
||||
minWidowLines: Int = 2,
|
||||
minOrphanLines: Int = 2,
|
||||
safetyHeightPx: Int = 4,
|
||||
chapterExtraHeightPx: Int = 0,
|
||||
separatorHeightPx: Int = DEFAULT_SEPARATOR_HEIGHT_PX,
|
||||
defaultImageHeightPx: Int = DEFAULT_IMAGE_HEIGHT_PX,
|
||||
): List<ReaderPage> {
|
||||
if (text.isEmpty() || contentWidthPx <= 0 || contentHeightPx <= 0) return emptyList()
|
||||
|
||||
val availableHeight = (contentHeightPx - safetyHeightPx).coerceAtLeast(1)
|
||||
val workingBlocks = text.toMutableList()
|
||||
val metrics = workingBlocks.map { block ->
|
||||
measureBlock(
|
||||
block = block,
|
||||
contentWidthPx = contentWidthPx,
|
||||
imageMaxWidthPx = imageMaxWidthPx,
|
||||
paragraphStyle = paragraphStyle,
|
||||
chapterTitleAlignment = chapterTitleAlignment,
|
||||
textMeasurer = textMeasurer,
|
||||
chapterExtraHeightPx = chapterExtraHeightPx,
|
||||
separatorHeightPx = separatorHeightPx,
|
||||
defaultImageHeightPx = defaultImageHeightPx,
|
||||
)
|
||||
}.toMutableList()
|
||||
|
||||
val pages = mutableListOf<ReaderPage>()
|
||||
val pageItems = mutableListOf<ReaderText>()
|
||||
var startTextIndex = 0
|
||||
var remainingHeight = availableHeight
|
||||
var currentPageIndex = 0
|
||||
var index = 0
|
||||
|
||||
/**
|
||||
* Returns the height of [metric] plus the inter-item spacing if this
|
||||
* block is not the first one on the current page. This matches the
|
||||
* [Arrangement.spacedBy] used in the paginated reader UI.
|
||||
*/
|
||||
fun heightWithSpacing(metric: BlockMetrics): Int {
|
||||
return if (pageItems.isEmpty()) metric.height else metric.height + paragraphSpacingPx
|
||||
}
|
||||
|
||||
fun emitPage(endTextIndex: Int) {
|
||||
if (pageItems.isEmpty()) return
|
||||
pages.add(
|
||||
ReaderPage(
|
||||
index = currentPageIndex,
|
||||
items = pageItems.toList(),
|
||||
startTextIndex = startTextIndex,
|
||||
endTextIndex = endTextIndex,
|
||||
)
|
||||
)
|
||||
currentPageIndex++
|
||||
pageItems.clear()
|
||||
remainingHeight = availableHeight
|
||||
startTextIndex = endTextIndex
|
||||
}
|
||||
|
||||
while (index < workingBlocks.size) {
|
||||
val block = workingBlocks[index]
|
||||
val metric = metrics[index]
|
||||
val blockHeight = heightWithSpacing(metric)
|
||||
|
||||
when (block) {
|
||||
is ReaderText.Chapter -> {
|
||||
val keep = followingTextKeepHeight(
|
||||
blocks = workingBlocks,
|
||||
metrics = metrics,
|
||||
startIndex = index + 1,
|
||||
keepLines = chapterKeepLines,
|
||||
)
|
||||
// Heading itself + spacing after it (if anything follows) + kept text.
|
||||
val requiredWithKeep = blockHeight +
|
||||
(if (index < workingBlocks.lastIndex) paragraphSpacingPx else 0) +
|
||||
keep
|
||||
|
||||
when {
|
||||
requiredWithKeep > availableHeight -> {
|
||||
// Heading + kept lines don't fit on a fresh page.
|
||||
// Place heading alone.
|
||||
if (blockHeight > remainingHeight && pageItems.isNotEmpty()) {
|
||||
emitPage(index)
|
||||
}
|
||||
pageItems.add(block)
|
||||
remainingHeight = (remainingHeight - blockHeight).coerceAtLeast(0)
|
||||
index++
|
||||
}
|
||||
|
||||
requiredWithKeep > remainingHeight && pageItems.isNotEmpty() -> {
|
||||
// Move heading to the next page so it stays with following text.
|
||||
emitPage(index)
|
||||
}
|
||||
|
||||
else -> {
|
||||
pageItems.add(block)
|
||||
remainingHeight = (remainingHeight - blockHeight).coerceAtLeast(0)
|
||||
index++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is ReaderText.Separator,
|
||||
is ReaderText.Image -> {
|
||||
if (blockHeight > remainingHeight && pageItems.isNotEmpty()) {
|
||||
emitPage(index)
|
||||
}
|
||||
pageItems.add(block)
|
||||
remainingHeight = (remainingHeight - blockHeight).coerceAtLeast(0)
|
||||
index++
|
||||
}
|
||||
|
||||
is ReaderText.Text -> {
|
||||
if (blockHeight <= remainingHeight) {
|
||||
pageItems.add(block)
|
||||
remainingHeight = (remainingHeight - blockHeight).coerceAtLeast(0)
|
||||
index++
|
||||
} else if (pageItems.isNotEmpty()) {
|
||||
emitPage(index)
|
||||
// Retry the same block on a fresh page.
|
||||
} else {
|
||||
// Fresh page and the paragraph still doesn't fit whole.
|
||||
val split = splitParagraph(
|
||||
block = block,
|
||||
metric = metric,
|
||||
availableHeight = remainingHeight,
|
||||
minWidowLines = minWidowLines,
|
||||
minOrphanLines = minOrphanLines,
|
||||
)
|
||||
|
||||
if (split == null) {
|
||||
// Whole paragraph fits on a fresh page but we shouldn't be here.
|
||||
// Safety fallback: place it anyway.
|
||||
pageItems.add(block)
|
||||
remainingHeight = 0
|
||||
index++
|
||||
} else {
|
||||
val (head, tail) = split
|
||||
pageItems.add(ReaderText.Text(head))
|
||||
emitPage(index + 1)
|
||||
// The remainder is still the same logical block, so the next page
|
||||
// should start at this index rather than index + 1.
|
||||
startTextIndex = index
|
||||
|
||||
if (tail.isBlank()) {
|
||||
index++
|
||||
} else {
|
||||
// Replace current block with remainder and retry on fresh page.
|
||||
val remainderBlock = ReaderText.Text(tail)
|
||||
workingBlocks[index] = remainderBlock
|
||||
metrics[index] = measureBlock(
|
||||
block = remainderBlock,
|
||||
contentWidthPx = contentWidthPx,
|
||||
imageMaxWidthPx = imageMaxWidthPx,
|
||||
paragraphStyle = paragraphStyle,
|
||||
chapterTitleAlignment = chapterTitleAlignment,
|
||||
textMeasurer = textMeasurer,
|
||||
chapterExtraHeightPx = chapterExtraHeightPx,
|
||||
separatorHeightPx = separatorHeightPx,
|
||||
defaultImageHeightPx = defaultImageHeightPx,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pageItems.isNotEmpty()) {
|
||||
emitPage(workingBlocks.size)
|
||||
}
|
||||
|
||||
Log.d("Pagination", "Split ${text.size} blocks into ${pages.size} pages")
|
||||
return pages
|
||||
}
|
||||
|
||||
private fun measureBlock(
|
||||
block: ReaderText,
|
||||
contentWidthPx: Int,
|
||||
imageMaxWidthPx: Int,
|
||||
paragraphStyle: TextStyle,
|
||||
chapterTitleAlignment: ReaderTextAlignment,
|
||||
textMeasurer: TextMeasurer,
|
||||
chapterExtraHeightPx: Int,
|
||||
separatorHeightPx: Int,
|
||||
defaultImageHeightPx: Int,
|
||||
): BlockMetrics {
|
||||
return when (block) {
|
||||
is ReaderText.Text -> {
|
||||
val layout = measureText(block.line, paragraphStyle, contentWidthPx, textMeasurer)
|
||||
val textHeight = layout?.size?.height ?: 0
|
||||
val lineCount = layout?.lineCount ?: 0
|
||||
val lineHeights = (0 until lineCount).map { lineIndex ->
|
||||
layout!!.getLineBottom(lineIndex)
|
||||
}
|
||||
BlockMetrics(
|
||||
height = textHeight,
|
||||
lineHeights = lineHeights,
|
||||
isSplittable = true,
|
||||
layout = layout,
|
||||
)
|
||||
}
|
||||
|
||||
is ReaderText.Chapter -> {
|
||||
val chapterStyle = readerChapterTextStyle(
|
||||
nested = block.nested,
|
||||
textAlignment = chapterTitleAlignment,
|
||||
)
|
||||
val layout = measureText(
|
||||
AnnotatedString(block.title),
|
||||
chapterStyle,
|
||||
contentWidthPx,
|
||||
textMeasurer,
|
||||
)
|
||||
val titleHeight = layout?.size?.height ?: 0
|
||||
BlockMetrics(
|
||||
height = titleHeight + chapterExtraHeightPx,
|
||||
isSplittable = false,
|
||||
)
|
||||
}
|
||||
|
||||
is ReaderText.Separator -> BlockMetrics(
|
||||
height = separatorHeightPx,
|
||||
isSplittable = false,
|
||||
)
|
||||
|
||||
is ReaderText.Image -> BlockMetrics(
|
||||
height = imageHeight(block, imageMaxWidthPx, defaultImageHeightPx),
|
||||
isSplittable = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the height of the first [keepLines] lines of the text block at
|
||||
* [startIndex], or the full height of the block if it has fewer lines. If
|
||||
* the next block is not splittable (e.g. an image), its full height is
|
||||
* returned so the heading can be moved to the next page when necessary.
|
||||
*/
|
||||
private fun followingTextKeepHeight(
|
||||
blocks: List<ReaderText>,
|
||||
metrics: List<BlockMetrics>,
|
||||
startIndex: Int,
|
||||
keepLines: Int,
|
||||
): Int {
|
||||
if (startIndex >= blocks.size) return 0
|
||||
val metric = metrics[startIndex]
|
||||
val block = blocks[startIndex]
|
||||
|
||||
if (metric.lineHeights.isEmpty()) return metric.height
|
||||
|
||||
if (block !is ReaderText.Text) {
|
||||
// Keep the whole non-text block with the heading.
|
||||
return metric.height
|
||||
}
|
||||
|
||||
val linesToKeep = minOf(keepLines, metric.lineHeights.size)
|
||||
return metric.lineHeights[linesToKeep - 1].toInt()
|
||||
}
|
||||
|
||||
private fun splitParagraph(
|
||||
block: ReaderText.Text,
|
||||
metric: BlockMetrics,
|
||||
availableHeight: Int,
|
||||
minWidowLines: Int,
|
||||
minOrphanLines: Int,
|
||||
): Pair<AnnotatedString, AnnotatedString>? {
|
||||
val layout = metric.layout ?: return null
|
||||
val lineCount = layout.lineCount
|
||||
if (lineCount == 0) return null
|
||||
|
||||
// Find the last line whose bottom fits inside the available height.
|
||||
val lastFittingLine = layout.getLineForVerticalPosition(availableHeight.toFloat())
|
||||
.coerceIn(0, lineCount - 1)
|
||||
|
||||
val maxLinesCurrent = lastFittingLine + 1
|
||||
val minLinesCurrent = minWidowLines.coerceAtLeast(1)
|
||||
val minLinesRemainder = minOrphanLines.coerceAtLeast(1)
|
||||
|
||||
// Ideal break: current page has at least minWidowLines,
|
||||
// remainder has at least minOrphanLines.
|
||||
val idealMax = (lineCount - minLinesRemainder).coerceAtLeast(minLinesCurrent)
|
||||
val candidateLines = maxLinesCurrent.coerceIn(minLinesCurrent, idealMax)
|
||||
|
||||
if (candidateLines in minLinesCurrent..idealMax) {
|
||||
val breakChar = layout.getLineEnd(candidateLines - 1, visibleEnd = true)
|
||||
if (breakChar in 1 until block.line.length) {
|
||||
return block.line.subSequence(0, breakChar) to
|
||||
block.line.subSequence(breakChar, block.line.length)
|
||||
}
|
||||
}
|
||||
|
||||
// Paragraph is longer than a full page or constraints cannot be satisfied.
|
||||
// Split at the last line that actually fits.
|
||||
val forcedLines = maxLinesCurrent.coerceIn(1, lineCount - 1)
|
||||
val breakChar = layout.getLineEnd(forcedLines - 1, visibleEnd = true)
|
||||
if (breakChar in 1 until block.line.length) {
|
||||
return block.line.subSequence(0, breakChar) to
|
||||
block.line.subSequence(breakChar, block.line.length)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun measureText(
|
||||
text: AnnotatedString,
|
||||
style: TextStyle,
|
||||
maxWidthPx: Int,
|
||||
textMeasurer: TextMeasurer,
|
||||
): TextLayoutResult? {
|
||||
if (text.isBlank()) return null
|
||||
return try {
|
||||
textMeasurer.measure(
|
||||
text = text,
|
||||
style = style,
|
||||
constraints = Constraints(maxWidth = maxWidthPx),
|
||||
softWrap = true,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e("Pagination", "measure failed", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun imageHeight(
|
||||
block: ReaderText.Image,
|
||||
imageMaxWidthPx: Int,
|
||||
defaultImageHeightPx: Int,
|
||||
): Int {
|
||||
val bitmap = block.imageBitmap
|
||||
val width = bitmap.width
|
||||
val height = bitmap.height
|
||||
if (width <= 0 || height <= 0) return defaultImageHeightPx
|
||||
return (imageMaxWidthPx * height / width).toInt()
|
||||
}
|
||||
|
||||
private data class BlockMetrics(
|
||||
val height: Int,
|
||||
val lineHeights: List<Float> = emptyList(),
|
||||
val isSplittable: Boolean = false,
|
||||
val layout: TextLayoutResult? = null,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val DEFAULT_SEPARATOR_HEIGHT_PX = 12
|
||||
private const val DEFAULT_IMAGE_HEIGHT_PX = 720
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.domain.use_case.reader.pagination
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.PlatformTextStyle
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.text.TextMeasurer
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextIndent
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.dueattendant149.bookshelf.domain.model.reader.ReaderText
|
||||
import org.dueattendant149.bookshelf.domain.model.reader.pagination.BlockMetrics
|
||||
import org.dueattendant149.bookshelf.domain.model.reader.pagination.LineMetric
|
||||
|
||||
object BlockMetricsFactory {
|
||||
|
||||
fun measureAll(
|
||||
blocks: List<ReaderText>,
|
||||
contentWidthPx: Int,
|
||||
imageMaxWidthPx: Int,
|
||||
paragraphStyle: TextStyle,
|
||||
textMeasurer: TextMeasurer,
|
||||
chapterExtraHeightPx: Int,
|
||||
separatorHeightPx: Int,
|
||||
defaultImageHeightPx: Int,
|
||||
chapterTitleAlignment: TextAlign = TextAlign.Start,
|
||||
): List<BlockMetrics> {
|
||||
return blocks.mapIndexed { index, block ->
|
||||
measureBlock(
|
||||
block = block,
|
||||
blockIndex = index,
|
||||
contentWidthPx = contentWidthPx,
|
||||
imageMaxWidthPx = imageMaxWidthPx,
|
||||
paragraphStyle = paragraphStyle,
|
||||
textMeasurer = textMeasurer,
|
||||
chapterExtraHeightPx = chapterExtraHeightPx,
|
||||
separatorHeightPx = separatorHeightPx,
|
||||
defaultImageHeightPx = defaultImageHeightPx,
|
||||
chapterTitleAlignment = chapterTitleAlignment,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun measureBlock(
|
||||
block: ReaderText,
|
||||
blockIndex: Int,
|
||||
contentWidthPx: Int,
|
||||
imageMaxWidthPx: Int,
|
||||
paragraphStyle: TextStyle,
|
||||
textMeasurer: TextMeasurer,
|
||||
chapterExtraHeightPx: Int,
|
||||
separatorHeightPx: Int,
|
||||
defaultImageHeightPx: Int,
|
||||
chapterTitleAlignment: TextAlign = TextAlign.Start,
|
||||
): BlockMetrics {
|
||||
return when (block) {
|
||||
is ReaderText.Text -> {
|
||||
val layout = measureText(block.line, paragraphStyle, contentWidthPx, textMeasurer)
|
||||
val lineMetrics = if (layout != null) {
|
||||
(0 until layout.lineCount).map { lineIndex ->
|
||||
LineMetric(
|
||||
top = layout.getLineTop(lineIndex),
|
||||
bottom = layout.getLineBottom(lineIndex),
|
||||
visibleEnd = layout.getLineEnd(lineIndex, visibleEnd = true),
|
||||
)
|
||||
}
|
||||
} else emptyList()
|
||||
val height = layout?.size?.height ?: 0
|
||||
BlockMetrics.TextMetrics(
|
||||
blockIndex = blockIndex,
|
||||
height = height,
|
||||
lineMetrics = lineMetrics,
|
||||
layoutResult = layout,
|
||||
)
|
||||
}
|
||||
|
||||
is ReaderText.Chapter -> {
|
||||
val chapterStyle = TextStyle(
|
||||
fontSize = if (block.nested) 24.sp else 28.sp,
|
||||
lineHeight = if (block.nested) 32.sp else 36.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
textAlign = chapterTitleAlignment,
|
||||
platformStyle = PlatformTextStyle(includeFontPadding = false),
|
||||
)
|
||||
val layout = measureText(
|
||||
text = AnnotatedString(block.title),
|
||||
style = chapterStyle,
|
||||
maxWidthPx = contentWidthPx,
|
||||
textMeasurer = textMeasurer,
|
||||
)
|
||||
val titleHeight = layout?.size?.height ?: 0
|
||||
BlockMetrics.ChapterMetrics(
|
||||
blockIndex = blockIndex,
|
||||
height = titleHeight + chapterExtraHeightPx,
|
||||
)
|
||||
}
|
||||
|
||||
is ReaderText.Separator -> BlockMetrics.SeparatorMetrics(
|
||||
blockIndex = blockIndex,
|
||||
height = separatorHeightPx,
|
||||
)
|
||||
|
||||
is ReaderText.Image -> BlockMetrics.ImageMetrics(
|
||||
blockIndex = blockIndex,
|
||||
height = imageHeight(block, imageMaxWidthPx, defaultImageHeightPx),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun measureText(
|
||||
text: AnnotatedString,
|
||||
style: TextStyle,
|
||||
maxWidthPx: Int,
|
||||
textMeasurer: TextMeasurer,
|
||||
): TextLayoutResult? {
|
||||
if (text.isBlank()) return null
|
||||
return try {
|
||||
textMeasurer.measure(
|
||||
text = text,
|
||||
style = style,
|
||||
constraints = Constraints(maxWidth = maxWidthPx),
|
||||
softWrap = true,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e("BlockMetricsFactory", "measure failed", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun imageHeight(
|
||||
block: ReaderText.Image,
|
||||
imageMaxWidthPx: Int,
|
||||
defaultImageHeightPx: Int,
|
||||
): Int {
|
||||
val bitmap = block.imageBitmap
|
||||
val width = bitmap.width
|
||||
val height = bitmap.height
|
||||
if (width <= 0 || height <= 0) return defaultImageHeightPx
|
||||
return (imageMaxWidthPx * height / width).toInt()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.domain.use_case.reader.pagination
|
||||
|
||||
import android.util.Log
|
||||
import org.dueattendant149.bookshelf.domain.model.reader.ReaderText
|
||||
import org.dueattendant149.bookshelf.domain.model.reader.pagination.BlockMetrics
|
||||
import org.dueattendant149.bookshelf.domain.model.reader.pagination.PageSpan
|
||||
import org.dueattendant149.bookshelf.domain.model.reader.pagination.PaginationResult
|
||||
import org.dueattendant149.bookshelf.domain.model.reader.pagination.ReaderConstraints
|
||||
import org.dueattendant149.bookshelf.domain.model.reader.pagination.ReaderPage2
|
||||
|
||||
object PagePacker {
|
||||
|
||||
fun pack(
|
||||
blocks: List<ReaderText>,
|
||||
metrics: List<BlockMetrics>,
|
||||
constraints: ReaderConstraints,
|
||||
reMeasureText: (ReaderText.Text, Int) -> BlockMetrics,
|
||||
): PaginationResult {
|
||||
if (blocks.isEmpty() || metrics.isEmpty()) {
|
||||
return PaginationResult(pages = emptyList(), totalBlocks = blocks.size)
|
||||
}
|
||||
|
||||
val workingBlocks = blocks.toMutableList()
|
||||
val workingMetrics = metrics.toMutableList()
|
||||
val availableHeight = constraints.availableHeight
|
||||
val pages = mutableListOf<ReaderPage2>()
|
||||
val currentSpans = mutableListOf<PageSpan>()
|
||||
var remainingHeight = availableHeight
|
||||
var pageIndex = 0
|
||||
|
||||
fun heightWithSpacing(metric: BlockMetrics): Int {
|
||||
return if (currentSpans.isEmpty()) metric.height
|
||||
else metric.height + constraints.paragraphSpacingPx
|
||||
}
|
||||
|
||||
fun emitPage() {
|
||||
if (currentSpans.isEmpty()) return
|
||||
pages.add(ReaderPage2(index = pageIndex, spans = currentSpans.toList()))
|
||||
pageIndex++
|
||||
currentSpans.clear()
|
||||
remainingHeight = availableHeight
|
||||
}
|
||||
|
||||
var i = 0
|
||||
while (i < workingBlocks.size) {
|
||||
val block = workingBlocks[i]
|
||||
val metric = workingMetrics[i]
|
||||
val blockHeight = heightWithSpacing(metric)
|
||||
|
||||
when (metric) {
|
||||
is BlockMetrics.TextMetrics -> {
|
||||
if (blockHeight <= remainingHeight) {
|
||||
currentSpans.add(PageSpan.atomic(i))
|
||||
remainingHeight = (remainingHeight - blockHeight).coerceAtLeast(0)
|
||||
i++
|
||||
} else if (currentSpans.isNotEmpty()) {
|
||||
emitPage()
|
||||
} else {
|
||||
val split = splitParagraph(
|
||||
block = block as ReaderText.Text,
|
||||
metric = metric,
|
||||
availableHeight = remainingHeight,
|
||||
minWidowLines = constraints.minWidowLines,
|
||||
minOrphanLines = constraints.minOrphanLines,
|
||||
)
|
||||
if (split == null) {
|
||||
currentSpans.add(PageSpan.atomic(i))
|
||||
remainingHeight = 0
|
||||
i++
|
||||
} else {
|
||||
val (headEndChar, tailText) = split
|
||||
currentSpans.add(PageSpan(blockIndex = i, startChar = 0, endChar = headEndChar))
|
||||
emitPage()
|
||||
|
||||
if (tailText.isBlank()) {
|
||||
i++
|
||||
} else {
|
||||
val tailBlock = ReaderText.Text(tailText)
|
||||
workingBlocks[i] = tailBlock
|
||||
workingMetrics[i] = reMeasureText(tailBlock, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is BlockMetrics.ChapterMetrics -> {
|
||||
val keep = followingTextKeepHeight(
|
||||
metrics = workingMetrics,
|
||||
startIndex = i + 1,
|
||||
keepLines = constraints.chapterKeepLines,
|
||||
)
|
||||
val chapterSpacing = if (i < workingBlocks.lastIndex) constraints.paragraphSpacingPx else 0
|
||||
val requiredWithKeep = blockHeight + chapterSpacing + keep
|
||||
|
||||
when {
|
||||
requiredWithKeep > availableHeight -> {
|
||||
if (blockHeight > remainingHeight && currentSpans.isNotEmpty()) {
|
||||
emitPage()
|
||||
}
|
||||
currentSpans.add(PageSpan.atomic(i))
|
||||
remainingHeight = (remainingHeight - blockHeight).coerceAtLeast(0)
|
||||
i++
|
||||
}
|
||||
requiredWithKeep > remainingHeight && currentSpans.isNotEmpty() -> {
|
||||
emitPage()
|
||||
}
|
||||
else -> {
|
||||
currentSpans.add(PageSpan.atomic(i))
|
||||
remainingHeight = (remainingHeight - blockHeight).coerceAtLeast(0)
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is BlockMetrics.ImageMetrics,
|
||||
is BlockMetrics.SeparatorMetrics -> {
|
||||
if (blockHeight > remainingHeight && currentSpans.isNotEmpty()) {
|
||||
emitPage()
|
||||
}
|
||||
currentSpans.add(PageSpan.atomic(i))
|
||||
remainingHeight = (remainingHeight - blockHeight).coerceAtLeast(0)
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentSpans.isNotEmpty()) {
|
||||
emitPage()
|
||||
}
|
||||
|
||||
Log.d("PagePacker", "Packed ${blocks.size} blocks into ${pages.size} pages")
|
||||
return PaginationResult(pages = pages, totalBlocks = blocks.size)
|
||||
}
|
||||
|
||||
private fun splitParagraph(
|
||||
block: ReaderText.Text,
|
||||
metric: BlockMetrics.TextMetrics,
|
||||
availableHeight: Int,
|
||||
minWidowLines: Int,
|
||||
minOrphanLines: Int,
|
||||
): Pair<Int, androidx.compose.ui.text.AnnotatedString>? {
|
||||
val layout = metric.layoutResult ?: return null
|
||||
val lineCount = layout.lineCount
|
||||
if (lineCount == 0) return null
|
||||
|
||||
val lastFittingLine = layout.getLineForVerticalPosition(availableHeight.toFloat())
|
||||
.coerceIn(0, lineCount - 1)
|
||||
|
||||
val maxLinesCurrent = lastFittingLine + 1
|
||||
val minLinesCurrent = minWidowLines.coerceAtLeast(1)
|
||||
val minLinesRemainder = minOrphanLines.coerceAtLeast(1)
|
||||
val idealMax = (lineCount - minLinesRemainder).coerceAtLeast(minLinesCurrent)
|
||||
val candidateLines = maxLinesCurrent.coerceIn(minLinesCurrent, idealMax)
|
||||
|
||||
if (candidateLines in minLinesCurrent..idealMax) {
|
||||
val breakChar = layout.getLineEnd(candidateLines - 1, visibleEnd = true)
|
||||
if (breakChar in 1 until block.line.length) {
|
||||
return breakChar to block.line.subSequence(breakChar, block.line.length)
|
||||
}
|
||||
}
|
||||
|
||||
val forcedLines = maxLinesCurrent.coerceIn(1, lineCount - 1)
|
||||
val breakChar = layout.getLineEnd(forcedLines - 1, visibleEnd = true)
|
||||
if (breakChar in 1 until block.line.length) {
|
||||
return breakChar to block.line.subSequence(breakChar, block.line.length)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun followingTextKeepHeight(
|
||||
metrics: List<BlockMetrics>,
|
||||
startIndex: Int,
|
||||
keepLines: Int,
|
||||
): Int {
|
||||
if (startIndex >= metrics.size) return 0
|
||||
val metric = metrics[startIndex]
|
||||
if (metric !is BlockMetrics.TextMetrics) return metric.height
|
||||
val linesToKeep = minOf(keepLines, metric.lineMetrics.size)
|
||||
if (linesToKeep == 0) return 0
|
||||
return metric.lineMetrics[linesToKeep - 1].bottom.toInt()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.domain.use_case.reader.pagination
|
||||
|
||||
import org.dueattendant149.bookshelf.domain.model.reader.pagination.FingerprintKey
|
||||
import org.dueattendant149.bookshelf.domain.model.reader.pagination.PaginationResult
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class PaginationCache @Inject constructor(
|
||||
private val maxSize: Int = 16,
|
||||
) {
|
||||
private val cache = object : LinkedHashMap<FingerprintKey, PaginationResult>() {
|
||||
override fun removeEldestEntry(eldest: Map.Entry<FingerprintKey, PaginationResult>): Boolean {
|
||||
return size > maxSize
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun get(key: FingerprintKey): PaginationResult? = cache[key]
|
||||
|
||||
@Synchronized
|
||||
fun put(key: FingerprintKey, result: PaginationResult) {
|
||||
cache[key] = result
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun clear() {
|
||||
cache.clear()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.domain.use_case.reader.pagination
|
||||
|
||||
import androidx.compose.ui.text.TextMeasurer
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import org.dueattendant149.bookshelf.domain.model.reader.ReaderText
|
||||
import org.dueattendant149.bookshelf.domain.model.reader.pagination.PaginationResult
|
||||
import org.dueattendant149.bookshelf.domain.model.reader.pagination.ReaderConstraints
|
||||
|
||||
interface PaginationEngine {
|
||||
fun paginate(
|
||||
blocks: List<ReaderText>,
|
||||
constraints: ReaderConstraints,
|
||||
paragraphStyle: TextStyle,
|
||||
textMeasurer: TextMeasurer,
|
||||
imageMaxWidthPx: Int,
|
||||
chapterExtraHeightPx: Int,
|
||||
chapterTitleAlignment: TextAlign = TextAlign.Start,
|
||||
): PaginationResult
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.domain.use_case.reader.pagination
|
||||
|
||||
import androidx.compose.ui.text.TextMeasurer
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import org.dueattendant149.bookshelf.domain.model.reader.ReaderText
|
||||
import org.dueattendant149.bookshelf.domain.model.reader.pagination.BlockMetrics
|
||||
import org.dueattendant149.bookshelf.domain.model.reader.pagination.PaginationResult
|
||||
import org.dueattendant149.bookshelf.domain.model.reader.pagination.ReaderConstraints
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class TextMeasurerPaginationEngine @Inject constructor() : PaginationEngine {
|
||||
|
||||
override fun paginate(
|
||||
blocks: List<ReaderText>,
|
||||
constraints: ReaderConstraints,
|
||||
paragraphStyle: TextStyle,
|
||||
textMeasurer: TextMeasurer,
|
||||
imageMaxWidthPx: Int,
|
||||
chapterExtraHeightPx: Int,
|
||||
chapterTitleAlignment: TextAlign,
|
||||
): PaginationResult {
|
||||
val metrics = BlockMetricsFactory.measureAll(
|
||||
blocks = blocks,
|
||||
contentWidthPx = constraints.contentWidthPx,
|
||||
imageMaxWidthPx = imageMaxWidthPx,
|
||||
paragraphStyle = paragraphStyle,
|
||||
textMeasurer = textMeasurer,
|
||||
chapterExtraHeightPx = chapterExtraHeightPx,
|
||||
separatorHeightPx = constraints.separatorHeightPx,
|
||||
defaultImageHeightPx = constraints.defaultImageHeightPx,
|
||||
chapterTitleAlignment = chapterTitleAlignment,
|
||||
)
|
||||
|
||||
return PagePacker.pack(
|
||||
blocks = blocks,
|
||||
metrics = metrics,
|
||||
constraints = constraints,
|
||||
reMeasureText = { tailBlock, blockIndex ->
|
||||
measureSingleBlock(
|
||||
block = tailBlock,
|
||||
blockIndex = blockIndex,
|
||||
contentWidthPx = constraints.contentWidthPx,
|
||||
imageMaxWidthPx = imageMaxWidthPx,
|
||||
paragraphStyle = paragraphStyle,
|
||||
textMeasurer = textMeasurer,
|
||||
chapterExtraHeightPx = chapterExtraHeightPx,
|
||||
separatorHeightPx = constraints.separatorHeightPx,
|
||||
defaultImageHeightPx = constraints.defaultImageHeightPx,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun measureSingleBlock(
|
||||
block: ReaderText.Text,
|
||||
blockIndex: Int,
|
||||
contentWidthPx: Int,
|
||||
imageMaxWidthPx: Int,
|
||||
paragraphStyle: TextStyle,
|
||||
textMeasurer: TextMeasurer,
|
||||
chapterExtraHeightPx: Int,
|
||||
separatorHeightPx: Int,
|
||||
defaultImageHeightPx: Int,
|
||||
): BlockMetrics {
|
||||
val measured = BlockMetricsFactory.measureAll(
|
||||
blocks = listOf(block),
|
||||
contentWidthPx = contentWidthPx,
|
||||
imageMaxWidthPx = imageMaxWidthPx,
|
||||
paragraphStyle = paragraphStyle,
|
||||
textMeasurer = textMeasurer,
|
||||
chapterExtraHeightPx = chapterExtraHeightPx,
|
||||
separatorHeightPx = separatorHeightPx,
|
||||
defaultImageHeightPx = defaultImageHeightPx,
|
||||
).first()
|
||||
return if (measured is BlockMetrics.TextMetrics) {
|
||||
measured.copy(blockIndex = blockIndex)
|
||||
} else {
|
||||
measured
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.domain.use_case.reader.pagination
|
||||
|
||||
import androidx.compose.ui.text.TextMeasurer
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.dueattendant149.bookshelf.domain.model.reader.ReaderText
|
||||
import org.dueattendant149.bookshelf.domain.model.reader.pagination.FingerprintKey
|
||||
import org.dueattendant149.bookshelf.domain.model.reader.pagination.PaginationResult
|
||||
import org.dueattendant149.bookshelf.domain.model.reader.pagination.ReaderConstraints
|
||||
import org.dueattendant149.bookshelf.domain.model.reader.pagination.ReaderPage2
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.math.abs
|
||||
|
||||
@Singleton
|
||||
class WindowedPaginationState @Inject constructor(
|
||||
private val engine: TextMeasurerPaginationEngine,
|
||||
private val cache: PaginationCache,
|
||||
) {
|
||||
private val _result = MutableStateFlow(PaginationResult(emptyList(), 0))
|
||||
val result: StateFlow<PaginationResult> = _result.asStateFlow()
|
||||
|
||||
private val _currentPage = MutableStateFlow(0)
|
||||
val currentPage: StateFlow<Int> = _currentPage.asStateFlow()
|
||||
|
||||
private val _windowSize = MutableStateFlow(WINDOW_SIZE)
|
||||
val windowSize: StateFlow<Int> = _windowSize.asStateFlow()
|
||||
|
||||
private var lastKey: FingerprintKey? = null
|
||||
|
||||
suspend fun refresh(
|
||||
blocks: List<ReaderText>,
|
||||
constraints: ReaderConstraints,
|
||||
paragraphStyle: TextStyle,
|
||||
textMeasurer: TextMeasurer,
|
||||
imageMaxWidthPx: Int,
|
||||
chapterExtraHeightPx: Int,
|
||||
dispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||
) {
|
||||
if (blocks.isEmpty()) {
|
||||
_result.value = PaginationResult(emptyList(), 0)
|
||||
_currentPage.value = 0
|
||||
return
|
||||
}
|
||||
|
||||
val key = FingerprintKey(
|
||||
contentHash = blocks.hashCode(),
|
||||
styleFingerprint = paragraphStyle.hashCode(),
|
||||
constraintsFingerprint = constraints.hashCode(),
|
||||
)
|
||||
|
||||
if (key == lastKey) return
|
||||
|
||||
val cached = cache.get(key)
|
||||
if (cached != null) {
|
||||
_result.value = cached
|
||||
lastKey = key
|
||||
return
|
||||
}
|
||||
|
||||
val computed = withContext(dispatcher) {
|
||||
engine.paginate(
|
||||
blocks = blocks,
|
||||
constraints = constraints,
|
||||
paragraphStyle = paragraphStyle,
|
||||
textMeasurer = textMeasurer,
|
||||
imageMaxWidthPx = imageMaxWidthPx,
|
||||
chapterExtraHeightPx = chapterExtraHeightPx,
|
||||
)
|
||||
}
|
||||
|
||||
cache.put(key, computed)
|
||||
_result.value = computed
|
||||
lastKey = key
|
||||
}
|
||||
|
||||
fun selectPage(index: Int) {
|
||||
val pages = _result.value.pages
|
||||
if (pages.isEmpty()) {
|
||||
_currentPage.value = 0
|
||||
return
|
||||
}
|
||||
_currentPage.value = index.coerceIn(0, pages.lastIndex)
|
||||
}
|
||||
|
||||
fun restorePage(savedBlockIndex: Int): Int {
|
||||
val pages = _result.value.pages
|
||||
if (pages.isEmpty()) return 0
|
||||
val found = pages.indexOfFirst { it.firstBlockIndex <= savedBlockIndex && savedBlockIndex <= it.lastBlockIndex }
|
||||
return found.takeIf { it >= 0 } ?: 0
|
||||
}
|
||||
|
||||
fun visiblePages(): List<ReaderPage2> {
|
||||
val pages = _result.value.pages
|
||||
val current = _currentPage.value
|
||||
val window = _windowSize.value
|
||||
val from = (current - window).coerceAtLeast(0)
|
||||
val to = (current + window + 1).coerceAtMost(pages.size)
|
||||
return pages.subList(from, to)
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
_result.value = PaginationResult(emptyList(), 0)
|
||||
_currentPage.value = 0
|
||||
lastKey = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val WINDOW_SIZE = 3
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ package org.dueattendant149.bookshelf.presentation.reader
|
|||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import org.dueattendant149.bookshelf.domain.model.reader.ReaderText.Chapter
|
||||
import org.dueattendant149.bookshelf.domain.model.reader.pagination.ReaderPage2
|
||||
import org.dueattendant149.bookshelf.presentation.reader.model.Checkpoint
|
||||
|
||||
@Immutable
|
||||
|
|
@ -45,7 +46,7 @@ sealed class ReaderEvent {
|
|||
) : ReaderEvent()
|
||||
|
||||
data class OnPagesComputed(
|
||||
val pages: List<org.dueattendant149.bookshelf.domain.model.reader.ReaderPage>
|
||||
val pages: List<ReaderPage2>
|
||||
) : ReaderEvent()
|
||||
|
||||
data class OnRestoreCheckpoint(
|
||||
|
|
@ -90,4 +91,4 @@ sealed class ReaderEvent {
|
|||
data class OnNavigateToRsvp(
|
||||
val bookId: Int
|
||||
) : ReaderEvent()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,12 +32,13 @@ import org.dueattendant149.bookshelf.R
|
|||
import org.dueattendant149.bookshelf.core.helpers.coerceAndPreventNaN
|
||||
import org.dueattendant149.bookshelf.core.ui.UIText
|
||||
import org.dueattendant149.bookshelf.domain.model.reader.ReaderText.Chapter
|
||||
import org.dueattendant149.bookshelf.domain.model.reader.pagination.ReaderPage2
|
||||
import org.dueattendant149.bookshelf.domain.use_case.book.GetBookUseCase
|
||||
import org.dueattendant149.bookshelf.domain.use_case.book.GetChapterProgressUseCase
|
||||
import org.dueattendant149.bookshelf.domain.use_case.book.GetTextUseCase
|
||||
import org.dueattendant149.bookshelf.domain.use_case.book.UpdateBookUseCase
|
||||
import org.dueattendant149.bookshelf.domain.use_case.history.GetHistoryForBookUseCase
|
||||
import org.dueattendant149.bookshelf.domain.use_case.reader.PaginateReaderTextUseCase
|
||||
import org.dueattendant149.bookshelf.domain.use_case.reader.pagination.TextMeasurerPaginationEngine
|
||||
import org.dueattendant149.bookshelf.domain.use_case.remote.SyncReadingProgressUseCase
|
||||
import org.dueattendant149.bookshelf.presentation.history.HistoryScreen
|
||||
import org.dueattendant149.bookshelf.presentation.library.LibraryScreen
|
||||
|
|
@ -54,7 +55,7 @@ class ReaderModel @Inject constructor(
|
|||
private val getHistoryForBookUseCase: GetHistoryForBookUseCase,
|
||||
private val getChapterProgressUseCase: GetChapterProgressUseCase,
|
||||
private val syncReadingProgressUseCase: SyncReadingProgressUseCase,
|
||||
private val paginateReaderTextUseCase: PaginateReaderTextUseCase,
|
||||
val paginationEngine: TextMeasurerPaginationEngine,
|
||||
) : ViewModel() {
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
|
@ -118,7 +119,7 @@ class ReaderModel @Inject constructor(
|
|||
val state = _state.value
|
||||
if (state.pages.isNotEmpty()) {
|
||||
val page = state.pages
|
||||
.indexOfFirst { it.startTextIndex <= state.book.scrollIndex && state.book.scrollIndex < it.endTextIndex }
|
||||
.indexOfFirst { it.firstBlockIndex <= state.book.scrollIndex && state.book.scrollIndex <= it.lastBlockIndex }
|
||||
.takeIf { it >= 0 }
|
||||
?: 0
|
||||
onEvent(ReaderEvent.OnChangePage(page))
|
||||
|
|
@ -265,7 +266,7 @@ class ReaderModel @Inject constructor(
|
|||
val pages = state.pages
|
||||
if (pages.isEmpty()) return@withContext
|
||||
val page = event.page.coerceIn(0, pages.lastIndex)
|
||||
val pageStart = pages.getOrNull(page)?.startTextIndex ?: 0
|
||||
val pageStart = pages.getOrNull(page)?.firstBlockIndex ?: 0
|
||||
|
||||
val (currentChapter, currentChapterProgress) = getChapterProgressUseCase(
|
||||
pageStart,
|
||||
|
|
@ -300,10 +301,9 @@ class ReaderModel @Inject constructor(
|
|||
_state.update { it.copy(pages = emptyList(), currentPage = 0) }
|
||||
return@withContext
|
||||
}
|
||||
// Restore the page that contains the saved scrollIndex.
|
||||
val savedIndex = _state.value.book.scrollIndex
|
||||
val targetPage = pages
|
||||
.indexOfFirst { it.startTextIndex <= savedIndex && savedIndex < it.endTextIndex }
|
||||
.indexOfFirst { it.firstBlockIndex <= savedIndex && savedIndex <= it.lastBlockIndex }
|
||||
.takeIf { it >= 0 }
|
||||
?: 0
|
||||
_state.update { it.copy(pages = pages, currentPage = targetPage) }
|
||||
|
|
@ -590,4 +590,4 @@ class ReaderModel @Inject constructor(
|
|||
this.value = function(this.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import androidx.compose.runtime.DisposableEffect
|
|||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -56,6 +56,8 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.dueattendant149.bookshelf.core.helpers.calculateProgress
|
||||
import org.dueattendant149.bookshelf.domain.model.reader.pagination.ReaderConstraints
|
||||
import org.dueattendant149.bookshelf.domain.model.reader.pagination.ReaderPage2
|
||||
import org.dueattendant149.bookshelf.presentation.navigator.Screen
|
||||
import org.dueattendant149.bookshelf.presentation.reader.model.ReaderColorEffects
|
||||
import org.dueattendant149.bookshelf.presentation.reader.model.ReaderProgressCount
|
||||
|
|
@ -239,8 +241,10 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable {
|
|||
}
|
||||
|
||||
// Recompute pages when text or relevant settings change.
|
||||
val pages by produceState(
|
||||
initialValue = emptyList<org.dueattendant149.bookshelf.domain.model.reader.ReaderPage>(),
|
||||
val pagesState = remember { mutableStateOf(emptyList<ReaderPage2>()) }
|
||||
val pages = pagesState.value
|
||||
|
||||
LaunchedEffect(
|
||||
state.value.text,
|
||||
readerPagination,
|
||||
settings.fontSize.value,
|
||||
|
|
@ -261,64 +265,70 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable {
|
|||
configuration.screenWidthDp,
|
||||
configuration.screenHeightDp,
|
||||
) {
|
||||
value = withContext(Dispatchers.Default) {
|
||||
if (!readerPagination || state.value.text.isEmpty()) {
|
||||
emptyList()
|
||||
} else {
|
||||
val contentPaddingTopPx = with(density) {
|
||||
contentPadding.calculateTopPadding().toPx()
|
||||
}.toInt()
|
||||
val contentPaddingBottomPx = with(density) {
|
||||
contentPadding.calculateBottomPadding().toPx()
|
||||
}.toInt()
|
||||
val contentPaddingStartPx = with(density) {
|
||||
contentPadding.calculateStartPadding(layoutDirection).toPx()
|
||||
}.toInt()
|
||||
val contentPaddingEndPx = with(density) {
|
||||
contentPadding.calculateEndPadding(layoutDirection).toPx()
|
||||
}.toInt()
|
||||
if (!readerPagination || state.value.text.isEmpty()) {
|
||||
pagesState.value = emptyList()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
val sidePaddingPx = with(density) { sidePadding.toPx() }.toInt()
|
||||
val verticalPaddingPx = with(density) { verticalPadding.toPx() }.toInt()
|
||||
val paragraphHeightPx = with(density) { paragraphHeight.toPx() }.toInt()
|
||||
withContext(Dispatchers.Default) {
|
||||
val contentPaddingTopPx = with(density) {
|
||||
contentPadding.calculateTopPadding().toPx()
|
||||
}.toInt()
|
||||
val contentPaddingBottomPx = with(density) {
|
||||
contentPadding.calculateBottomPadding().toPx()
|
||||
}.toInt()
|
||||
val contentPaddingStartPx = with(density) {
|
||||
contentPadding.calculateStartPadding(layoutDirection).toPx()
|
||||
}.toInt()
|
||||
val contentPaddingEndPx = with(density) {
|
||||
contentPadding.calculateEndPadding(layoutDirection).toPx()
|
||||
}.toInt()
|
||||
|
||||
val contentWidthPx = with(density) {
|
||||
configuration.screenWidthDp.dp.toPx()
|
||||
}.toInt() - contentPaddingStartPx - contentPaddingEndPx - sidePaddingPx * 2
|
||||
val sidePaddingPx = with(density) { sidePadding.toPx() }.toInt()
|
||||
val verticalPaddingPx = with(density) { verticalPadding.toPx() }.toInt()
|
||||
val paragraphHeightPx = with(density) { paragraphHeight.toPx() }.toInt()
|
||||
|
||||
val progressBarHeightPx = with(density) {
|
||||
(progressBarFontSize.toPx() * 1.5f + progressBarPadding.toPx() * 2).toInt()
|
||||
}
|
||||
val contentWidthPx = with(density) {
|
||||
configuration.screenWidthDp.dp.toPx()
|
||||
}.toInt() - contentPaddingStartPx - contentPaddingEndPx - sidePaddingPx * 2
|
||||
|
||||
val contentHeightPx = with(density) {
|
||||
configuration.screenHeightDp.dp.toPx()
|
||||
}.toInt() - contentPaddingTopPx - contentPaddingBottomPx - verticalPaddingPx * 2 - paragraphHeightPx * 2 - progressBarHeightPx
|
||||
|
||||
val imageMaxWidthPx = (contentWidthPx * imagesWidth).toInt()
|
||||
|
||||
val style = readerParagraphTextStyle(
|
||||
fontFamily = settings.fontFamily.lastValue,
|
||||
fontThickness = settings.fontThickness.lastValue,
|
||||
fontStyle = if (settings.italic.lastValue) FontStyle.Italic else FontStyle.Normal,
|
||||
textAlignment = settings.textAlignment.lastValue,
|
||||
fontSize = settings.fontSize.lastValue.sp,
|
||||
lineHeight = (settings.fontSize.lastValue + settings.lineHeight.lastValue).sp,
|
||||
letterSpacing = (settings.letterSpacing.lastValue / 100f).em,
|
||||
paragraphIndentation = paragraphIndentation,
|
||||
)
|
||||
org.dueattendant149.bookshelf.domain.use_case.reader.PaginateReaderTextUseCase().invoke(
|
||||
text = state.value.text,
|
||||
contentWidthPx = contentWidthPx,
|
||||
contentHeightPx = contentHeightPx,
|
||||
paragraphStyle = style,
|
||||
chapterTitleAlignment = settings.chapterTitleAlignment.lastValue,
|
||||
paragraphSpacingPx = with(density) { paragraphHeight.toPx() }.toInt(),
|
||||
textMeasurer = textMeasurer,
|
||||
imageMaxWidthPx = imageMaxWidthPx,
|
||||
chapterExtraHeightPx = with(density) { 55.dp.toPx() }.toInt(),
|
||||
separatorHeightPx = with(density) { 3.dp.toPx() }.toInt(),
|
||||
)
|
||||
val progressBarHeightPx = with(density) {
|
||||
(progressBarFontSize.toPx() * 1.5f + progressBarPadding.toPx() * 2).toInt()
|
||||
}
|
||||
|
||||
val contentHeightPx = with(density) {
|
||||
configuration.screenHeightDp.dp.toPx()
|
||||
}.toInt() - contentPaddingTopPx - contentPaddingBottomPx - verticalPaddingPx * 2 - paragraphHeightPx * 2 - progressBarHeightPx
|
||||
|
||||
val imageMaxWidthPx = (contentWidthPx * imagesWidth).toInt()
|
||||
|
||||
val style = readerParagraphTextStyle(
|
||||
fontFamily = settings.fontFamily.lastValue,
|
||||
fontThickness = settings.fontThickness.lastValue,
|
||||
fontStyle = if (settings.italic.lastValue) FontStyle.Italic else FontStyle.Normal,
|
||||
textAlignment = settings.textAlignment.lastValue,
|
||||
fontSize = settings.fontSize.lastValue.sp,
|
||||
lineHeight = (settings.fontSize.lastValue + settings.lineHeight.lastValue).sp,
|
||||
letterSpacing = (settings.letterSpacing.lastValue / 100f).em,
|
||||
paragraphIndentation = paragraphIndentation,
|
||||
)
|
||||
|
||||
val constraints = ReaderConstraints(
|
||||
contentWidthPx = contentWidthPx,
|
||||
contentHeightPx = contentHeightPx,
|
||||
paragraphSpacingPx = paragraphHeightPx,
|
||||
separatorHeightPx = with(density) { 3.dp.toPx() }.toInt(),
|
||||
)
|
||||
|
||||
pagesState.value = screenModel.paginationEngine.paginate(
|
||||
blocks = state.value.text,
|
||||
constraints = constraints,
|
||||
paragraphStyle = style,
|
||||
textMeasurer = textMeasurer,
|
||||
imageMaxWidthPx = imageMaxWidthPx,
|
||||
chapterExtraHeightPx = with(density) { 55.dp.toPx() }.toInt(),
|
||||
chapterTitleAlignment = settings.chapterTitleAlignment.lastValue.textAlignment,
|
||||
).pages
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,16 +12,16 @@ import org.dueattendant149.bookshelf.core.BottomSheet
|
|||
import org.dueattendant149.bookshelf.core.Drawer
|
||||
import org.dueattendant149.bookshelf.core.ui.UIText
|
||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||
import org.dueattendant149.bookshelf.domain.model.reader.ReaderPage
|
||||
import org.dueattendant149.bookshelf.domain.model.reader.ReaderText
|
||||
import org.dueattendant149.bookshelf.domain.model.reader.ReaderText.Chapter
|
||||
import org.dueattendant149.bookshelf.domain.model.reader.pagination.ReaderPage2
|
||||
import org.dueattendant149.bookshelf.presentation.reader.model.Checkpoint
|
||||
|
||||
@Immutable
|
||||
data class ReaderState(
|
||||
val book: Book = Book.default,
|
||||
val text: List<ReaderText> = emptyList(),
|
||||
val pages: List<ReaderPage> = emptyList(),
|
||||
val pages: List<ReaderPage2> = emptyList(),
|
||||
val currentPage: Int = 0,
|
||||
val listState: LazyListState = LazyListState(),
|
||||
|
||||
|
|
@ -37,4 +37,4 @@ data class ReaderState(
|
|||
|
||||
val bottomSheet: BottomSheet? = null,
|
||||
val drawer: Drawer? = null
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import androidx.compose.ui.unit.dp
|
|||
import org.dueattendant149.bookshelf.R
|
||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||
import org.dueattendant149.bookshelf.domain.model.reader.ReaderText
|
||||
import org.dueattendant149.bookshelf.domain.model.reader.pagination.ReaderPage2
|
||||
import org.dueattendant149.bookshelf.presentation.reader.ReaderEvent
|
||||
import org.dueattendant149.bookshelf.presentation.reader.model.Checkpoint
|
||||
import org.dueattendant149.bookshelf.ui.common.components.common.IconButton
|
||||
|
|
@ -62,7 +63,7 @@ fun ReaderBottomBar(
|
|||
checkpoints: List<Checkpoint>,
|
||||
bottomBarPadding: Dp,
|
||||
readerPagination: Boolean,
|
||||
pages: List<org.dueattendant149.bookshelf.domain.model.reader.ReaderPage>,
|
||||
pages: List<ReaderPage2>,
|
||||
currentPage: Int,
|
||||
onChangePage: (Int) -> Unit,
|
||||
restoreCheckpoint: (ReaderEvent.OnRestoreCheckpoint) -> Unit,
|
||||
|
|
@ -250,7 +251,7 @@ private fun ReaderBottomBarSlider(
|
|||
lockMenu: Boolean,
|
||||
listState: LazyListState,
|
||||
readerPagination: Boolean,
|
||||
pages: List<org.dueattendant149.bookshelf.domain.model.reader.ReaderPage>,
|
||||
pages: List<ReaderPage2>,
|
||||
currentPage: Int,
|
||||
onChangePage: (Int) -> Unit,
|
||||
scroll: (ReaderEvent.OnScroll) -> Unit,
|
||||
|
|
@ -274,7 +275,7 @@ private fun ReaderBottomBarSlider(
|
|||
changeProgress(
|
||||
ReaderEvent.OnChangeProgress(
|
||||
progress = it,
|
||||
firstVisibleItemIndex = pages[page].startTextIndex,
|
||||
firstVisibleItemIndex = pages[page].firstBlockIndex,
|
||||
firstVisibleItemOffset = 0
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ fun ReaderContent(
|
|||
showMenu: Boolean,
|
||||
lockMenu: Boolean,
|
||||
readerPagination: Boolean,
|
||||
pages: List<org.dueattendant149.bookshelf.domain.model.reader.ReaderPage>,
|
||||
pages: List<org.dueattendant149.bookshelf.domain.model.reader.pagination.ReaderPage2>,
|
||||
currentPage: Int,
|
||||
onPageChanged: (Int) -> Unit,
|
||||
contentPadding: PaddingValues,
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ import org.dueattendant149.bookshelf.ui.theme.model.HorizontalAlignment
|
|||
@Composable
|
||||
fun ReaderLayout(
|
||||
text: List<ReaderText>,
|
||||
pages: List<org.dueattendant149.bookshelf.domain.model.reader.ReaderPage>,
|
||||
pages: List<org.dueattendant149.bookshelf.domain.model.reader.pagination.ReaderPage2>,
|
||||
currentPage: Int,
|
||||
readerPagination: Boolean,
|
||||
onPageChanged: (Int) -> Unit,
|
||||
|
|
@ -182,6 +182,7 @@ fun ReaderLayout(
|
|||
) {
|
||||
if (readerPagination && pages.isNotEmpty()) {
|
||||
ReaderPaginationLayout(
|
||||
text = text,
|
||||
pages = pages,
|
||||
currentPage = currentPage,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
|
|
|
|||
|
|
@ -29,8 +29,8 @@ import androidx.compose.ui.unit.Dp
|
|||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import org.dueattendant149.bookshelf.domain.model.reader.ReaderPage
|
||||
import org.dueattendant149.bookshelf.domain.model.reader.ReaderText
|
||||
import org.dueattendant149.bookshelf.domain.model.reader.pagination.ReaderPage2
|
||||
import org.dueattendant149.bookshelf.presentation.reader.ReaderEvent
|
||||
import org.dueattendant149.bookshelf.presentation.reader.model.ReaderFontThickness
|
||||
import org.dueattendant149.bookshelf.presentation.reader.model.ReaderTextAlignment
|
||||
|
|
@ -43,7 +43,8 @@ import org.dueattendant149.bookshelf.ui.theme.model.HorizontalAlignment
|
|||
|
||||
@Composable
|
||||
fun ReaderPaginationLayout(
|
||||
pages: List<ReaderPage>,
|
||||
text: List<ReaderText>,
|
||||
pages: List<ReaderPage2>,
|
||||
currentPage: Int,
|
||||
contentPadding: PaddingValues,
|
||||
verticalPadding: Dp,
|
||||
|
|
@ -155,13 +156,15 @@ fun ReaderPaginationLayout(
|
|||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
) { pageIndex ->
|
||||
ReaderPageContent(
|
||||
ReaderPageContent2(
|
||||
page = pages[pageIndex],
|
||||
blocks = text,
|
||||
verticalPadding = paragraphHeight,
|
||||
sidePadding = sidePadding,
|
||||
backgroundColor = backgroundColor,
|
||||
fontColor = fontColor,
|
||||
images = images,
|
||||
imagesCaptions = imagesCaptions,
|
||||
imagesCornersRoundness = imagesCornersRoundness,
|
||||
imagesAlignment = imagesAlignment,
|
||||
imagesWidth = imagesWidth,
|
||||
|
|
@ -201,13 +204,15 @@ fun ReaderPaginationLayout(
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun ReaderPageContent(
|
||||
page: ReaderPage,
|
||||
private fun ReaderPageContent2(
|
||||
page: ReaderPage2,
|
||||
blocks: List<ReaderText>,
|
||||
verticalPadding: Dp,
|
||||
sidePadding: Dp,
|
||||
backgroundColor: Color,
|
||||
fontColor: Color,
|
||||
images: Boolean,
|
||||
imagesCaptions: Boolean,
|
||||
imagesCornersRoundness: Dp,
|
||||
imagesAlignment: HorizontalAlignment,
|
||||
imagesWidth: Float,
|
||||
|
|
@ -237,15 +242,23 @@ private fun ReaderPageContent(
|
|||
verticalArrangement = Arrangement.spacedBy(verticalPadding, Alignment.Top),
|
||||
horizontalAlignment = horizontalAlignment
|
||||
) {
|
||||
page.items.forEachIndexed { index, entry ->
|
||||
val previousEntry = page.items.getOrNull(index - 1)
|
||||
page.spans.forEach { span ->
|
||||
val block = blocks.getOrNull(span.blockIndex) ?: return@forEach
|
||||
|
||||
val displayBlock = when {
|
||||
block is ReaderText.Text && !span.isAtomic ->
|
||||
ReaderText.Text(block.line.subSequence(span.startChar, span.endChar))
|
||||
else -> block
|
||||
}
|
||||
|
||||
val previousBlock = blocks.getOrNull(span.blockIndex - 1)
|
||||
when {
|
||||
!images && (entry is ReaderText.Image || previousEntry is ReaderText.Image) -> return@forEachIndexed
|
||||
!images && previousEntry is ReaderText.Image -> return@forEachIndexed
|
||||
!images && (displayBlock is ReaderText.Image || previousBlock is ReaderText.Image) -> return@forEach
|
||||
!imagesCaptions && previousBlock is ReaderText.Image -> return@forEach
|
||||
else -> {
|
||||
ReaderLayoutText(
|
||||
showMenu = showMenu,
|
||||
entry = entry,
|
||||
entry = displayBlock,
|
||||
imagesCornersRoundness = imagesCornersRoundness,
|
||||
imagesAlignment = imagesAlignment,
|
||||
imagesWidth = imagesWidth,
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ fun ReaderScaffold(
|
|||
showMenu: Boolean,
|
||||
lockMenu: Boolean,
|
||||
readerPagination: Boolean,
|
||||
pages: List<org.dueattendant149.bookshelf.domain.model.reader.ReaderPage>,
|
||||
pages: List<org.dueattendant149.bookshelf.domain.model.reader.pagination.ReaderPage2>,
|
||||
currentPage: Int,
|
||||
onPageChanged: (Int) -> Unit,
|
||||
contentPadding: PaddingValues,
|
||||
|
|
|
|||
|
|
@ -53,28 +53,3 @@ fun readerParagraphTextStyle(
|
|||
platformStyle = PlatformTextStyle(includeFontPadding = includeFontPadding),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a [TextStyle] for measuring chapter titles. It intentionally mirrors
|
||||
* the rendering style used by [ReaderLayoutTextChapter] as closely as possible
|
||||
* without accessing MaterialTheme at the call site.
|
||||
*/
|
||||
fun readerChapterTextStyle(
|
||||
nested: Boolean,
|
||||
textAlignment: ReaderTextAlignment,
|
||||
color: Color = Color.Unspecified,
|
||||
): TextStyle {
|
||||
// Mirror Material3 headlineMedium / headlineSmall sizing. These are the
|
||||
// default Material3 values; they may differ slightly from a customized
|
||||
// theme, but they are accurate enough for pagination.
|
||||
val fontSize = if (nested) 24.sp else 28.sp
|
||||
val lineHeight = if (nested) 32.sp else 36.sp
|
||||
return TextStyle(
|
||||
fontSize = fontSize,
|
||||
lineHeight = lineHeight,
|
||||
fontWeight = FontWeight.Normal,
|
||||
textAlign = textAlignment.textAlignment,
|
||||
color = color,
|
||||
platformStyle = PlatformTextStyle(includeFontPadding = false),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue