Compare commits

..

1 commit
main ... master

Author SHA1 Message Date
Atte149
e1276fcee1 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
2026-06-21 20:59:26 +03:00
23 changed files with 876 additions and 489 deletions

View file

@ -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()
}

View file

@ -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,
)

View file

@ -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,
)
}
}

View file

@ -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,
)

View file

@ -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
}
}

View file

@ -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
}

View file

@ -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
}
}

View file

@ -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()
}
}

View file

@ -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()
}
}

View file

@ -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()
}
}

View file

@ -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
}

View file

@ -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
}
}
}

View file

@ -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
}
}

View file

@ -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(

View file

@ -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) }

View file

@ -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
}
}

View file

@ -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(),

View file

@ -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
)
)

View file

@ -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,

View file

@ -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),

View file

@ -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,

View file

@ -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,

View file

@ -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),
)
}