From e1276fcee1955a228021aa05025d8b788e0ed890 Mon Sep 17 00:00:00 2001 From: Atte149 Date: Sun, 21 Jun 2026 20:59:26 +0300 Subject: [PATCH] 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 --- .../model/reader/pagination/BlockMetrics.kt | 42 ++ .../model/reader/pagination/LineMetric.kt | 16 + .../model/reader/pagination/PageSpan.kt | 26 ++ .../reader/pagination/PaginationResult.kt | 22 + .../reader/pagination/ReaderConstraints.kt | 29 ++ .../model/reader/pagination/ReaderPage2.kt | 18 + .../reader/PaginateReaderTextUseCase.kt | 380 ------------------ .../reader/pagination/BlockMetricsFactory.kt | 150 +++++++ .../use_case/reader/pagination/PagePacker.kt | 189 +++++++++ .../reader/pagination/PaginationCache.kt | 36 ++ .../reader/pagination/PaginationEngine.kt | 26 ++ .../TextMeasurerPaginationEngine.kt | 90 +++++ .../pagination/WindowedPaginationState.kt | 122 ++++++ .../presentation/reader/ReaderEvent.kt | 5 +- .../presentation/reader/ReaderModel.kt | 14 +- .../presentation/reader/ReaderScreen.kt | 122 +++--- .../presentation/reader/ReaderState.kt | 6 +- .../bookshelf/ui/reader/ReaderBottomBar.kt | 7 +- .../bookshelf/ui/reader/ReaderContent.kt | 2 +- .../bookshelf/ui/reader/ReaderLayout.kt | 3 +- .../ui/reader/ReaderPaginationLayout.kt | 33 +- .../bookshelf/ui/reader/ReaderScaffold.kt | 2 +- .../bookshelf/ui/reader/ReaderTextStyle.kt | 25 -- 23 files changed, 876 insertions(+), 489 deletions(-) create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/domain/model/reader/pagination/BlockMetrics.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/domain/model/reader/pagination/LineMetric.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/domain/model/reader/pagination/PageSpan.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/domain/model/reader/pagination/PaginationResult.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/domain/model/reader/pagination/ReaderConstraints.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/domain/model/reader/pagination/ReaderPage2.kt delete mode 100644 app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/reader/PaginateReaderTextUseCase.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/reader/pagination/BlockMetricsFactory.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/reader/pagination/PagePacker.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/reader/pagination/PaginationCache.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/reader/pagination/PaginationEngine.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/reader/pagination/TextMeasurerPaginationEngine.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/reader/pagination/WindowedPaginationState.kt diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/reader/pagination/BlockMetrics.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/model/reader/pagination/BlockMetrics.kt new file mode 100644 index 00000000..bf2f3ac6 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/model/reader/pagination/BlockMetrics.kt @@ -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, + 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() +} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/reader/pagination/LineMetric.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/model/reader/pagination/LineMetric.kt new file mode 100644 index 00000000..006c5680 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/model/reader/pagination/LineMetric.kt @@ -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, +) diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/reader/pagination/PageSpan.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/model/reader/pagination/PageSpan.kt new file mode 100644 index 00000000..a05f1026 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/model/reader/pagination/PageSpan.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/reader/pagination/PaginationResult.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/model/reader/pagination/PaginationResult.kt new file mode 100644 index 00000000..36f70cb3 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/model/reader/pagination/PaginationResult.kt @@ -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, + val totalBlocks: Int, +) + +@Immutable +data class FingerprintKey( + val contentHash: Int, + val styleFingerprint: Int, + val constraintsFingerprint: Int, +) diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/reader/pagination/ReaderConstraints.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/model/reader/pagination/ReaderConstraints.kt new file mode 100644 index 00000000..bd2becd0 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/model/reader/pagination/ReaderConstraints.kt @@ -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 + } +} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/reader/pagination/ReaderPage2.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/model/reader/pagination/ReaderPage2.kt new file mode 100644 index 00000000..5cb5dd81 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/model/reader/pagination/ReaderPage2.kt @@ -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, +) { + val firstBlockIndex: Int get() = spans.firstOrNull()?.blockIndex ?: 0 + val lastBlockIndex: Int get() = spans.lastOrNull()?.blockIndex ?: 0 +} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/reader/PaginateReaderTextUseCase.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/reader/PaginateReaderTextUseCase.kt deleted file mode 100644 index fdd2f6d8..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/reader/PaginateReaderTextUseCase.kt +++ /dev/null @@ -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, - 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 { - 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() - val pageItems = mutableListOf() - 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, - metrics: List, - 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? { - 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 = 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 - } -} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/reader/pagination/BlockMetricsFactory.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/reader/pagination/BlockMetricsFactory.kt new file mode 100644 index 00000000..8f67ca37 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/reader/pagination/BlockMetricsFactory.kt @@ -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, + contentWidthPx: Int, + imageMaxWidthPx: Int, + paragraphStyle: TextStyle, + textMeasurer: TextMeasurer, + chapterExtraHeightPx: Int, + separatorHeightPx: Int, + defaultImageHeightPx: Int, + chapterTitleAlignment: TextAlign = TextAlign.Start, + ): List { + 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() + } +} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/reader/pagination/PagePacker.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/reader/pagination/PagePacker.kt new file mode 100644 index 00000000..48c64033 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/reader/pagination/PagePacker.kt @@ -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, + metrics: List, + 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() + val currentSpans = mutableListOf() + 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? { + 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, + 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() + } +} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/reader/pagination/PaginationCache.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/reader/pagination/PaginationCache.kt new file mode 100644 index 00000000..5228d829 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/reader/pagination/PaginationCache.kt @@ -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() { + override fun removeEldestEntry(eldest: Map.Entry): 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() + } +} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/reader/pagination/PaginationEngine.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/reader/pagination/PaginationEngine.kt new file mode 100644 index 00000000..e8ea6892 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/reader/pagination/PaginationEngine.kt @@ -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, + constraints: ReaderConstraints, + paragraphStyle: TextStyle, + textMeasurer: TextMeasurer, + imageMaxWidthPx: Int, + chapterExtraHeightPx: Int, + chapterTitleAlignment: TextAlign = TextAlign.Start, + ): PaginationResult +} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/reader/pagination/TextMeasurerPaginationEngine.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/reader/pagination/TextMeasurerPaginationEngine.kt new file mode 100644 index 00000000..7af4d505 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/reader/pagination/TextMeasurerPaginationEngine.kt @@ -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, + 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 + } + } +} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/reader/pagination/WindowedPaginationState.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/reader/pagination/WindowedPaginationState.kt new file mode 100644 index 00000000..2b71f40f --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/reader/pagination/WindowedPaginationState.kt @@ -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 = _result.asStateFlow() + + private val _currentPage = MutableStateFlow(0) + val currentPage: StateFlow = _currentPage.asStateFlow() + + private val _windowSize = MutableStateFlow(WINDOW_SIZE) + val windowSize: StateFlow = _windowSize.asStateFlow() + + private var lastKey: FingerprintKey? = null + + suspend fun refresh( + blocks: List, + 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 { + 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 + } +} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderEvent.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderEvent.kt index 6bb8b711..ca4defb2 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderEvent.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderEvent.kt @@ -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 + val pages: List ) : ReaderEvent() data class OnRestoreCheckpoint( @@ -90,4 +91,4 @@ sealed class ReaderEvent { data class OnNavigateToRsvp( val bookId: Int ) : ReaderEvent() -} \ No newline at end of file +} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderModel.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderModel.kt index e6436ea5..20cfa24e 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderModel.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderModel.kt @@ -32,12 +32,13 @@ import org.dueattendant149.bookshelf.R import org.dueattendant149.bookshelf.core.helpers.coerceAndPreventNaN import org.dueattendant149.bookshelf.core.ui.UIText import org.dueattendant149.bookshelf.domain.model.reader.ReaderText.Chapter +import org.dueattendant149.bookshelf.domain.model.reader.pagination.ReaderPage2 import org.dueattendant149.bookshelf.domain.use_case.book.GetBookUseCase import org.dueattendant149.bookshelf.domain.use_case.book.GetChapterProgressUseCase import org.dueattendant149.bookshelf.domain.use_case.book.GetTextUseCase import org.dueattendant149.bookshelf.domain.use_case.book.UpdateBookUseCase import org.dueattendant149.bookshelf.domain.use_case.history.GetHistoryForBookUseCase -import org.dueattendant149.bookshelf.domain.use_case.reader.PaginateReaderTextUseCase +import org.dueattendant149.bookshelf.domain.use_case.reader.pagination.TextMeasurerPaginationEngine import org.dueattendant149.bookshelf.domain.use_case.remote.SyncReadingProgressUseCase import org.dueattendant149.bookshelf.presentation.history.HistoryScreen import org.dueattendant149.bookshelf.presentation.library.LibraryScreen @@ -54,7 +55,7 @@ class ReaderModel @Inject constructor( private val getHistoryForBookUseCase: GetHistoryForBookUseCase, private val getChapterProgressUseCase: GetChapterProgressUseCase, private val syncReadingProgressUseCase: SyncReadingProgressUseCase, - private val paginateReaderTextUseCase: PaginateReaderTextUseCase, + val paginationEngine: TextMeasurerPaginationEngine, ) : ViewModel() { private val mutex = Mutex() @@ -118,7 +119,7 @@ class ReaderModel @Inject constructor( val state = _state.value if (state.pages.isNotEmpty()) { val page = state.pages - .indexOfFirst { it.startTextIndex <= state.book.scrollIndex && state.book.scrollIndex < it.endTextIndex } + .indexOfFirst { it.firstBlockIndex <= state.book.scrollIndex && state.book.scrollIndex <= it.lastBlockIndex } .takeIf { it >= 0 } ?: 0 onEvent(ReaderEvent.OnChangePage(page)) @@ -265,7 +266,7 @@ class ReaderModel @Inject constructor( val pages = state.pages if (pages.isEmpty()) return@withContext val page = event.page.coerceIn(0, pages.lastIndex) - val pageStart = pages.getOrNull(page)?.startTextIndex ?: 0 + val pageStart = pages.getOrNull(page)?.firstBlockIndex ?: 0 val (currentChapter, currentChapterProgress) = getChapterProgressUseCase( pageStart, @@ -300,10 +301,9 @@ class ReaderModel @Inject constructor( _state.update { it.copy(pages = emptyList(), currentPage = 0) } return@withContext } - // Restore the page that contains the saved scrollIndex. val savedIndex = _state.value.book.scrollIndex val targetPage = pages - .indexOfFirst { it.startTextIndex <= savedIndex && savedIndex < it.endTextIndex } + .indexOfFirst { it.firstBlockIndex <= savedIndex && savedIndex <= it.lastBlockIndex } .takeIf { it >= 0 } ?: 0 _state.update { it.copy(pages = pages, currentPage = targetPage) } @@ -590,4 +590,4 @@ class ReaderModel @Inject constructor( this.value = function(this.value) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderScreen.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderScreen.kt index b142d758..866ee5bb 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderScreen.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderScreen.kt @@ -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(), + val pagesState = remember { mutableStateOf(emptyList()) } + 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 } } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderState.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderState.kt index 60aa8023..a3524bf9 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderState.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderState.kt @@ -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 = emptyList(), - val pages: List = emptyList(), + val pages: List = emptyList(), val currentPage: Int = 0, val listState: LazyListState = LazyListState(), @@ -37,4 +37,4 @@ data class ReaderState( val bottomSheet: BottomSheet? = null, val drawer: Drawer? = null -) \ No newline at end of file +) diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderBottomBar.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderBottomBar.kt index d43a0d6f..65395941 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderBottomBar.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderBottomBar.kt @@ -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, bottomBarPadding: Dp, readerPagination: Boolean, - pages: List, + pages: List, currentPage: Int, onChangePage: (Int) -> Unit, restoreCheckpoint: (ReaderEvent.OnRestoreCheckpoint) -> Unit, @@ -250,7 +251,7 @@ private fun ReaderBottomBarSlider( lockMenu: Boolean, listState: LazyListState, readerPagination: Boolean, - pages: List, + pages: List, 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 ) ) diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderContent.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderContent.kt index f05326e4..415fca37 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderContent.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderContent.kt @@ -59,7 +59,7 @@ fun ReaderContent( showMenu: Boolean, lockMenu: Boolean, readerPagination: Boolean, - pages: List, + pages: List, currentPage: Int, onPageChanged: (Int) -> Unit, contentPadding: PaddingValues, diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayout.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayout.kt index c13c374d..98cac815 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayout.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayout.kt @@ -56,7 +56,7 @@ import org.dueattendant149.bookshelf.ui.theme.model.HorizontalAlignment @Composable fun ReaderLayout( text: List, - pages: List, + pages: List, 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), diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderPaginationLayout.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderPaginationLayout.kt index 2fa65363..055422aa 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderPaginationLayout.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderPaginationLayout.kt @@ -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, + text: List, + pages: List, 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, 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, diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderScaffold.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderScaffold.kt index c5bd2c03..c407f044 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderScaffold.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderScaffold.kt @@ -63,7 +63,7 @@ fun ReaderScaffold( showMenu: Boolean, lockMenu: Boolean, readerPagination: Boolean, - pages: List, + pages: List, currentPage: Int, onPageChanged: (Int) -> Unit, contentPadding: PaddingValues, diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderTextStyle.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderTextStyle.kt index 7d0ce7ac..56575e1f 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderTextStyle.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderTextStyle.kt @@ -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), - ) -}