Compare commits
No commits in common. "master" and "main" have entirely different histories.
23 changed files with 489 additions and 876 deletions
|
|
@ -1,42 +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.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()
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +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.model.reader.pagination
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
|
||||||
|
|
||||||
@Immutable
|
|
||||||
data class LineMetric(
|
|
||||||
val top: Float,
|
|
||||||
val bottom: Float,
|
|
||||||
val visibleEnd: Int,
|
|
||||||
)
|
|
||||||
|
|
@ -1,26 +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.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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +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.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,
|
|
||||||
)
|
|
||||||
|
|
@ -1,29 +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.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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +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.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
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,380 @@
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,150 +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.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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,189 +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.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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +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.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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +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.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
|
|
||||||
}
|
|
||||||
|
|
@ -1,90 +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.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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,122 +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.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,7 +8,6 @@ package org.dueattendant149.bookshelf.presentation.reader
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import org.dueattendant149.bookshelf.domain.model.reader.ReaderText.Chapter
|
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
|
import org.dueattendant149.bookshelf.presentation.reader.model.Checkpoint
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
|
|
@ -46,7 +45,7 @@ sealed class ReaderEvent {
|
||||||
) : ReaderEvent()
|
) : ReaderEvent()
|
||||||
|
|
||||||
data class OnPagesComputed(
|
data class OnPagesComputed(
|
||||||
val pages: List<ReaderPage2>
|
val pages: List<org.dueattendant149.bookshelf.domain.model.reader.ReaderPage>
|
||||||
) : ReaderEvent()
|
) : ReaderEvent()
|
||||||
|
|
||||||
data class OnRestoreCheckpoint(
|
data class OnRestoreCheckpoint(
|
||||||
|
|
@ -91,4 +90,4 @@ sealed class ReaderEvent {
|
||||||
data class OnNavigateToRsvp(
|
data class OnNavigateToRsvp(
|
||||||
val bookId: Int
|
val bookId: Int
|
||||||
) : ReaderEvent()
|
) : ReaderEvent()
|
||||||
}
|
}
|
||||||
|
|
@ -32,13 +32,12 @@ import org.dueattendant149.bookshelf.R
|
||||||
import org.dueattendant149.bookshelf.core.helpers.coerceAndPreventNaN
|
import org.dueattendant149.bookshelf.core.helpers.coerceAndPreventNaN
|
||||||
import org.dueattendant149.bookshelf.core.ui.UIText
|
import org.dueattendant149.bookshelf.core.ui.UIText
|
||||||
import org.dueattendant149.bookshelf.domain.model.reader.ReaderText.Chapter
|
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.GetBookUseCase
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.book.GetChapterProgressUseCase
|
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.GetTextUseCase
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.book.UpdateBookUseCase
|
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.history.GetHistoryForBookUseCase
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.reader.pagination.TextMeasurerPaginationEngine
|
import org.dueattendant149.bookshelf.domain.use_case.reader.PaginateReaderTextUseCase
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.remote.SyncReadingProgressUseCase
|
import org.dueattendant149.bookshelf.domain.use_case.remote.SyncReadingProgressUseCase
|
||||||
import org.dueattendant149.bookshelf.presentation.history.HistoryScreen
|
import org.dueattendant149.bookshelf.presentation.history.HistoryScreen
|
||||||
import org.dueattendant149.bookshelf.presentation.library.LibraryScreen
|
import org.dueattendant149.bookshelf.presentation.library.LibraryScreen
|
||||||
|
|
@ -55,7 +54,7 @@ class ReaderModel @Inject constructor(
|
||||||
private val getHistoryForBookUseCase: GetHistoryForBookUseCase,
|
private val getHistoryForBookUseCase: GetHistoryForBookUseCase,
|
||||||
private val getChapterProgressUseCase: GetChapterProgressUseCase,
|
private val getChapterProgressUseCase: GetChapterProgressUseCase,
|
||||||
private val syncReadingProgressUseCase: SyncReadingProgressUseCase,
|
private val syncReadingProgressUseCase: SyncReadingProgressUseCase,
|
||||||
val paginationEngine: TextMeasurerPaginationEngine,
|
private val paginateReaderTextUseCase: PaginateReaderTextUseCase,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
|
@ -119,7 +118,7 @@ class ReaderModel @Inject constructor(
|
||||||
val state = _state.value
|
val state = _state.value
|
||||||
if (state.pages.isNotEmpty()) {
|
if (state.pages.isNotEmpty()) {
|
||||||
val page = state.pages
|
val page = state.pages
|
||||||
.indexOfFirst { it.firstBlockIndex <= state.book.scrollIndex && state.book.scrollIndex <= it.lastBlockIndex }
|
.indexOfFirst { it.startTextIndex <= state.book.scrollIndex && state.book.scrollIndex < it.endTextIndex }
|
||||||
.takeIf { it >= 0 }
|
.takeIf { it >= 0 }
|
||||||
?: 0
|
?: 0
|
||||||
onEvent(ReaderEvent.OnChangePage(page))
|
onEvent(ReaderEvent.OnChangePage(page))
|
||||||
|
|
@ -266,7 +265,7 @@ class ReaderModel @Inject constructor(
|
||||||
val pages = state.pages
|
val pages = state.pages
|
||||||
if (pages.isEmpty()) return@withContext
|
if (pages.isEmpty()) return@withContext
|
||||||
val page = event.page.coerceIn(0, pages.lastIndex)
|
val page = event.page.coerceIn(0, pages.lastIndex)
|
||||||
val pageStart = pages.getOrNull(page)?.firstBlockIndex ?: 0
|
val pageStart = pages.getOrNull(page)?.startTextIndex ?: 0
|
||||||
|
|
||||||
val (currentChapter, currentChapterProgress) = getChapterProgressUseCase(
|
val (currentChapter, currentChapterProgress) = getChapterProgressUseCase(
|
||||||
pageStart,
|
pageStart,
|
||||||
|
|
@ -301,9 +300,10 @@ class ReaderModel @Inject constructor(
|
||||||
_state.update { it.copy(pages = emptyList(), currentPage = 0) }
|
_state.update { it.copy(pages = emptyList(), currentPage = 0) }
|
||||||
return@withContext
|
return@withContext
|
||||||
}
|
}
|
||||||
|
// Restore the page that contains the saved scrollIndex.
|
||||||
val savedIndex = _state.value.book.scrollIndex
|
val savedIndex = _state.value.book.scrollIndex
|
||||||
val targetPage = pages
|
val targetPage = pages
|
||||||
.indexOfFirst { it.firstBlockIndex <= savedIndex && savedIndex <= it.lastBlockIndex }
|
.indexOfFirst { it.startTextIndex <= savedIndex && savedIndex < it.endTextIndex }
|
||||||
.takeIf { it >= 0 }
|
.takeIf { it >= 0 }
|
||||||
?: 0
|
?: 0
|
||||||
_state.update { it.copy(pages = pages, currentPage = targetPage) }
|
_state.update { it.copy(pages = pages, currentPage = targetPage) }
|
||||||
|
|
@ -590,4 +590,4 @@ class ReaderModel @Inject constructor(
|
||||||
this.value = function(this.value)
|
this.value = function(this.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -25,7 +25,7 @@ import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
|
@ -56,8 +56,6 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import org.dueattendant149.bookshelf.core.helpers.calculateProgress
|
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.navigator.Screen
|
||||||
import org.dueattendant149.bookshelf.presentation.reader.model.ReaderColorEffects
|
import org.dueattendant149.bookshelf.presentation.reader.model.ReaderColorEffects
|
||||||
import org.dueattendant149.bookshelf.presentation.reader.model.ReaderProgressCount
|
import org.dueattendant149.bookshelf.presentation.reader.model.ReaderProgressCount
|
||||||
|
|
@ -241,10 +239,8 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recompute pages when text or relevant settings change.
|
// Recompute pages when text or relevant settings change.
|
||||||
val pagesState = remember { mutableStateOf(emptyList<ReaderPage2>()) }
|
val pages by produceState(
|
||||||
val pages = pagesState.value
|
initialValue = emptyList<org.dueattendant149.bookshelf.domain.model.reader.ReaderPage>(),
|
||||||
|
|
||||||
LaunchedEffect(
|
|
||||||
state.value.text,
|
state.value.text,
|
||||||
readerPagination,
|
readerPagination,
|
||||||
settings.fontSize.value,
|
settings.fontSize.value,
|
||||||
|
|
@ -265,70 +261,64 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable {
|
||||||
configuration.screenWidthDp,
|
configuration.screenWidthDp,
|
||||||
configuration.screenHeightDp,
|
configuration.screenHeightDp,
|
||||||
) {
|
) {
|
||||||
if (!readerPagination || state.value.text.isEmpty()) {
|
value = withContext(Dispatchers.Default) {
|
||||||
pagesState.value = emptyList()
|
if (!readerPagination || state.value.text.isEmpty()) {
|
||||||
return@LaunchedEffect
|
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()
|
||||||
|
|
||||||
withContext(Dispatchers.Default) {
|
val sidePaddingPx = with(density) { sidePadding.toPx() }.toInt()
|
||||||
val contentPaddingTopPx = with(density) {
|
val verticalPaddingPx = with(density) { verticalPadding.toPx() }.toInt()
|
||||||
contentPadding.calculateTopPadding().toPx()
|
val paragraphHeightPx = with(density) { paragraphHeight.toPx() }.toInt()
|
||||||
}.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 sidePaddingPx = with(density) { sidePadding.toPx() }.toInt()
|
val contentWidthPx = with(density) {
|
||||||
val verticalPaddingPx = with(density) { verticalPadding.toPx() }.toInt()
|
configuration.screenWidthDp.dp.toPx()
|
||||||
val paragraphHeightPx = with(density) { paragraphHeight.toPx() }.toInt()
|
}.toInt() - contentPaddingStartPx - contentPaddingEndPx - sidePaddingPx * 2
|
||||||
|
|
||||||
val contentWidthPx = with(density) {
|
val progressBarHeightPx = with(density) {
|
||||||
configuration.screenWidthDp.dp.toPx()
|
(progressBarFontSize.toPx() * 1.5f + progressBarPadding.toPx() * 2).toInt()
|
||||||
}.toInt() - contentPaddingStartPx - contentPaddingEndPx - sidePaddingPx * 2
|
}
|
||||||
|
|
||||||
val progressBarHeightPx = with(density) {
|
val contentHeightPx = with(density) {
|
||||||
(progressBarFontSize.toPx() * 1.5f + progressBarPadding.toPx() * 2).toInt()
|
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 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.Drawer
|
||||||
import org.dueattendant149.bookshelf.core.ui.UIText
|
import org.dueattendant149.bookshelf.core.ui.UIText
|
||||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
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
|
||||||
import org.dueattendant149.bookshelf.domain.model.reader.ReaderText.Chapter
|
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
|
import org.dueattendant149.bookshelf.presentation.reader.model.Checkpoint
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
data class ReaderState(
|
data class ReaderState(
|
||||||
val book: Book = Book.default,
|
val book: Book = Book.default,
|
||||||
val text: List<ReaderText> = emptyList(),
|
val text: List<ReaderText> = emptyList(),
|
||||||
val pages: List<ReaderPage2> = emptyList(),
|
val pages: List<ReaderPage> = emptyList(),
|
||||||
val currentPage: Int = 0,
|
val currentPage: Int = 0,
|
||||||
val listState: LazyListState = LazyListState(),
|
val listState: LazyListState = LazyListState(),
|
||||||
|
|
||||||
|
|
@ -37,4 +37,4 @@ data class ReaderState(
|
||||||
|
|
||||||
val bottomSheet: BottomSheet? = null,
|
val bottomSheet: BottomSheet? = null,
|
||||||
val drawer: Drawer? = null
|
val drawer: Drawer? = null
|
||||||
)
|
)
|
||||||
|
|
@ -42,7 +42,6 @@ import androidx.compose.ui.unit.dp
|
||||||
import org.dueattendant149.bookshelf.R
|
import org.dueattendant149.bookshelf.R
|
||||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||||
import org.dueattendant149.bookshelf.domain.model.reader.ReaderText
|
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.ReaderEvent
|
||||||
import org.dueattendant149.bookshelf.presentation.reader.model.Checkpoint
|
import org.dueattendant149.bookshelf.presentation.reader.model.Checkpoint
|
||||||
import org.dueattendant149.bookshelf.ui.common.components.common.IconButton
|
import org.dueattendant149.bookshelf.ui.common.components.common.IconButton
|
||||||
|
|
@ -63,7 +62,7 @@ fun ReaderBottomBar(
|
||||||
checkpoints: List<Checkpoint>,
|
checkpoints: List<Checkpoint>,
|
||||||
bottomBarPadding: Dp,
|
bottomBarPadding: Dp,
|
||||||
readerPagination: Boolean,
|
readerPagination: Boolean,
|
||||||
pages: List<ReaderPage2>,
|
pages: List<org.dueattendant149.bookshelf.domain.model.reader.ReaderPage>,
|
||||||
currentPage: Int,
|
currentPage: Int,
|
||||||
onChangePage: (Int) -> Unit,
|
onChangePage: (Int) -> Unit,
|
||||||
restoreCheckpoint: (ReaderEvent.OnRestoreCheckpoint) -> Unit,
|
restoreCheckpoint: (ReaderEvent.OnRestoreCheckpoint) -> Unit,
|
||||||
|
|
@ -251,7 +250,7 @@ private fun ReaderBottomBarSlider(
|
||||||
lockMenu: Boolean,
|
lockMenu: Boolean,
|
||||||
listState: LazyListState,
|
listState: LazyListState,
|
||||||
readerPagination: Boolean,
|
readerPagination: Boolean,
|
||||||
pages: List<ReaderPage2>,
|
pages: List<org.dueattendant149.bookshelf.domain.model.reader.ReaderPage>,
|
||||||
currentPage: Int,
|
currentPage: Int,
|
||||||
onChangePage: (Int) -> Unit,
|
onChangePage: (Int) -> Unit,
|
||||||
scroll: (ReaderEvent.OnScroll) -> Unit,
|
scroll: (ReaderEvent.OnScroll) -> Unit,
|
||||||
|
|
@ -275,7 +274,7 @@ private fun ReaderBottomBarSlider(
|
||||||
changeProgress(
|
changeProgress(
|
||||||
ReaderEvent.OnChangeProgress(
|
ReaderEvent.OnChangeProgress(
|
||||||
progress = it,
|
progress = it,
|
||||||
firstVisibleItemIndex = pages[page].firstBlockIndex,
|
firstVisibleItemIndex = pages[page].startTextIndex,
|
||||||
firstVisibleItemOffset = 0
|
firstVisibleItemOffset = 0
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ fun ReaderContent(
|
||||||
showMenu: Boolean,
|
showMenu: Boolean,
|
||||||
lockMenu: Boolean,
|
lockMenu: Boolean,
|
||||||
readerPagination: Boolean,
|
readerPagination: Boolean,
|
||||||
pages: List<org.dueattendant149.bookshelf.domain.model.reader.pagination.ReaderPage2>,
|
pages: List<org.dueattendant149.bookshelf.domain.model.reader.ReaderPage>,
|
||||||
currentPage: Int,
|
currentPage: Int,
|
||||||
onPageChanged: (Int) -> Unit,
|
onPageChanged: (Int) -> Unit,
|
||||||
contentPadding: PaddingValues,
|
contentPadding: PaddingValues,
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ import org.dueattendant149.bookshelf.ui.theme.model.HorizontalAlignment
|
||||||
@Composable
|
@Composable
|
||||||
fun ReaderLayout(
|
fun ReaderLayout(
|
||||||
text: List<ReaderText>,
|
text: List<ReaderText>,
|
||||||
pages: List<org.dueattendant149.bookshelf.domain.model.reader.pagination.ReaderPage2>,
|
pages: List<org.dueattendant149.bookshelf.domain.model.reader.ReaderPage>,
|
||||||
currentPage: Int,
|
currentPage: Int,
|
||||||
readerPagination: Boolean,
|
readerPagination: Boolean,
|
||||||
onPageChanged: (Int) -> Unit,
|
onPageChanged: (Int) -> Unit,
|
||||||
|
|
@ -182,7 +182,6 @@ fun ReaderLayout(
|
||||||
) {
|
) {
|
||||||
if (readerPagination && pages.isNotEmpty()) {
|
if (readerPagination && pages.isNotEmpty()) {
|
||||||
ReaderPaginationLayout(
|
ReaderPaginationLayout(
|
||||||
text = text,
|
|
||||||
pages = pages,
|
pages = pages,
|
||||||
currentPage = currentPage,
|
currentPage = currentPage,
|
||||||
contentPadding = PaddingValues(0.dp),
|
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.TextUnit
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import kotlinx.coroutines.launch
|
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.ReaderText
|
||||||
import org.dueattendant149.bookshelf.domain.model.reader.pagination.ReaderPage2
|
|
||||||
import org.dueattendant149.bookshelf.presentation.reader.ReaderEvent
|
import org.dueattendant149.bookshelf.presentation.reader.ReaderEvent
|
||||||
import org.dueattendant149.bookshelf.presentation.reader.model.ReaderFontThickness
|
import org.dueattendant149.bookshelf.presentation.reader.model.ReaderFontThickness
|
||||||
import org.dueattendant149.bookshelf.presentation.reader.model.ReaderTextAlignment
|
import org.dueattendant149.bookshelf.presentation.reader.model.ReaderTextAlignment
|
||||||
|
|
@ -43,8 +43,7 @@ import org.dueattendant149.bookshelf.ui.theme.model.HorizontalAlignment
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ReaderPaginationLayout(
|
fun ReaderPaginationLayout(
|
||||||
text: List<ReaderText>,
|
pages: List<ReaderPage>,
|
||||||
pages: List<ReaderPage2>,
|
|
||||||
currentPage: Int,
|
currentPage: Int,
|
||||||
contentPadding: PaddingValues,
|
contentPadding: PaddingValues,
|
||||||
verticalPadding: Dp,
|
verticalPadding: Dp,
|
||||||
|
|
@ -156,15 +155,13 @@ fun ReaderPaginationLayout(
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
) { pageIndex ->
|
) { pageIndex ->
|
||||||
ReaderPageContent2(
|
ReaderPageContent(
|
||||||
page = pages[pageIndex],
|
page = pages[pageIndex],
|
||||||
blocks = text,
|
|
||||||
verticalPadding = paragraphHeight,
|
verticalPadding = paragraphHeight,
|
||||||
sidePadding = sidePadding,
|
sidePadding = sidePadding,
|
||||||
backgroundColor = backgroundColor,
|
backgroundColor = backgroundColor,
|
||||||
fontColor = fontColor,
|
fontColor = fontColor,
|
||||||
images = images,
|
images = images,
|
||||||
imagesCaptions = imagesCaptions,
|
|
||||||
imagesCornersRoundness = imagesCornersRoundness,
|
imagesCornersRoundness = imagesCornersRoundness,
|
||||||
imagesAlignment = imagesAlignment,
|
imagesAlignment = imagesAlignment,
|
||||||
imagesWidth = imagesWidth,
|
imagesWidth = imagesWidth,
|
||||||
|
|
@ -204,15 +201,13 @@ fun ReaderPaginationLayout(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ReaderPageContent2(
|
private fun ReaderPageContent(
|
||||||
page: ReaderPage2,
|
page: ReaderPage,
|
||||||
blocks: List<ReaderText>,
|
|
||||||
verticalPadding: Dp,
|
verticalPadding: Dp,
|
||||||
sidePadding: Dp,
|
sidePadding: Dp,
|
||||||
backgroundColor: Color,
|
backgroundColor: Color,
|
||||||
fontColor: Color,
|
fontColor: Color,
|
||||||
images: Boolean,
|
images: Boolean,
|
||||||
imagesCaptions: Boolean,
|
|
||||||
imagesCornersRoundness: Dp,
|
imagesCornersRoundness: Dp,
|
||||||
imagesAlignment: HorizontalAlignment,
|
imagesAlignment: HorizontalAlignment,
|
||||||
imagesWidth: Float,
|
imagesWidth: Float,
|
||||||
|
|
@ -242,23 +237,15 @@ private fun ReaderPageContent2(
|
||||||
verticalArrangement = Arrangement.spacedBy(verticalPadding, Alignment.Top),
|
verticalArrangement = Arrangement.spacedBy(verticalPadding, Alignment.Top),
|
||||||
horizontalAlignment = horizontalAlignment
|
horizontalAlignment = horizontalAlignment
|
||||||
) {
|
) {
|
||||||
page.spans.forEach { span ->
|
page.items.forEachIndexed { index, entry ->
|
||||||
val block = blocks.getOrNull(span.blockIndex) ?: return@forEach
|
val previousEntry = page.items.getOrNull(index - 1)
|
||||||
|
|
||||||
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 {
|
when {
|
||||||
!images && (displayBlock is ReaderText.Image || previousBlock is ReaderText.Image) -> return@forEach
|
!images && (entry is ReaderText.Image || previousEntry is ReaderText.Image) -> return@forEachIndexed
|
||||||
!imagesCaptions && previousBlock is ReaderText.Image -> return@forEach
|
!images && previousEntry is ReaderText.Image -> return@forEachIndexed
|
||||||
else -> {
|
else -> {
|
||||||
ReaderLayoutText(
|
ReaderLayoutText(
|
||||||
showMenu = showMenu,
|
showMenu = showMenu,
|
||||||
entry = displayBlock,
|
entry = entry,
|
||||||
imagesCornersRoundness = imagesCornersRoundness,
|
imagesCornersRoundness = imagesCornersRoundness,
|
||||||
imagesAlignment = imagesAlignment,
|
imagesAlignment = imagesAlignment,
|
||||||
imagesWidth = imagesWidth,
|
imagesWidth = imagesWidth,
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ fun ReaderScaffold(
|
||||||
showMenu: Boolean,
|
showMenu: Boolean,
|
||||||
lockMenu: Boolean,
|
lockMenu: Boolean,
|
||||||
readerPagination: Boolean,
|
readerPagination: Boolean,
|
||||||
pages: List<org.dueattendant149.bookshelf.domain.model.reader.pagination.ReaderPage2>,
|
pages: List<org.dueattendant149.bookshelf.domain.model.reader.ReaderPage>,
|
||||||
currentPage: Int,
|
currentPage: Int,
|
||||||
onPageChanged: (Int) -> Unit,
|
onPageChanged: (Int) -> Unit,
|
||||||
contentPadding: PaddingValues,
|
contentPadding: PaddingValues,
|
||||||
|
|
|
||||||
|
|
@ -53,3 +53,28 @@ fun readerParagraphTextStyle(
|
||||||
platformStyle = PlatformTextStyle(includeFontPadding = includeFontPadding),
|
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