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 deleted file mode 100644 index bf2f3ac6..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/reader/pagination/BlockMetrics.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Book's Story — free and open-source Material You eBook reader. - * Copyright (C) 2024-2026 Acclorite - * SPDX-License-Identifier: GPL-3.0-only - */ - -package org.dueattendant149.bookshelf.domain.model.reader.pagination - -import androidx.compose.runtime.Immutable -import androidx.compose.ui.text.TextLayoutResult - -@Immutable -sealed class BlockMetrics { - abstract val blockIndex: Int - abstract val height: Int - - @Immutable - data class TextMetrics( - override val blockIndex: Int, - override val height: Int, - val lineMetrics: List, - 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 deleted file mode 100644 index 006c5680..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/reader/pagination/LineMetric.kt +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Book's Story — free and open-source Material You eBook reader. - * Copyright (C) 2024-2026 Acclorite - * SPDX-License-Identifier: GPL-3.0-only - */ - -package org.dueattendant149.bookshelf.domain.model.reader.pagination - -import androidx.compose.runtime.Immutable - -@Immutable -data class LineMetric( - val top: Float, - val bottom: Float, - val visibleEnd: Int, -) 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 deleted file mode 100644 index a05f1026..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/reader/pagination/PageSpan.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Book's Story — free and open-source Material You eBook reader. - * Copyright (C) 2024-2026 Acclorite - * SPDX-License-Identifier: GPL-3.0-only - */ - -package org.dueattendant149.bookshelf.domain.model.reader.pagination - -import androidx.compose.runtime.Immutable - -@Immutable -data class PageSpan( - val blockIndex: Int, - val startChar: Int, - val endChar: Int, -) { - val isAtomic: Boolean get() = startChar == 0 && endChar == 0 - - companion object { - fun atomic(blockIndex: Int) = PageSpan( - blockIndex = blockIndex, - startChar = 0, - endChar = 0, - ) - } -} 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 deleted file mode 100644 index 36f70cb3..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/reader/pagination/PaginationResult.kt +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Book's Story — free and open-source Material You eBook reader. - * Copyright (C) 2024-2026 Acclorite - * SPDX-License-Identifier: GPL-3.0-only - */ - -package org.dueattendant149.bookshelf.domain.model.reader.pagination - -import androidx.compose.runtime.Immutable - -@Immutable -data class PaginationResult( - val pages: List, - 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 deleted file mode 100644 index bd2becd0..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/reader/pagination/ReaderConstraints.kt +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Book's Story — free and open-source Material You eBook reader. - * Copyright (C) 2024-2026 Acclorite - * SPDX-License-Identifier: GPL-3.0-only - */ - -package org.dueattendant149.bookshelf.domain.model.reader.pagination - -import androidx.compose.runtime.Immutable - -@Immutable -data class ReaderConstraints( - val contentWidthPx: Int, - val contentHeightPx: Int, - val paragraphSpacingPx: Int, - val safetyMarginPx: Int = 4, - val minWidowLines: Int = 2, - val minOrphanLines: Int = 2, - val chapterKeepLines: Int = 2, - val separatorHeightPx: Int = DEFAULT_SEPARATOR_HEIGHT_PX, - val defaultImageHeightPx: Int = DEFAULT_IMAGE_HEIGHT_PX, -) { - val availableHeight: Int get() = (contentHeightPx - safetyMarginPx).coerceAtLeast(1) - - companion object { - private const val DEFAULT_SEPARATOR_HEIGHT_PX = 12 - private const val DEFAULT_IMAGE_HEIGHT_PX = 720 - } -} 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 deleted file mode 100644 index 5cb5dd81..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/reader/pagination/ReaderPage2.kt +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Book's Story — free and open-source Material You eBook reader. - * Copyright (C) 2024-2026 Acclorite - * SPDX-License-Identifier: GPL-3.0-only - */ - -package org.dueattendant149.bookshelf.domain.model.reader.pagination - -import androidx.compose.runtime.Immutable - -@Immutable -data class ReaderPage2( - val index: Int, - val spans: List, -) { - 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 new file mode 100644 index 00000000..fdd2f6d8 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/reader/PaginateReaderTextUseCase.kt @@ -0,0 +1,380 @@ +/* + * Book's Story — free and open-source Material You eBook reader. + * Copyright (C) 2024-2026 Acclorite + * SPDX-License-Identifier: GPL-3.0-only + */ + +package org.dueattendant149.bookshelf.domain.use_case.reader + +import android.util.Log +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Constraints +import org.dueattendant149.bookshelf.domain.model.reader.ReaderPage +import org.dueattendant149.bookshelf.domain.model.reader.ReaderText +import org.dueattendant149.bookshelf.presentation.reader.model.ReaderTextAlignment +import org.dueattendant149.bookshelf.ui.reader.readerChapterTextStyle +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class PaginateReaderTextUseCase @Inject constructor() { + + operator fun invoke( + text: List, + 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 deleted file mode 100644 index 8f67ca37..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/reader/pagination/BlockMetricsFactory.kt +++ /dev/null @@ -1,150 +0,0 @@ -/* - * Book's Story — free and open-source Material You eBook reader. - * Copyright (C) 2024-2026 Acclorite - * SPDX-License-Identifier: GPL-3.0-only - */ - -package org.dueattendant149.bookshelf.domain.use_case.reader.pagination - -import android.util.Log -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.PlatformTextStyle -import androidx.compose.ui.text.TextLayoutResult -import androidx.compose.ui.text.TextMeasurer -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextIndent -import androidx.compose.ui.unit.Constraints -import androidx.compose.ui.unit.sp -import org.dueattendant149.bookshelf.domain.model.reader.ReaderText -import org.dueattendant149.bookshelf.domain.model.reader.pagination.BlockMetrics -import org.dueattendant149.bookshelf.domain.model.reader.pagination.LineMetric - -object BlockMetricsFactory { - - fun measureAll( - blocks: List, - 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 deleted file mode 100644 index 48c64033..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/reader/pagination/PagePacker.kt +++ /dev/null @@ -1,189 +0,0 @@ -/* - * Book's Story — free and open-source Material You eBook reader. - * Copyright (C) 2024-2026 Acclorite - * SPDX-License-Identifier: GPL-3.0-only - */ - -package org.dueattendant149.bookshelf.domain.use_case.reader.pagination - -import android.util.Log -import org.dueattendant149.bookshelf.domain.model.reader.ReaderText -import org.dueattendant149.bookshelf.domain.model.reader.pagination.BlockMetrics -import org.dueattendant149.bookshelf.domain.model.reader.pagination.PageSpan -import org.dueattendant149.bookshelf.domain.model.reader.pagination.PaginationResult -import org.dueattendant149.bookshelf.domain.model.reader.pagination.ReaderConstraints -import org.dueattendant149.bookshelf.domain.model.reader.pagination.ReaderPage2 - -object PagePacker { - - fun pack( - blocks: List, - 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 deleted file mode 100644 index 5228d829..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/reader/pagination/PaginationCache.kt +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Book's Story — free and open-source Material You eBook reader. - * Copyright (C) 2024-2026 Acclorite - * SPDX-License-Identifier: GPL-3.0-only - */ - -package org.dueattendant149.bookshelf.domain.use_case.reader.pagination - -import org.dueattendant149.bookshelf.domain.model.reader.pagination.FingerprintKey -import org.dueattendant149.bookshelf.domain.model.reader.pagination.PaginationResult -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class PaginationCache @Inject constructor( - private val maxSize: Int = 16, -) { - private val cache = object : LinkedHashMap() { - 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 deleted file mode 100644 index e8ea6892..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/reader/pagination/PaginationEngine.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Book's Story — free and open-source Material You eBook reader. - * Copyright (C) 2024-2026 Acclorite - * SPDX-License-Identifier: GPL-3.0-only - */ - -package org.dueattendant149.bookshelf.domain.use_case.reader.pagination - -import androidx.compose.ui.text.TextMeasurer -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.style.TextAlign -import org.dueattendant149.bookshelf.domain.model.reader.ReaderText -import org.dueattendant149.bookshelf.domain.model.reader.pagination.PaginationResult -import org.dueattendant149.bookshelf.domain.model.reader.pagination.ReaderConstraints - -interface PaginationEngine { - fun paginate( - blocks: List, - 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 deleted file mode 100644 index 7af4d505..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/reader/pagination/TextMeasurerPaginationEngine.kt +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Book's Story — free and open-source Material You eBook reader. - * Copyright (C) 2024-2026 Acclorite - * SPDX-License-Identifier: GPL-3.0-only - */ - -package org.dueattendant149.bookshelf.domain.use_case.reader.pagination - -import androidx.compose.ui.text.TextMeasurer -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.style.TextAlign -import org.dueattendant149.bookshelf.domain.model.reader.ReaderText -import org.dueattendant149.bookshelf.domain.model.reader.pagination.BlockMetrics -import org.dueattendant149.bookshelf.domain.model.reader.pagination.PaginationResult -import org.dueattendant149.bookshelf.domain.model.reader.pagination.ReaderConstraints -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class TextMeasurerPaginationEngine @Inject constructor() : PaginationEngine { - - override fun paginate( - blocks: List, - 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 deleted file mode 100644 index 2b71f40f..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/reader/pagination/WindowedPaginationState.kt +++ /dev/null @@ -1,122 +0,0 @@ -/* - * Book's Story — free and open-source Material You eBook reader. - * Copyright (C) 2024-2026 Acclorite - * SPDX-License-Identifier: GPL-3.0-only - */ - -package org.dueattendant149.bookshelf.domain.use_case.reader.pagination - -import androidx.compose.ui.text.TextMeasurer -import androidx.compose.ui.text.TextStyle -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.withContext -import org.dueattendant149.bookshelf.domain.model.reader.ReaderText -import org.dueattendant149.bookshelf.domain.model.reader.pagination.FingerprintKey -import org.dueattendant149.bookshelf.domain.model.reader.pagination.PaginationResult -import org.dueattendant149.bookshelf.domain.model.reader.pagination.ReaderConstraints -import org.dueattendant149.bookshelf.domain.model.reader.pagination.ReaderPage2 -import javax.inject.Inject -import javax.inject.Singleton -import kotlin.math.abs - -@Singleton -class WindowedPaginationState @Inject constructor( - private val engine: TextMeasurerPaginationEngine, - private val cache: PaginationCache, -) { - private val _result = MutableStateFlow(PaginationResult(emptyList(), 0)) - val result: StateFlow = _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 ca4defb2..6bb8b711 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,7 +8,6 @@ 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 @@ -46,7 +45,7 @@ sealed class ReaderEvent { ) : ReaderEvent() data class OnPagesComputed( - val pages: List + val pages: List ) : ReaderEvent() data class OnRestoreCheckpoint( @@ -91,4 +90,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 20cfa24e..e6436ea5 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,13 +32,12 @@ 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.pagination.TextMeasurerPaginationEngine +import org.dueattendant149.bookshelf.domain.use_case.reader.PaginateReaderTextUseCase import org.dueattendant149.bookshelf.domain.use_case.remote.SyncReadingProgressUseCase import org.dueattendant149.bookshelf.presentation.history.HistoryScreen import org.dueattendant149.bookshelf.presentation.library.LibraryScreen @@ -55,7 +54,7 @@ class ReaderModel @Inject constructor( private val getHistoryForBookUseCase: GetHistoryForBookUseCase, private val getChapterProgressUseCase: GetChapterProgressUseCase, private val syncReadingProgressUseCase: SyncReadingProgressUseCase, - val paginationEngine: TextMeasurerPaginationEngine, + private val paginateReaderTextUseCase: PaginateReaderTextUseCase, ) : ViewModel() { private val mutex = Mutex() @@ -119,7 +118,7 @@ class ReaderModel @Inject constructor( val state = _state.value if (state.pages.isNotEmpty()) { val page = state.pages - .indexOfFirst { it.firstBlockIndex <= state.book.scrollIndex && state.book.scrollIndex <= it.lastBlockIndex } + .indexOfFirst { it.startTextIndex <= state.book.scrollIndex && state.book.scrollIndex < it.endTextIndex } .takeIf { it >= 0 } ?: 0 onEvent(ReaderEvent.OnChangePage(page)) @@ -266,7 +265,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)?.firstBlockIndex ?: 0 + val pageStart = pages.getOrNull(page)?.startTextIndex ?: 0 val (currentChapter, currentChapterProgress) = getChapterProgressUseCase( pageStart, @@ -301,9 +300,10 @@ 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.firstBlockIndex <= savedIndex && savedIndex <= it.lastBlockIndex } + .indexOfFirst { it.startTextIndex <= savedIndex && savedIndex < it.endTextIndex } .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 866ee5bb..b142d758 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.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import kotlinx.coroutines.Dispatchers @@ -56,8 +56,6 @@ 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 @@ -241,10 +239,8 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable { } // Recompute pages when text or relevant settings change. - val pagesState = remember { mutableStateOf(emptyList()) } - val pages = pagesState.value - - LaunchedEffect( + val pages by produceState( + initialValue = emptyList(), state.value.text, readerPagination, settings.fontSize.value, @@ -265,70 +261,64 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable { configuration.screenWidthDp, configuration.screenHeightDp, ) { - if (!readerPagination || state.value.text.isEmpty()) { - pagesState.value = emptyList() - return@LaunchedEffect - } + 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() - 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 sidePaddingPx = with(density) { sidePadding.toPx() }.toInt() + val verticalPaddingPx = with(density) { verticalPadding.toPx() }.toInt() + val paragraphHeightPx = with(density) { paragraphHeight.toPx() }.toInt() - val sidePaddingPx = with(density) { sidePadding.toPx() }.toInt() - val verticalPaddingPx = with(density) { verticalPadding.toPx() }.toInt() - val paragraphHeightPx = with(density) { paragraphHeight.toPx() }.toInt() + val contentWidthPx = with(density) { + configuration.screenWidthDp.dp.toPx() + }.toInt() - contentPaddingStartPx - contentPaddingEndPx - sidePaddingPx * 2 - val contentWidthPx = with(density) { - configuration.screenWidthDp.dp.toPx() - }.toInt() - contentPaddingStartPx - contentPaddingEndPx - sidePaddingPx * 2 + val progressBarHeightPx = with(density) { + (progressBarFontSize.toPx() * 1.5f + progressBarPadding.toPx() * 2).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, + ) + org.dueattendant149.bookshelf.domain.use_case.reader.PaginateReaderTextUseCase().invoke( + text = state.value.text, + contentWidthPx = contentWidthPx, + contentHeightPx = contentHeightPx, + paragraphStyle = style, + chapterTitleAlignment = settings.chapterTitleAlignment.lastValue, + paragraphSpacingPx = with(density) { paragraphHeight.toPx() }.toInt(), + textMeasurer = textMeasurer, + imageMaxWidthPx = imageMaxWidthPx, + chapterExtraHeightPx = with(density) { 55.dp.toPx() }.toInt(), + separatorHeightPx = with(density) { 3.dp.toPx() }.toInt(), + ) } - - val contentHeightPx = with(density) { - configuration.screenHeightDp.dp.toPx() - }.toInt() - contentPaddingTopPx - contentPaddingBottomPx - verticalPaddingPx * 2 - paragraphHeightPx * 2 - progressBarHeightPx - - val imageMaxWidthPx = (contentWidthPx * imagesWidth).toInt() - - val style = readerParagraphTextStyle( - fontFamily = settings.fontFamily.lastValue, - fontThickness = settings.fontThickness.lastValue, - fontStyle = if (settings.italic.lastValue) FontStyle.Italic else FontStyle.Normal, - textAlignment = settings.textAlignment.lastValue, - fontSize = settings.fontSize.lastValue.sp, - lineHeight = (settings.fontSize.lastValue + settings.lineHeight.lastValue).sp, - letterSpacing = (settings.letterSpacing.lastValue / 100f).em, - paragraphIndentation = paragraphIndentation, - ) - - val constraints = ReaderConstraints( - contentWidthPx = contentWidthPx, - contentHeightPx = contentHeightPx, - paragraphSpacingPx = paragraphHeightPx, - separatorHeightPx = with(density) { 3.dp.toPx() }.toInt(), - ) - - pagesState.value = screenModel.paginationEngine.paginate( - blocks = state.value.text, - constraints = constraints, - paragraphStyle = style, - textMeasurer = textMeasurer, - imageMaxWidthPx = imageMaxWidthPx, - chapterExtraHeightPx = with(density) { 55.dp.toPx() }.toInt(), - chapterTitleAlignment = settings.chapterTitleAlignment.lastValue.textAlignment, - ).pages } } 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 a3524bf9..60aa8023 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 65395941..d43a0d6f 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,7 +42,6 @@ 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 @@ -63,7 +62,7 @@ fun ReaderBottomBar( checkpoints: List, bottomBarPadding: Dp, readerPagination: Boolean, - pages: List, + pages: List, currentPage: Int, onChangePage: (Int) -> Unit, restoreCheckpoint: (ReaderEvent.OnRestoreCheckpoint) -> Unit, @@ -251,7 +250,7 @@ private fun ReaderBottomBarSlider( lockMenu: Boolean, listState: LazyListState, readerPagination: Boolean, - pages: List, + pages: List, currentPage: Int, onChangePage: (Int) -> Unit, scroll: (ReaderEvent.OnScroll) -> Unit, @@ -275,7 +274,7 @@ private fun ReaderBottomBarSlider( changeProgress( ReaderEvent.OnChangeProgress( progress = it, - firstVisibleItemIndex = pages[page].firstBlockIndex, + firstVisibleItemIndex = pages[page].startTextIndex, 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 415fca37..f05326e4 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 98cac815..c13c374d 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,7 +182,6 @@ 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 055422aa..2fa65363 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,8 +43,7 @@ import org.dueattendant149.bookshelf.ui.theme.model.HorizontalAlignment @Composable fun ReaderPaginationLayout( - text: List, - pages: List, + pages: List, currentPage: Int, contentPadding: PaddingValues, verticalPadding: Dp, @@ -156,15 +155,13 @@ fun ReaderPaginationLayout( .fillMaxWidth() .weight(1f) ) { pageIndex -> - ReaderPageContent2( + ReaderPageContent( page = pages[pageIndex], - blocks = text, verticalPadding = paragraphHeight, sidePadding = sidePadding, backgroundColor = backgroundColor, fontColor = fontColor, images = images, - imagesCaptions = imagesCaptions, imagesCornersRoundness = imagesCornersRoundness, imagesAlignment = imagesAlignment, imagesWidth = imagesWidth, @@ -204,15 +201,13 @@ fun ReaderPaginationLayout( } @Composable -private fun ReaderPageContent2( - page: ReaderPage2, - blocks: List, +private fun ReaderPageContent( + page: ReaderPage, verticalPadding: Dp, sidePadding: Dp, backgroundColor: Color, fontColor: Color, images: Boolean, - imagesCaptions: Boolean, imagesCornersRoundness: Dp, imagesAlignment: HorizontalAlignment, imagesWidth: Float, @@ -242,23 +237,15 @@ private fun ReaderPageContent2( verticalArrangement = Arrangement.spacedBy(verticalPadding, Alignment.Top), horizontalAlignment = horizontalAlignment ) { - 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) + page.items.forEachIndexed { index, entry -> + val previousEntry = page.items.getOrNull(index - 1) when { - !images && (displayBlock is ReaderText.Image || previousBlock is ReaderText.Image) -> return@forEach - !imagesCaptions && previousBlock is ReaderText.Image -> return@forEach + !images && (entry is ReaderText.Image || previousEntry is ReaderText.Image) -> return@forEachIndexed + !images && previousEntry is ReaderText.Image -> return@forEachIndexed else -> { ReaderLayoutText( showMenu = showMenu, - entry = displayBlock, + entry = entry, 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 c407f044..c5bd2c03 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 56575e1f..7d0ce7ac 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,3 +53,28 @@ 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), + ) +}