diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt index bd50f2b..a2e8203 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt @@ -44,6 +44,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -1295,6 +1296,25 @@ private fun TextWithEmphasis( }) } +@SuppressLint("BinaryOperationInTimber") +private fun checkLayoutMismatch( + blockIndex: Int, + blockType: String, + expectedHeight: Int, + actualHeight: Int, + tolerance: Int = 2 +) { + if (actualHeight > expectedHeight + tolerance) { + val diff = actualHeight - expectedHeight + Timber.tag("PAGINATION_MISMATCH").e( + "OVERFLOW DETECTED! Block #$blockIndex ($blockType)\n" + + " -> Expected: ${expectedHeight}px\n" + + " -> Actual: ${actualHeight}px\n" + + " -> Diff: +${diff}px" + ) + } +} + @SuppressLint("UnusedBoxWithConstraintsScope") @OptIn(ExperimentalFoundationApi::class) @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) @@ -1516,90 +1536,84 @@ internal fun PaginatedReaderContent( } Column(modifier = Modifier.fillMaxSize()) { - val searchHighlightColor = - MaterialTheme.colorScheme.primary.copy(alpha = 0.4f) - val ttsHighlightColor = - MaterialTheme.colorScheme.secondary.copy(alpha = 0.5f) + val searchHighlightColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.4f) + val ttsHighlightColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.5f) + pageContent!!.content.forEach { block -> val marginModifier = Modifier.padding( top = block.style.margin.top.coerceAtLeast(0.dp), bottom = block.style.margin.bottom.coerceAtLeast(0.dp) ) - val alignModifier = - if (block.style.horizontalAlign == "center") { - Modifier.align(Alignment.CenterHorizontally) - } else { - Modifier.padding( - start = block.style.margin.left.coerceAtLeast(0.dp), - end = block.style.margin.right.coerceAtLeast(0.dp) - ) - } + val alignModifier = if (block.style.horizontalAlign == "center") { + Modifier.align(Alignment.CenterHorizontally) + } else { + Modifier.padding( + start = block.style.margin.left.coerceAtLeast(0.dp), + end = block.style.margin.right.coerceAtLeast(0.dp) + ) + } - val widthModifier = - if (block.style.width != Dp.Unspecified) { - Modifier.width(block.style.width) - } else { - Modifier.fillMaxWidth() - } + val widthModifier = if (block.style.width != Dp.Unspecified) { + Modifier.width(block.style.width) + } else { + Modifier.fillMaxWidth() + } val boxModifier = marginModifier .then(alignModifier) + .then(if (block.style.horizontalAlign == "center") widthModifier else Modifier) .then( - if (block.style.horizontalAlign == "center") widthModifier + if (block.style.borderRadius > 0.dp) Modifier.clip(RoundedCornerShape(block.style.borderRadius)) else Modifier ) - .then( - if (block.style.borderRadius > 0.dp) { - Modifier.clip(RoundedCornerShape(block.style.borderRadius)) - } else Modifier - ) .then( if (block.style.backgroundColor.isSpecified) { Modifier.background( block.style.backgroundColor, - shape = if (block.style.borderRadius > 0.dp) RoundedCornerShape( - block.style.borderRadius - ) else androidx.compose.ui.graphics.RectangleShape + shape = if (block.style.borderRadius > 0.dp) RoundedCornerShape(block.style.borderRadius) else androidx.compose.ui.graphics.RectangleShape ) - } else { - Modifier - } + } else Modifier ) - .then(block.style.border?.let { border -> - Modifier.border( - BorderStroke( - border.width, border.color - ), - shape = if (block.style.borderRadius > 0.dp) RoundedCornerShape( - block.style.borderRadius - ) else androidx.compose.ui.graphics.RectangleShape - ) - } ?: Modifier) - - Box(modifier = boxModifier) { - val paddingModifier = Modifier - .padding( - start = block.style.padding.left.coerceAtLeast( - 0.dp - ), - top = block.style.padding.top.coerceAtLeast( - 0.dp - ), - end = block.style.padding.right.coerceAtLeast( - 0.dp - ), - bottom = block.style.padding.bottom.coerceAtLeast( - 0.dp + .then( + block.style.border?.let { border -> + Modifier.border( + BorderStroke(border.width, border.color), + shape = if (block.style.borderRadius > 0.dp) RoundedCornerShape(block.style.borderRadius) else androidx.compose.ui.graphics.RectangleShape ) - ) - .then( - if (block.style.horizontalAlign != "center") widthModifier - else Modifier.fillMaxWidth() - ) + } ?: Modifier + ) + + val diagnosticModifier = Modifier + .onGloballyPositioned { coordinates -> + val actualHeight = coordinates.size.height + if (block.expectedHeight > 0) { + checkLayoutMismatch( + blockIndex = block.blockIndex, + blockType = block::class.simpleName ?: "Block", + expectedHeight = block.expectedHeight, + actualHeight = actualHeight + ) + } + } + .then(boxModifier) + + Box(modifier = diagnosticModifier) { + val borderWidth = block.style.border?.width ?: 0.dp + val paddingModifier = Modifier.padding( + start = block.style.padding.left.coerceAtLeast(0.dp) + borderWidth, + top = block.style.padding.top.coerceAtLeast(0.dp) + borderWidth, + end = block.style.padding.right.coerceAtLeast(0.dp) + borderWidth, + bottom = block.style.padding.bottom.coerceAtLeast(0.dp) + borderWidth + ).then( + if (block.style.horizontalAlign != "center") widthModifier else Modifier.fillMaxWidth() + ) @Suppress("DEPRECATION") when (block) { is ParagraphBlock -> { + val paragraphStyle = textStyle.copy( + textAlign = block.textAlign ?: textStyle.textAlign + ) val searchHighlighted = highlightQueryInText( block.content, searchQuery, @@ -1648,7 +1662,7 @@ internal fun PaginatedReaderContent( searchHighlighted } - val diagnosticModifier = + @Suppress("UnusedVariable", "Unused") val diagnosticModifier = if (block.textAlign == TextAlign.Justify) { Modifier.onGloballyPositioned { coordinates -> val width = coordinates.size.width @@ -1672,10 +1686,8 @@ internal fun PaginatedReaderContent( TextWithEmphasis( text = finalContent, - style = textStyle, - modifier = paddingModifier.then( - diagnosticModifier - ), + style = paragraphStyle, + modifier = paddingModifier, textMeasurer = textMeasurer, onLinkClick = onLinkClickCallback, onGeneralTap = onGeneralTapCallback, @@ -1704,7 +1716,8 @@ internal fun PaginatedReaderContent( is HeaderBlock -> { val style = textStyle.copy( - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, + textAlign = block.textAlign ?: textStyle.textAlign ) val searchHighlighted = highlightQueryInText( block.content, @@ -1780,6 +1793,9 @@ internal fun PaginatedReaderContent( } is QuoteBlock -> { + val quoteStyle = textStyle.copy( + textAlign = block.textAlign ?: textStyle.textAlign + ) val quoteModifier = paddingModifier.padding(start = 16.dp) val searchHighlighted = highlightQueryInText( @@ -1827,7 +1843,7 @@ internal fun PaginatedReaderContent( } TextWithEmphasis( text = finalContent, - style = textStyle, + style = quoteStyle, modifier = quoteModifier, textMeasurer = textMeasurer, onLinkClick = onLinkClickCallback, @@ -2112,22 +2128,39 @@ internal fun PaginatedReaderContent( containerWidthPx, localDensity ) - val imageModifier = - if (widthPx != null) { - val finalWidthDp = - with(localDensity) { - widthPx.toDp() - } - Timber.d( - "Applying calculated width to MathBlock image: $finalWidthDp" - ) - Modifier.width(finalWidthDp) + val heightPx = parseSvgDimension( + block.svgHeight, + fontSizePx, + containerWidthPx, + localDensity + ) + + var imageModifier: Modifier = Modifier + if (widthPx != null) { + val finalWidthDp = with(localDensity) { widthPx.toDp() } + Timber.d("Applying calculated width to MathBlock image: $finalWidthDp") + imageModifier = imageModifier.width(finalWidthDp) + } else { + Timber.w("Could not calculate a specific width for MathBlock. It will fill available space.") + imageModifier = imageModifier.fillMaxWidth() + } + + if (heightPx != null) { + val finalHeightDp = with(localDensity) { heightPx.toDp() } + Timber.d("Applying calculated height to MathBlock image: $finalHeightDp") + imageModifier = imageModifier.height(finalHeightDp) + } else { + val viewBoxParts = block.svgViewBox?.split(' ', ',')?.mapNotNull { it.toFloatOrNull() } + if (viewBoxParts != null && viewBoxParts.size == 4 && viewBoxParts[2] > 0) { + val aspectRatio = viewBoxParts[3] / viewBoxParts[2] + val effectiveWidth = widthPx ?: containerWidthPx.toFloat() + val finalHeightDp = with(localDensity) { (effectiveWidth * aspectRatio).toDp() } + imageModifier = imageModifier.height(finalHeightDp) } else { - Timber.w( - "Could not calculate a specific width for MathBlock. It will fill available space." - ) - Modifier + val fallbackHeightDp = with(localDensity) { (textStyle.fontSize.value * 3).sp.toDp() } + imageModifier = imageModifier.height(fallbackHeightDp) } + } val imageRequest = Builder(LocalContext.current).data( @@ -2186,6 +2219,15 @@ internal fun PaginatedReaderContent( ) else Modifier ) + .then( + if (block.intrinsicWidth != null && block.intrinsicHeight != null && block.intrinsicWidth > 0f && block.intrinsicHeight > 0f) { + Modifier.aspectRatio(block.intrinsicWidth / block.intrinsicHeight, matchHeightConstraintsFirst = false) + } else if (style.height != Dp.Unspecified) { + Modifier.height(style.height) + } else { + Modifier.height(250.dp) + } + ) .then(paddingModifier) val colorFilter = @@ -2450,6 +2492,15 @@ internal fun PaginatedReaderContent( } is ImageBlock -> { + val imageModifier = Modifier.fillMaxWidth().then( + if (blockInCell.intrinsicWidth != null && blockInCell.intrinsicHeight != null && blockInCell.intrinsicWidth > 0f && blockInCell.intrinsicHeight > 0f) { + Modifier.aspectRatio(blockInCell.intrinsicWidth / blockInCell.intrinsicHeight, matchHeightConstraintsFirst = false) + } else if (blockInCell.style.height != Dp.Unspecified) { + Modifier.height(blockInCell.style.height) + } else { + Modifier.height(250.dp) + } + ) AsyncImage( model = Builder( LocalContext.current @@ -2460,12 +2511,9 @@ internal fun PaginatedReaderContent( ).build(), contentDescription = blockInCell.altText, contentScale = ContentScale.Fit, - modifier = Modifier.fillMaxWidth() + modifier = imageModifier ) } - // Catch-all for any - // other text-based - // content is TextContentBlock -> { Text( text = blockInCell.content, @@ -3178,6 +3226,15 @@ private fun RenderFlexChildBlock( if (style.maxWidth != Dp.Unspecified) Modifier.widthIn(max = style.maxWidth) else Modifier ) + .then( + if (childBlock.intrinsicWidth != null && childBlock.intrinsicHeight != null && childBlock.intrinsicWidth > 0f && childBlock.intrinsicHeight > 0f) { + Modifier.aspectRatio(childBlock.intrinsicWidth / childBlock.intrinsicHeight, matchHeightConstraintsFirst = false) + } else if (style.height != Dp.Unspecified) { + Modifier.height(style.height) + } else { + Modifier.height(250.dp) + } + ) val colorFilter = if (childBlock.style.filter == "invert(100%)") { val matrix = floatArrayOf( @@ -3295,6 +3352,15 @@ private fun RenderFlexChildBlock( modifier = Modifier.fillMaxWidth() ) } else if (blockInCell is ImageBlock) { + val imageModifier = Modifier.fillMaxWidth().then( + if (blockInCell.intrinsicWidth != null && blockInCell.intrinsicHeight != null && blockInCell.intrinsicWidth > 0f && blockInCell.intrinsicHeight > 0f) { + Modifier.aspectRatio(blockInCell.intrinsicWidth / blockInCell.intrinsicHeight, matchHeightConstraintsFirst = false) + } else if (blockInCell.style.height != Dp.Unspecified) { + Modifier.height(blockInCell.style.height) + } else { + Modifier.height(250.dp) + } + ) AsyncImage( model = Builder(LocalContext.current).data( File( @@ -3303,7 +3369,7 @@ private fun RenderFlexChildBlock( ).build(), contentDescription = blockInCell.altText, contentScale = ContentScale.Fit, - modifier = Modifier.fillMaxWidth() + modifier = imageModifier ) } } diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReaderData.kt b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReaderData.kt index 3b1241d..b048119 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReaderData.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReaderData.kt @@ -139,6 +139,7 @@ sealed interface ContentBlock { val elementId: String? val cfi: String? val blockIndex: Int + val expectedHeight: Int } sealed interface TextContentBlock : ContentBlock { @@ -156,7 +157,8 @@ data class ParagraphBlock( @ProtoNumber(5) override val cfi: String? = null, @ProtoNumber(6) override val startCharOffsetInSource: Int = 0, @ProtoNumber(7) override val endCharOffsetInSource: Int = -1, - @ProtoNumber(8) override val blockIndex: Int + @ProtoNumber(8) override val blockIndex: Int, + @ProtoNumber(9) override val expectedHeight: Int = 0 ) : TextContentBlock @Serializable @@ -169,7 +171,8 @@ data class ImageBlock( @ProtoNumber(6) override val elementId: String? = null, @ProtoNumber(7) override val cfi: String? = null, @ProtoNumber(8) val invertOnDarkTheme: Boolean = false, - @ProtoNumber(9) override val blockIndex: Int + @ProtoNumber(9) override val blockIndex: Int, + @ProtoNumber(10) override val expectedHeight: Int = 0 ) : ContentBlock @Serializable @@ -183,6 +186,7 @@ data class HeaderBlock( @ProtoNumber(7) override val startCharOffsetInSource: Int = 0, @ProtoNumber(8) override val endCharOffsetInSource: Int = -1, @ProtoNumber(9) override val blockIndex: Int, + @ProtoNumber(10) override val expectedHeight: Int = 0 ) : TextContentBlock @Serializable @@ -191,7 +195,8 @@ data class SpacerBlock( @ProtoNumber(2) override val style: BlockStyle = BlockStyle(), @ProtoNumber(3) override val elementId: String? = null, @ProtoNumber(4) override val cfi: String? = null, - @ProtoNumber(5) override val blockIndex: Int + @ProtoNumber(5) override val blockIndex: Int, + @ProtoNumber(6) override val expectedHeight: Int = 0 ) : ContentBlock @Serializable @@ -203,7 +208,8 @@ data class QuoteBlock( @ProtoNumber(5) override val cfi: String? = null, @ProtoNumber(6) override val startCharOffsetInSource: Int = 0, @ProtoNumber(7) override val endCharOffsetInSource: Int = -1, - @ProtoNumber(8) override val blockIndex: Int + @ProtoNumber(8) override val blockIndex: Int, + @ProtoNumber(9) override val expectedHeight: Int = 0 ) : TextContentBlock @Serializable @@ -216,7 +222,8 @@ data class ListItemBlock( @ProtoNumber(6) override val cfi: String? = null, @ProtoNumber(7) override val startCharOffsetInSource: Int = 0, @ProtoNumber(8) override val endCharOffsetInSource: Int = -1, - @ProtoNumber(9) override val blockIndex: Int + @ProtoNumber(9) override val blockIndex: Int, + @ProtoNumber(10) override val expectedHeight: Int = 0 ) : TextContentBlock @Serializable @@ -233,7 +240,8 @@ data class TableBlock( @ProtoNumber(2) override val style: BlockStyle = BlockStyle(), @ProtoNumber(3) override val elementId: String? = null, @ProtoNumber(4) override val cfi: String? = null, - @ProtoNumber(5) override val blockIndex: Int + @ProtoNumber(5) override val blockIndex: Int, + @ProtoNumber(6) override val expectedHeight: Int = 0 ) : ContentBlock @Serializable @@ -247,7 +255,8 @@ data class MathBlock( @ProtoNumber(7) val svgHeight: String? = null, @ProtoNumber(8) val svgViewBox: String? = null, @ProtoNumber(9) val isFromMathJax: Boolean = false, - @ProtoNumber(10) override val blockIndex: Int + @ProtoNumber(10) override val blockIndex: Int, + @ProtoNumber(11) override val expectedHeight: Int = 0 ) : ContentBlock @Serializable @@ -257,7 +266,8 @@ data class WrappingContentBlock( @ProtoNumber(3) override val style: BlockStyle = BlockStyle(), @ProtoNumber(4) override val elementId: String? = null, @ProtoNumber(5) override val cfi: String? = null, - @ProtoNumber(6) override val blockIndex: Int + @ProtoNumber(6) override val blockIndex: Int, + @ProtoNumber(7) override val expectedHeight: Int = 0 ) : ContentBlock @Serializable @@ -332,7 +342,8 @@ data class FlexContainerBlock( @ProtoNumber(2) override val style: BlockStyle = BlockStyle(), @ProtoNumber(3) override val elementId: String? = null, @ProtoNumber(4) override val cfi: String? = null, - @ProtoNumber(5) override val blockIndex: Int + @ProtoNumber(5) override val blockIndex: Int, + @ProtoNumber(6) override val expectedHeight: Int = 0 ) : ContentBlock @Serializable diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/Paginator.kt b/app/src/main/java/com/aryan/reader/paginatedreader/Paginator.kt index 313e252..37bf74e 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/Paginator.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/Paginator.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.unit.isSpecified import androidx.compose.ui.unit.sp import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlin.math.roundToInt interface BlockMeasurementProvider { suspend fun measure(block: ContentBlock): Int @@ -286,14 +287,13 @@ class SuspendingAndroidBlockMeasurementProvider( var currentHeight = 0 var splitRowIndex = -1 - // Account for top and bottom decorations (padding + border) val decorationTop = with(density) { block.style.padding.top.toPx() + (block.style.border?.width?.toPx() ?: 0f) - }.toInt() + }.roundToInt() val decorationBottom = with(density) { block.style.padding.bottom.toPx() + (block.style.border?.width?.toPx() ?: 0f) - }.toInt() + }.roundToInt() Timber.tag("PAGINATION_DEBUG").d("SplitTable: avail=$availableHeight, topDec=$decorationTop, botDec=$decorationBottom") currentHeight += decorationTop @@ -304,8 +304,8 @@ class SuspendingAndroidBlockMeasurementProvider( val totalColspan = row.sumOf { it.colspan }.toFloat().coerceAtLeast(1f) row.forEach { cell -> - val cellMaxWidth = ((constraints.maxWidth) * (cell.colspan.toFloat() / totalColspan)).toInt() - constraints.copy(maxWidth = cellMaxWidth.coerceAtLeast(0)) + val cellMaxWidth = ((constraints.maxWidth) * (cell.colspan.toFloat() / totalColspan)).roundToInt() + @Suppress("UnusedVariable", "Unused") val cellConstraints = constraints.copy(maxWidth = cellMaxWidth.coerceAtLeast(0)) var cellHeight = 0 cell.content.forEach { b -> @@ -314,11 +314,10 @@ class SuspendingAndroidBlockMeasurementProvider( val cellDecoration = with(density) { cell.style.blockStyle.padding.top.toPx() + cell.style.blockStyle.padding.bottom.toPx() + (cell.style.blockStyle.border?.width?.toPx() ?: 0f) * 2 - }.toInt() + }.roundToInt() maxRowHeight = maxOf(maxRowHeight, cellHeight + cellDecoration) } - // CHECK: Must account for decorationBottom here if (currentHeight + maxRowHeight + decorationBottom > availableHeight) { Timber.tag("PAGINATION_DEBUG").d("SplitTable: Breaking at row $i. currentH=$currentHeight, rowH=$maxRowHeight") splitRowIndex = i @@ -327,7 +326,7 @@ class SuspendingAndroidBlockMeasurementProvider( currentHeight += maxRowHeight } - if (splitRowIndex <= 0) return null // Can't even fit the first row + if (splitRowIndex <= 0) return null val part1Rows = block.rows.subList(0, splitRowIndex) val part2Rows = block.rows.subList(splitRowIndex, block.rows.size) @@ -346,11 +345,11 @@ class SuspendingAndroidBlockMeasurementProvider( val decorationTop = with(density) { block.style.padding.top.toPx() + (block.style.border?.width?.toPx() ?: 0f) - }.toInt() + }.roundToInt() val decorationBottom = with(density) { block.style.padding.bottom.toPx() + (block.style.border?.width?.toPx() ?: 0f) - }.toInt() + }.roundToInt() currentHeight += decorationTop @@ -363,9 +362,8 @@ class SuspendingAndroidBlockMeasurementProvider( val currMargin = child.style.margin.top.toPx() maxOf(prevMargin, currMargin) } else child.style.margin.top.toPx() - }.toInt() + }.roundToInt() - // CHECK: Must account for decorationBottom here if (currentHeight + childHeight + margin + decorationBottom > availableHeight) { splitChildIndex = i break @@ -400,6 +398,22 @@ private fun copyBlockWithNewStyle(block: ContentBlock, newStyle: BlockStyle): Co } } +@Suppress("UNCHECKED_CAST") +private fun setBlockExpectedHeight(block: T, height: Int): T { + return when (block) { + is ParagraphBlock -> block.copy(expectedHeight = height) + is HeaderBlock -> block.copy(expectedHeight = height) + is ImageBlock -> block.copy(expectedHeight = height) + is SpacerBlock -> block.copy(expectedHeight = height) + is QuoteBlock -> block.copy(expectedHeight = height) + is ListItemBlock -> block.copy(expectedHeight = height) + is WrappingContentBlock -> block.copy(expectedHeight = height) + is TableBlock -> block.copy(expectedHeight = height) + is FlexContainerBlock -> block.copy(expectedHeight = height) + is MathBlock -> block.copy(expectedHeight = height) + } as T +} + @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) suspend fun paginate( blocks: List, @@ -417,10 +431,11 @@ suspend fun paginate( var remainingHeight = pageHeight val remainingBlocks = blocks.toMutableList() var pageIndex = 0 - val safetyMarginPerBlock = 2 + val safetyMarginPerBlock = 0 while (remainingBlocks.isNotEmpty()) { val block = remainingBlocks.removeAt(0) + val blockHeight = measurementProvider.measure(block) val blockHeightWithSafetyMargin = blockHeight + safetyMarginPerBlock @@ -432,11 +447,12 @@ suspend fun paginate( } else { block.style.margin.top.toPx() } - } + }.roundToInt() - val spaceRequired = (blockHeightWithSafetyMargin + spaceBetweenBlocks).toInt() + val spaceRequired = blockHeightWithSafetyMargin + spaceBetweenBlocks - Timber.tag("PAGINATION_DEBUG").d("Processing ${block::class.simpleName}: req=$spaceRequired, remaining=$remainingHeight, margin=$spaceBetweenBlocks, heightOnly=$blockHeight") + Timber.tag("PAGINATION_DEBUG") + .d("Processing ${block::class.simpleName}: req=$spaceRequired, remaining=$remainingHeight, margin=$spaceBetweenBlocks, heightOnly=$blockHeight") if (spaceRequired <= remainingHeight) { var blockToAdd = block @@ -444,76 +460,170 @@ suspend fun paginate( if (currentPageContent.isNotEmpty()) { val prevBlock = currentPageContent.last() - val newPrevStyle = prevBlock.style.copy(margin = prevBlock.style.margin.copy(bottom = 0.dp)) - val newPrevBlock = copyBlockWithNewStyle(prevBlock, newPrevStyle) + val newPrevStyle = + prevBlock.style.copy(margin = prevBlock.style.margin.copy(bottom = 0.dp)) + val newPrevBlock = setBlockExpectedHeight( + copyBlockWithNewStyle(prevBlock, newPrevStyle), + prevBlock.expectedHeight + ) currentPageContent[currentPageContent.size - 1] = newPrevBlock } - val newCurrentStyle = block.style.copy(margin = block.style.margin.copy(top = collapsedMarginDp)) + val newCurrentStyle = + block.style.copy(margin = block.style.margin.copy(top = collapsedMarginDp)) blockToAdd = copyBlockWithNewStyle(block, newCurrentStyle) + blockToAdd = setBlockExpectedHeight(blockToAdd, spaceRequired) + currentPageContent.add(blockToAdd) remainingHeight -= spaceRequired } else { var wasSplit = false - val heightForSplitting = remainingHeight - spaceBetweenBlocks.toInt() + val heightForSplitting = remainingHeight - spaceBetweenBlocks if (heightForSplitting > 50) { when (block) { is ParagraphBlock -> { if (!block.style.pageBreakInsideAvoid) { - measurementProvider.split(block, heightForSplitting)?.let { (part1, part2) -> - if (part1.content.isNotEmpty()) { - val collapsedMarginDp = with(density) { spaceBetweenBlocks.toDp() } + measurementProvider.split(block, heightForSplitting) + ?.let { (part1, part2) -> + if (part1.content.isNotEmpty()) { + val collapsedMarginDp = + with(density) { spaceBetweenBlocks.toDp() } + if (currentPageContent.isNotEmpty()) { + val prevBlock = currentPageContent.last() + val newPrevStyle = prevBlock.style.copy( + margin = prevBlock.style.margin.copy(bottom = 0.dp) + ) + currentPageContent[currentPageContent.size - 1] = + setBlockExpectedHeight( + copyBlockWithNewStyle( + prevBlock, + newPrevStyle + ), prevBlock.expectedHeight + ) + } + val newPart1Style = + part1.style.copy(margin = part1.style.margin.copy(top = collapsedMarginDp)) + var finalPart1 = part1.copy(style = newPart1Style) + + val part1Height = measurementProvider.measure(finalPart1) + val part1Total = part1Height + spaceBetweenBlocks + finalPart1 = setBlockExpectedHeight(finalPart1, part1Total) + + currentPageContent.add(finalPart1) + if (part2.content.isNotEmpty()) remainingBlocks.add( + 0, + part2 + ) + wasSplit = true + } + } + } + } + + is WrappingContentBlock -> { + measurementProvider.split(block, heightForSplitting) + ?.let { (part1, part2) -> + if (part1.paragraphsToWrap.any { it.content.isNotBlank() }) { + val collapsedMarginDp = + with(density) { spaceBetweenBlocks.toDp() } if (currentPageContent.isNotEmpty()) { val prevBlock = currentPageContent.last() - val newPrevStyle = prevBlock.style.copy(margin = prevBlock.style.margin.copy(bottom = 0.dp)) - currentPageContent[currentPageContent.size - 1] = copyBlockWithNewStyle(prevBlock, newPrevStyle) + val newPrevStyle = prevBlock.style.copy( + margin = prevBlock.style.margin.copy(bottom = 0.dp) + ) + currentPageContent[currentPageContent.size - 1] = + setBlockExpectedHeight( + copyBlockWithNewStyle( + prevBlock, + newPrevStyle + ), prevBlock.expectedHeight + ) } - val newPart1Style = part1.style.copy(margin = part1.style.margin.copy(top = collapsedMarginDp)) - val finalPart1 = part1.copy(style = newPart1Style) + val newPart1Style = + part1.style.copy(margin = part1.style.margin.copy(top = collapsedMarginDp)) + var finalPart1 = part1.copy(style = newPart1Style) + + val part1Height = measurementProvider.measure(finalPart1) + val part1Total = part1Height + spaceBetweenBlocks + finalPart1 = setBlockExpectedHeight(finalPart1, part1Total) + currentPageContent.add(finalPart1) - if (part2.content.isNotEmpty()) remainingBlocks.add(0, part2) + if (part2.isNotEmpty()) { + remainingBlocks.addAll(0, part2) + } wasSplit = true } } - } } - is WrappingContentBlock -> { - measurementProvider.split(block, heightForSplitting)?.let { (part1, part2) -> - if (part1.paragraphsToWrap.any { it.content.isNotBlank() }) { + + is TableBlock -> { + measurementProvider.split(block, heightForSplitting) + ?.let { (part1, part2) -> val collapsedMarginDp = with(density) { spaceBetweenBlocks.toDp() } if (currentPageContent.isNotEmpty()) { val prevBlock = currentPageContent.last() - val newPrevStyle = prevBlock.style.copy(margin = prevBlock.style.margin.copy(bottom = 0.dp)) - currentPageContent[currentPageContent.size - 1] = copyBlockWithNewStyle(prevBlock, newPrevStyle) + val newPrevStyle = prevBlock.style.copy( + margin = prevBlock.style.margin.copy(bottom = 0.dp) + ) + currentPageContent[currentPageContent.size - 1] = + setBlockExpectedHeight( + copyBlockWithNewStyle( + prevBlock, + newPrevStyle + ), prevBlock.expectedHeight + ) } - val newPart1Style = part1.style.copy(margin = part1.style.margin.copy(top = collapsedMarginDp)) - val finalPart1 = part1.copy(style = newPart1Style) + + val newPart1Style = + part1.style.copy(margin = part1.style.margin.copy(top = collapsedMarginDp)) + var finalPart1 = copyBlockWithNewStyle(part1, newPart1Style) + + val part1Height = measurementProvider.measure(finalPart1) + val part1Total = part1Height + spaceBetweenBlocks + finalPart1 = setBlockExpectedHeight(finalPart1, part1Total) + currentPageContent.add(finalPart1) - if (part2.isNotEmpty()) { - remainingBlocks.addAll(0, part2) - } + remainingBlocks.add(0, part2) wasSplit = true } - } - } - is TableBlock -> { - measurementProvider.split(block, heightForSplitting)?.let { (part1, part2) -> - val collapsedMarginDp = with(density) { spaceBetweenBlocks.toDp() } - currentPageContent.add(copyBlockWithNewStyle(part1, part1.style.copy(margin = part1.style.margin.copy(top = collapsedMarginDp)))) - remainingBlocks.add(0, part2) - wasSplit = true - } } + is FlexContainerBlock -> { - measurementProvider.split(block, heightForSplitting)?.let { (part1, part2) -> - val collapsedMarginDp = with(density) { spaceBetweenBlocks.toDp() } - currentPageContent.add(copyBlockWithNewStyle(part1, part1.style.copy(margin = part1.style.margin.copy(top = collapsedMarginDp)))) - remainingBlocks.add(0, part2) - wasSplit = true - } + measurementProvider.split(block, heightForSplitting) + ?.let { (part1, part2) -> + val collapsedMarginDp = with(density) { spaceBetweenBlocks.toDp() } + if (currentPageContent.isNotEmpty()) { + val prevBlock = currentPageContent.last() + val newPrevStyle = prevBlock.style.copy( + margin = prevBlock.style.margin.copy(bottom = 0.dp) + ) + currentPageContent[currentPageContent.size - 1] = + setBlockExpectedHeight( + copyBlockWithNewStyle( + prevBlock, + newPrevStyle + ), prevBlock.expectedHeight + ) + } + + val newPart1Style = + part1.style.copy(margin = part1.style.margin.copy(top = collapsedMarginDp)) + var finalPart1 = copyBlockWithNewStyle(part1, newPart1Style) + + val part1Height = measurementProvider.measure(finalPart1) + val part1Total = part1Height + spaceBetweenBlocks + finalPart1 = setBlockExpectedHeight(finalPart1, part1Total) + + currentPageContent.add(finalPart1) + remainingBlocks.add(0, part2) + wasSplit = true + } + } + + else -> { + Timber.d("Page ${pageIndex + 1}: Block type is not splittable.") } - else -> { Timber.d("Page ${pageIndex + 1}: Block type is not splittable.") } } } else { Timber.d("Page ${pageIndex + 1}: Not enough height for splitting ($heightForSplitting <= 50).") @@ -521,14 +631,20 @@ suspend fun paginate( if (!wasSplit) { if (currentPageContent.isEmpty()) { - Timber.tag("PAGINATION_DEBUG").w("FORCING block ${block::class.simpleName} onto page because it is the first block, even though req($spaceRequired) > remaining($remainingHeight)") - currentPageContent.add(block) + Timber.tag("PAGINATION_DEBUG") + .w("FORCING block ${block::class.simpleName} onto page because it is the first block, even though req($spaceRequired) > remaining($remainingHeight)") + val forcedHeight = blockHeight + spaceBetweenBlocks + val blockToAdd = setBlockExpectedHeight(block, forcedHeight) + currentPageContent.add(blockToAdd) } else { - Timber.tag("PAGINATION_DEBUG").d("Block ${block::class.simpleName} did not fit and was not split. Moving to next page.") + Timber.tag("PAGINATION_DEBUG") + .d("Block ${block::class.simpleName} did not fit and was not split. Moving to next page.") remainingBlocks.add(0, block) } } + zeroOutBottomMargin(currentPageContent) + pages.add(Page(content = currentPageContent.toList())) pageIndex++ currentPageContent = mutableListOf() @@ -536,6 +652,8 @@ suspend fun paginate( } } if (currentPageContent.isNotEmpty()) { + zeroOutBottomMargin(currentPageContent) + pages.add(Page(content = currentPageContent.toList())) } @@ -569,7 +687,6 @@ private suspend fun measureBlockHeight( val specifiedWidthDp = block.style.width val specifiedMaxWidthDp = block.style.maxWidth - // 1. Determine the block's final outer width. val blockOuterWidthPx = with(density) { var effectiveWidthPx = constraints.maxWidth.toFloat() if (specifiedWidthDp != Dp.Unspecified) { @@ -584,18 +701,18 @@ private suspend fun measureBlockHeight( effectiveWidthPx.coerceAtMost(constraints.maxWidth.toFloat()) } - // 2. Determine the width available for the content itself. - val contentMaxWidth = if (isBorderBox) { + val contentMaxWidth = if (specifiedWidthDp == Dp.Unspecified) { + (blockOuterWidthPx - horizontalPaddingPx - horizontalBorderPx) + } else if (isBorderBox) { (blockOuterWidthPx - horizontalPaddingPx - horizontalBorderPx) } else { blockOuterWidthPx } val adjustedConstraints = constraints.copy( - maxWidth = contentMaxWidth.toInt().coerceAtLeast(0) + maxWidth = contentMaxWidth.roundToInt().coerceAtLeast(0) ) - // 3. Measure the height of the actual content using the calculated content width. val contentHeight = when (block) { is ParagraphBlock -> { val height = withContext(Dispatchers.Main) { @@ -634,20 +751,19 @@ private suspend fun measureBlockHeight( contentMaxWidth } - val height = (imageRenderWidthPx * aspectRatio).toInt() + val height = (imageRenderWidthPx * aspectRatio).roundToInt() height } else { Timber.w("Image at '${block.path}' has no valid intrinsic dimensions, falling back to fixed height.") if (block.style.height != Dp.Unspecified) { - with(density) { block.style.height.toPx() }.toInt() + with(density) { block.style.height.toPx().roundToInt() } } else { - with(density) { 250.dp.toPx() }.toInt() + with(density) { 250.dp.toPx().roundToInt() } } } } is SpacerBlock -> { - // A spacer's height property defines its content height. - val height = with(density) { block.height.toPx() }.toInt() + val height = with(density) { block.height.toPx().roundToInt() } height } is QuoteBlock -> { @@ -673,7 +789,7 @@ private suspend fun measureBlockHeight( ).size.height } val markerImageHeight = if (block.itemMarkerImage != null) { - with(density) { (defaultStyle.fontSize.value * 0.8f).sp.toPx() }.toInt() + with(density) { (defaultStyle.fontSize.value * 0.8f).sp.toPx().roundToInt() } } else { 0 } @@ -689,8 +805,8 @@ private suspend fun measureBlockHeight( row.forEach { cell -> val cellBlockStyle = cell.style.blockStyle val cellMaxWidth = when { - cellBlockStyle.width.isSpecified -> with(density) { cellBlockStyle.width.toPx() }.toInt() - else -> (adjustedConstraints.maxWidth * (cell.colspan.toFloat() / totalColspan)).toInt() + cellBlockStyle.width.isSpecified -> with(density) { cellBlockStyle.width.toPx().roundToInt() } + else -> (adjustedConstraints.maxWidth * (cell.colspan.toFloat() / totalColspan)).roundToInt() } val cellConstraints = adjustedConstraints.copy(maxWidth = cellMaxWidth.coerceAtLeast(0)) @@ -702,7 +818,7 @@ private suspend fun measureBlockHeight( cellDecorationHeight = cellBlockStyle.padding.top.toPx() + cellBlockStyle.padding.bottom.toPx() cellBlockStyle.border?.let { cellDecorationHeight += it.width.toPx() * 2 } } - maxRowHeight = maxOf(maxRowHeight, (cellContentHeight + cellDecorationHeight).toInt()) + maxRowHeight = maxOf(maxRowHeight, (cellContentHeight + cellDecorationHeight).roundToInt()) } totalHeight += maxRowHeight } @@ -820,13 +936,12 @@ private suspend fun measureBlockHeight( textOffset += firstLineEndOffset - // Skip leading whitespace for the next iteration. while (textOffset < fullText.length && fullText[textOffset].isWhitespace()) { textOffset++ } } - val height = maxOf(currentY, imageHeightPx).toInt() + val height = maxOf(currentY, imageHeightPx).roundToInt() height } is FlexContainerBlock -> { @@ -848,36 +963,26 @@ private suspend fun measureBlockHeight( val heightPx = parseSvgDimension(block.svgHeight, fontSizePx, containerWidthPx, density) val finalHeight = if (heightPx != null) { - Timber.d("Paginator measuring MathBlock '${block.elementId}': Using explicit height '${block.svgHeight}' -> ${heightPx.toInt()}px") - heightPx.toInt() - } else if (widthPx != null && block.svgViewBox != null) { - val viewBoxParts = block.svgViewBox.split(' ', ',').mapNotNull { it.toFloatOrNull() } - if (viewBoxParts.size == 4 && viewBoxParts[2] > 0) { - val viewBoxWidth = viewBoxParts[2] - val viewBoxHeight = viewBoxParts[3] - val aspectRatio = viewBoxHeight / viewBoxWidth - val calculatedHeight = (widthPx * aspectRatio).toInt() - Timber.d("Paginator measuring MathBlock '${block.elementId}': Using width '${block.svgWidth}' -> ${widthPx}px and viewBox ratio $aspectRatio -> calculated height ${calculatedHeight}px") - calculatedHeight - } else { - val fallbackHeight = with(density) { (defaultStyle.fontSize.value * 3).sp.toPx() }.toInt() - Timber.w("Paginator measuring MathBlock '${block.elementId}': Invalid viewBox '${block.svgViewBox}'. Using fallback height ${fallbackHeight}px") - fallbackHeight - } + heightPx.roundToInt() } else { - val fallbackHeight = with(density) { (defaultStyle.fontSize.value * 3).sp.toPx() }.toInt() - Timber.w("Paginator measuring MathBlock '${block.elementId}': No usable dimensions (width='${block.svgWidth}', height='${block.svgHeight}'). Using fallback height ${fallbackHeight}px") - fallbackHeight + val viewBoxParts = block.svgViewBox?.split(' ', ',')?.mapNotNull { it.toFloatOrNull() } + if (viewBoxParts != null && viewBoxParts.size == 4 && viewBoxParts[2] > 0) { + val aspectRatio = viewBoxParts[3] / viewBoxParts[2] + + val effectiveWidth = widthPx ?: containerWidthPx.toFloat() + (effectiveWidth * aspectRatio).roundToInt() + } else { + with(density) { (defaultStyle.fontSize.value * 3).sp.toPx().roundToInt() } + } } finalHeight } } - // 4. Calculate the final total height based on box-sizing. val specifiedHeightDp = block.style.height val finalHeight = if (isBorderBox && specifiedHeightDp != Dp.Unspecified) { - with(density) { specifiedHeightDp.toPx() }.toInt() + with(density) { specifiedHeightDp.toPx().roundToInt() } } else { - (contentHeight + verticalPaddingPx + verticalBorderPx).toInt() + (contentHeight + verticalPaddingPx + verticalBorderPx).roundToInt() } Timber.tag("PAGINATION_DEBUG").v("Measure result for ${block::class.simpleName}: content=$contentHeight, paddingV=$verticalPaddingPx, borderV=$verticalBorderPx, total=$finalHeight") @@ -897,11 +1002,11 @@ private suspend fun splitParagraphBlock( val decorationTop = with(density) { block.style.padding.top.toPx() + (block.style.border?.width?.toPx() ?: 0f) - } + }.roundToInt() val decorationBottom = with(density) { block.style.padding.bottom.toPx() + (block.style.border?.width?.toPx() ?: 0f) - } + }.roundToInt() val availableTextHeight = availableHeight - decorationTop - decorationBottom @@ -928,13 +1033,13 @@ private suspend fun splitParagraphBlock( return null } - var lastVisibleLine = layoutResult.getLineForVerticalPosition(availableTextHeight) + var lastVisibleLine = layoutResult.getLineForVerticalPosition(availableTextHeight.toFloat()) if (layoutResult.getLineBottom(lastVisibleLine) > availableHeight.toFloat()) { lastVisibleLine-- } - if (lastVisibleLine < 0) { // Safety check after decrementing + if (lastVisibleLine < 0) { return null } @@ -1031,7 +1136,7 @@ internal fun parseSvgDimension( dimension.endsWith("ex") -> dimension.removeSuffix("ex").toFloatOrNull()?.let { it * 0.5f * fontSizePx } dimension.endsWith("em") -> dimension.removeSuffix("em").toFloatOrNull()?.let { it * fontSizePx } dimension.endsWith("px") -> dimension.removeSuffix("px").toFloatOrNull() - dimension.endsWith("pt") -> dimension.removeSuffix("pt").toFloatOrNull()?.let { it * density.density * 160f / 72f } + dimension.endsWith("pt") -> dimension.removeSuffix("pt").toFloatOrNull()?.let { it * 1.333f * density.density } dimension.endsWith("%") -> dimension.removeSuffix("%").toFloatOrNull()?.let { (it / 100f) * containerWidthPx } else -> dimension.toFloatOrNull() } @@ -1056,12 +1161,22 @@ private suspend fun calculateContentHeightWithMargins( } else { child.style.margin.top.toPx() } - } - totalHeight += (childHeight + margin).toInt() + }.roundToInt() + totalHeight += (childHeight + margin) Timber.tag("PAGINATION_DEBUG").v(" Internal Child ${child::class.simpleName}: h=$childHeight, margin=$margin, runningTotal=$totalHeight") } if (children.isNotEmpty()) { - totalHeight += with(density) { children.last().style.margin.bottom.toPx() }.toInt() + totalHeight += with(density) { children.last().style.margin.bottom.toPx().roundToInt() } } return totalHeight +} + +private fun zeroOutBottomMargin(blocks: MutableList) { + if (blocks.isNotEmpty()) { + val lastBlock = blocks.last() + val newLastStyle = lastBlock.style.copy(margin = lastBlock.style.margin.copy(bottom = 0.dp)) + val newLastBlock = + setBlockExpectedHeight(copyBlockWithNewStyle(lastBlock, newLastStyle), lastBlock.expectedHeight) + blocks[blocks.size - 1] = newLastBlock + } } \ No newline at end of file