fix(paginator): resolve page overflow and layout mismatches (#34)

- Enforce strict height and aspect ratio for Math and Image blocks.
- Align Paragraph padding, width math, and text alignment with the UI.
- Strip bottom margins on last blocks and use roundToInt for precision.
- Add expectedHeight diagnostics to detect and log UI overflows.
This commit is contained in:
Aryan 2026-03-05 21:42:57 +05:30 committed by GitHub
parent 7e28c1c2b3
commit 506f60473c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 392 additions and 200 deletions

View file

@ -44,6 +44,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth 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") @SuppressLint("UnusedBoxWithConstraintsScope")
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
@ -1516,90 +1536,84 @@ internal fun PaginatedReaderContent(
} }
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
val searchHighlightColor = val searchHighlightColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.4f)
MaterialTheme.colorScheme.primary.copy(alpha = 0.4f) val ttsHighlightColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.5f)
val ttsHighlightColor =
MaterialTheme.colorScheme.secondary.copy(alpha = 0.5f)
pageContent!!.content.forEach { block -> pageContent!!.content.forEach { block ->
val marginModifier = Modifier.padding( val marginModifier = Modifier.padding(
top = block.style.margin.top.coerceAtLeast(0.dp), top = block.style.margin.top.coerceAtLeast(0.dp),
bottom = block.style.margin.bottom.coerceAtLeast(0.dp) bottom = block.style.margin.bottom.coerceAtLeast(0.dp)
) )
val alignModifier = val alignModifier = if (block.style.horizontalAlign == "center") {
if (block.style.horizontalAlign == "center") { Modifier.align(Alignment.CenterHorizontally)
Modifier.align(Alignment.CenterHorizontally) } else {
} else { Modifier.padding(
Modifier.padding( start = block.style.margin.left.coerceAtLeast(0.dp),
start = block.style.margin.left.coerceAtLeast(0.dp), end = block.style.margin.right.coerceAtLeast(0.dp)
end = block.style.margin.right.coerceAtLeast(0.dp) )
) }
}
val widthModifier = val widthModifier = if (block.style.width != Dp.Unspecified) {
if (block.style.width != Dp.Unspecified) { Modifier.width(block.style.width)
Modifier.width(block.style.width) } else {
} else { Modifier.fillMaxWidth()
Modifier.fillMaxWidth() }
}
val boxModifier = marginModifier val boxModifier = marginModifier
.then(alignModifier) .then(alignModifier)
.then(if (block.style.horizontalAlign == "center") widthModifier else Modifier)
.then( .then(
if (block.style.horizontalAlign == "center") widthModifier if (block.style.borderRadius > 0.dp) Modifier.clip(RoundedCornerShape(block.style.borderRadius))
else Modifier else Modifier
) )
.then(
if (block.style.borderRadius > 0.dp) {
Modifier.clip(RoundedCornerShape(block.style.borderRadius))
} else Modifier
)
.then( .then(
if (block.style.backgroundColor.isSpecified) { if (block.style.backgroundColor.isSpecified) {
Modifier.background( Modifier.background(
block.style.backgroundColor, block.style.backgroundColor,
shape = if (block.style.borderRadius > 0.dp) RoundedCornerShape( shape = if (block.style.borderRadius > 0.dp) RoundedCornerShape(block.style.borderRadius) else androidx.compose.ui.graphics.RectangleShape
block.style.borderRadius
) else androidx.compose.ui.graphics.RectangleShape
) )
} else { } else Modifier
Modifier
}
) )
.then(block.style.border?.let { border -> .then(
Modifier.border( block.style.border?.let { border ->
BorderStroke( Modifier.border(
border.width, border.color BorderStroke(border.width, border.color),
), 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
)
} ?: 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
) )
) } ?: Modifier
.then( )
if (block.style.horizontalAlign != "center") widthModifier
else Modifier.fillMaxWidth() 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) { @Suppress("DEPRECATION") when (block) {
is ParagraphBlock -> { is ParagraphBlock -> {
val paragraphStyle = textStyle.copy(
textAlign = block.textAlign ?: textStyle.textAlign
)
val searchHighlighted = highlightQueryInText( val searchHighlighted = highlightQueryInText(
block.content, block.content,
searchQuery, searchQuery,
@ -1648,7 +1662,7 @@ internal fun PaginatedReaderContent(
searchHighlighted searchHighlighted
} }
val diagnosticModifier = @Suppress("UnusedVariable", "Unused") val diagnosticModifier =
if (block.textAlign == TextAlign.Justify) { if (block.textAlign == TextAlign.Justify) {
Modifier.onGloballyPositioned { coordinates -> Modifier.onGloballyPositioned { coordinates ->
val width = coordinates.size.width val width = coordinates.size.width
@ -1672,10 +1686,8 @@ internal fun PaginatedReaderContent(
TextWithEmphasis( TextWithEmphasis(
text = finalContent, text = finalContent,
style = textStyle, style = paragraphStyle,
modifier = paddingModifier.then( modifier = paddingModifier,
diagnosticModifier
),
textMeasurer = textMeasurer, textMeasurer = textMeasurer,
onLinkClick = onLinkClickCallback, onLinkClick = onLinkClickCallback,
onGeneralTap = onGeneralTapCallback, onGeneralTap = onGeneralTapCallback,
@ -1704,7 +1716,8 @@ internal fun PaginatedReaderContent(
is HeaderBlock -> { is HeaderBlock -> {
val style = textStyle.copy( val style = textStyle.copy(
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold,
textAlign = block.textAlign ?: textStyle.textAlign
) )
val searchHighlighted = highlightQueryInText( val searchHighlighted = highlightQueryInText(
block.content, block.content,
@ -1780,6 +1793,9 @@ internal fun PaginatedReaderContent(
} }
is QuoteBlock -> { is QuoteBlock -> {
val quoteStyle = textStyle.copy(
textAlign = block.textAlign ?: textStyle.textAlign
)
val quoteModifier = val quoteModifier =
paddingModifier.padding(start = 16.dp) paddingModifier.padding(start = 16.dp)
val searchHighlighted = highlightQueryInText( val searchHighlighted = highlightQueryInText(
@ -1827,7 +1843,7 @@ internal fun PaginatedReaderContent(
} }
TextWithEmphasis( TextWithEmphasis(
text = finalContent, text = finalContent,
style = textStyle, style = quoteStyle,
modifier = quoteModifier, modifier = quoteModifier,
textMeasurer = textMeasurer, textMeasurer = textMeasurer,
onLinkClick = onLinkClickCallback, onLinkClick = onLinkClickCallback,
@ -2112,22 +2128,39 @@ internal fun PaginatedReaderContent(
containerWidthPx, containerWidthPx,
localDensity localDensity
) )
val imageModifier = val heightPx = parseSvgDimension(
if (widthPx != null) { block.svgHeight,
val finalWidthDp = fontSizePx,
with(localDensity) { containerWidthPx,
widthPx.toDp() localDensity
} )
Timber.d(
"Applying calculated width to MathBlock image: $finalWidthDp" var imageModifier: Modifier = Modifier
) if (widthPx != null) {
Modifier.width(finalWidthDp) 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 { } else {
Timber.w( val fallbackHeightDp = with(localDensity) { (textStyle.fontSize.value * 3).sp.toDp() }
"Could not calculate a specific width for MathBlock. It will fill available space." imageModifier = imageModifier.height(fallbackHeightDp)
)
Modifier
} }
}
val imageRequest = val imageRequest =
Builder(LocalContext.current).data( Builder(LocalContext.current).data(
@ -2186,6 +2219,15 @@ internal fun PaginatedReaderContent(
) )
else Modifier 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) .then(paddingModifier)
val colorFilter = val colorFilter =
@ -2450,6 +2492,15 @@ internal fun PaginatedReaderContent(
} }
is ImageBlock -> { 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( AsyncImage(
model = Builder( model = Builder(
LocalContext.current LocalContext.current
@ -2460,12 +2511,9 @@ internal fun PaginatedReaderContent(
).build(), ).build(),
contentDescription = blockInCell.altText, contentDescription = blockInCell.altText,
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxWidth() modifier = imageModifier
) )
} }
// Catch-all for any
// other text-based
// content
is TextContentBlock -> { is TextContentBlock -> {
Text( Text(
text = blockInCell.content, text = blockInCell.content,
@ -3178,6 +3226,15 @@ private fun RenderFlexChildBlock(
if (style.maxWidth != Dp.Unspecified) Modifier.widthIn(max = style.maxWidth) if (style.maxWidth != Dp.Unspecified) Modifier.widthIn(max = style.maxWidth)
else Modifier 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 colorFilter = if (childBlock.style.filter == "invert(100%)") {
val matrix = floatArrayOf( val matrix = floatArrayOf(
@ -3295,6 +3352,15 @@ private fun RenderFlexChildBlock(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
} else if (blockInCell is ImageBlock) { } 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( AsyncImage(
model = Builder(LocalContext.current).data( model = Builder(LocalContext.current).data(
File( File(
@ -3303,7 +3369,7 @@ private fun RenderFlexChildBlock(
).build(), ).build(),
contentDescription = blockInCell.altText, contentDescription = blockInCell.altText,
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxWidth() modifier = imageModifier
) )
} }
} }

View file

@ -139,6 +139,7 @@ sealed interface ContentBlock {
val elementId: String? val elementId: String?
val cfi: String? val cfi: String?
val blockIndex: Int val blockIndex: Int
val expectedHeight: Int
} }
sealed interface TextContentBlock : ContentBlock { sealed interface TextContentBlock : ContentBlock {
@ -156,7 +157,8 @@ data class ParagraphBlock(
@ProtoNumber(5) override val cfi: String? = null, @ProtoNumber(5) override val cfi: String? = null,
@ProtoNumber(6) override val startCharOffsetInSource: Int = 0, @ProtoNumber(6) override val startCharOffsetInSource: Int = 0,
@ProtoNumber(7) override val endCharOffsetInSource: Int = -1, @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 ) : TextContentBlock
@Serializable @Serializable
@ -169,7 +171,8 @@ data class ImageBlock(
@ProtoNumber(6) override val elementId: String? = null, @ProtoNumber(6) override val elementId: String? = null,
@ProtoNumber(7) override val cfi: String? = null, @ProtoNumber(7) override val cfi: String? = null,
@ProtoNumber(8) val invertOnDarkTheme: Boolean = false, @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 ) : ContentBlock
@Serializable @Serializable
@ -183,6 +186,7 @@ data class HeaderBlock(
@ProtoNumber(7) override val startCharOffsetInSource: Int = 0, @ProtoNumber(7) override val startCharOffsetInSource: Int = 0,
@ProtoNumber(8) override val endCharOffsetInSource: Int = -1, @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 ) : TextContentBlock
@Serializable @Serializable
@ -191,7 +195,8 @@ data class SpacerBlock(
@ProtoNumber(2) override val style: BlockStyle = BlockStyle(), @ProtoNumber(2) override val style: BlockStyle = BlockStyle(),
@ProtoNumber(3) override val elementId: String? = null, @ProtoNumber(3) override val elementId: String? = null,
@ProtoNumber(4) override val cfi: 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 ) : ContentBlock
@Serializable @Serializable
@ -203,7 +208,8 @@ data class QuoteBlock(
@ProtoNumber(5) override val cfi: String? = null, @ProtoNumber(5) override val cfi: String? = null,
@ProtoNumber(6) override val startCharOffsetInSource: Int = 0, @ProtoNumber(6) override val startCharOffsetInSource: Int = 0,
@ProtoNumber(7) override val endCharOffsetInSource: Int = -1, @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 ) : TextContentBlock
@Serializable @Serializable
@ -216,7 +222,8 @@ data class ListItemBlock(
@ProtoNumber(6) override val cfi: String? = null, @ProtoNumber(6) override val cfi: String? = null,
@ProtoNumber(7) override val startCharOffsetInSource: Int = 0, @ProtoNumber(7) override val startCharOffsetInSource: Int = 0,
@ProtoNumber(8) override val endCharOffsetInSource: Int = -1, @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 ) : TextContentBlock
@Serializable @Serializable
@ -233,7 +240,8 @@ data class TableBlock(
@ProtoNumber(2) override val style: BlockStyle = BlockStyle(), @ProtoNumber(2) override val style: BlockStyle = BlockStyle(),
@ProtoNumber(3) override val elementId: String? = null, @ProtoNumber(3) override val elementId: String? = null,
@ProtoNumber(4) override val cfi: 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 ) : ContentBlock
@Serializable @Serializable
@ -247,7 +255,8 @@ data class MathBlock(
@ProtoNumber(7) val svgHeight: String? = null, @ProtoNumber(7) val svgHeight: String? = null,
@ProtoNumber(8) val svgViewBox: String? = null, @ProtoNumber(8) val svgViewBox: String? = null,
@ProtoNumber(9) val isFromMathJax: Boolean = false, @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 ) : ContentBlock
@Serializable @Serializable
@ -257,7 +266,8 @@ data class WrappingContentBlock(
@ProtoNumber(3) override val style: BlockStyle = BlockStyle(), @ProtoNumber(3) override val style: BlockStyle = BlockStyle(),
@ProtoNumber(4) override val elementId: String? = null, @ProtoNumber(4) override val elementId: String? = null,
@ProtoNumber(5) override val cfi: 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 ) : ContentBlock
@Serializable @Serializable
@ -332,7 +342,8 @@ data class FlexContainerBlock(
@ProtoNumber(2) override val style: BlockStyle = BlockStyle(), @ProtoNumber(2) override val style: BlockStyle = BlockStyle(),
@ProtoNumber(3) override val elementId: String? = null, @ProtoNumber(3) override val elementId: String? = null,
@ProtoNumber(4) override val cfi: 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 ) : ContentBlock
@Serializable @Serializable

View file

@ -35,6 +35,7 @@ import androidx.compose.ui.unit.isSpecified
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlin.math.roundToInt
interface BlockMeasurementProvider { interface BlockMeasurementProvider {
suspend fun measure(block: ContentBlock): Int suspend fun measure(block: ContentBlock): Int
@ -286,14 +287,13 @@ class SuspendingAndroidBlockMeasurementProvider(
var currentHeight = 0 var currentHeight = 0
var splitRowIndex = -1 var splitRowIndex = -1
// Account for top and bottom decorations (padding + border)
val decorationTop = with(density) { val decorationTop = with(density) {
block.style.padding.top.toPx() + (block.style.border?.width?.toPx() ?: 0f) block.style.padding.top.toPx() + (block.style.border?.width?.toPx() ?: 0f)
}.toInt() }.roundToInt()
val decorationBottom = with(density) { val decorationBottom = with(density) {
block.style.padding.bottom.toPx() + (block.style.border?.width?.toPx() ?: 0f) 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") Timber.tag("PAGINATION_DEBUG").d("SplitTable: avail=$availableHeight, topDec=$decorationTop, botDec=$decorationBottom")
currentHeight += decorationTop currentHeight += decorationTop
@ -304,8 +304,8 @@ class SuspendingAndroidBlockMeasurementProvider(
val totalColspan = row.sumOf { it.colspan }.toFloat().coerceAtLeast(1f) val totalColspan = row.sumOf { it.colspan }.toFloat().coerceAtLeast(1f)
row.forEach { cell -> row.forEach { cell ->
val cellMaxWidth = ((constraints.maxWidth) * (cell.colspan.toFloat() / totalColspan)).toInt() val cellMaxWidth = ((constraints.maxWidth) * (cell.colspan.toFloat() / totalColspan)).roundToInt()
constraints.copy(maxWidth = cellMaxWidth.coerceAtLeast(0)) @Suppress("UnusedVariable", "Unused") val cellConstraints = constraints.copy(maxWidth = cellMaxWidth.coerceAtLeast(0))
var cellHeight = 0 var cellHeight = 0
cell.content.forEach { b -> cell.content.forEach { b ->
@ -314,11 +314,10 @@ class SuspendingAndroidBlockMeasurementProvider(
val cellDecoration = with(density) { val cellDecoration = with(density) {
cell.style.blockStyle.padding.top.toPx() + cell.style.blockStyle.padding.bottom.toPx() + cell.style.blockStyle.padding.top.toPx() + cell.style.blockStyle.padding.bottom.toPx() +
(cell.style.blockStyle.border?.width?.toPx() ?: 0f) * 2 (cell.style.blockStyle.border?.width?.toPx() ?: 0f) * 2
}.toInt() }.roundToInt()
maxRowHeight = maxOf(maxRowHeight, cellHeight + cellDecoration) maxRowHeight = maxOf(maxRowHeight, cellHeight + cellDecoration)
} }
// CHECK: Must account for decorationBottom here
if (currentHeight + maxRowHeight + decorationBottom > availableHeight) { if (currentHeight + maxRowHeight + decorationBottom > availableHeight) {
Timber.tag("PAGINATION_DEBUG").d("SplitTable: Breaking at row $i. currentH=$currentHeight, rowH=$maxRowHeight") Timber.tag("PAGINATION_DEBUG").d("SplitTable: Breaking at row $i. currentH=$currentHeight, rowH=$maxRowHeight")
splitRowIndex = i splitRowIndex = i
@ -327,7 +326,7 @@ class SuspendingAndroidBlockMeasurementProvider(
currentHeight += maxRowHeight 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 part1Rows = block.rows.subList(0, splitRowIndex)
val part2Rows = block.rows.subList(splitRowIndex, block.rows.size) val part2Rows = block.rows.subList(splitRowIndex, block.rows.size)
@ -346,11 +345,11 @@ class SuspendingAndroidBlockMeasurementProvider(
val decorationTop = with(density) { val decorationTop = with(density) {
block.style.padding.top.toPx() + (block.style.border?.width?.toPx() ?: 0f) block.style.padding.top.toPx() + (block.style.border?.width?.toPx() ?: 0f)
}.toInt() }.roundToInt()
val decorationBottom = with(density) { val decorationBottom = with(density) {
block.style.padding.bottom.toPx() + (block.style.border?.width?.toPx() ?: 0f) block.style.padding.bottom.toPx() + (block.style.border?.width?.toPx() ?: 0f)
}.toInt() }.roundToInt()
currentHeight += decorationTop currentHeight += decorationTop
@ -363,9 +362,8 @@ class SuspendingAndroidBlockMeasurementProvider(
val currMargin = child.style.margin.top.toPx() val currMargin = child.style.margin.top.toPx()
maxOf(prevMargin, currMargin) maxOf(prevMargin, currMargin)
} else child.style.margin.top.toPx() } else child.style.margin.top.toPx()
}.toInt() }.roundToInt()
// CHECK: Must account for decorationBottom here
if (currentHeight + childHeight + margin + decorationBottom > availableHeight) { if (currentHeight + childHeight + margin + decorationBottom > availableHeight) {
splitChildIndex = i splitChildIndex = i
break break
@ -400,6 +398,22 @@ private fun copyBlockWithNewStyle(block: ContentBlock, newStyle: BlockStyle): Co
} }
} }
@Suppress("UNCHECKED_CAST")
private fun <T : ContentBlock> 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) @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
suspend fun paginate( suspend fun paginate(
blocks: List<ContentBlock>, blocks: List<ContentBlock>,
@ -417,10 +431,11 @@ suspend fun paginate(
var remainingHeight = pageHeight var remainingHeight = pageHeight
val remainingBlocks = blocks.toMutableList() val remainingBlocks = blocks.toMutableList()
var pageIndex = 0 var pageIndex = 0
val safetyMarginPerBlock = 2 val safetyMarginPerBlock = 0
while (remainingBlocks.isNotEmpty()) { while (remainingBlocks.isNotEmpty()) {
val block = remainingBlocks.removeAt(0) val block = remainingBlocks.removeAt(0)
val blockHeight = measurementProvider.measure(block) val blockHeight = measurementProvider.measure(block)
val blockHeightWithSafetyMargin = blockHeight + safetyMarginPerBlock val blockHeightWithSafetyMargin = blockHeight + safetyMarginPerBlock
@ -432,11 +447,12 @@ suspend fun paginate(
} else { } else {
block.style.margin.top.toPx() 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) { if (spaceRequired <= remainingHeight) {
var blockToAdd = block var blockToAdd = block
@ -444,76 +460,170 @@ suspend fun paginate(
if (currentPageContent.isNotEmpty()) { if (currentPageContent.isNotEmpty()) {
val prevBlock = currentPageContent.last() val prevBlock = currentPageContent.last()
val newPrevStyle = prevBlock.style.copy(margin = prevBlock.style.margin.copy(bottom = 0.dp)) val newPrevStyle =
val newPrevBlock = copyBlockWithNewStyle(prevBlock, 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 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 = copyBlockWithNewStyle(block, newCurrentStyle)
blockToAdd = setBlockExpectedHeight(blockToAdd, spaceRequired)
currentPageContent.add(blockToAdd) currentPageContent.add(blockToAdd)
remainingHeight -= spaceRequired remainingHeight -= spaceRequired
} else { } else {
var wasSplit = false var wasSplit = false
val heightForSplitting = remainingHeight - spaceBetweenBlocks.toInt() val heightForSplitting = remainingHeight - spaceBetweenBlocks
if (heightForSplitting > 50) { if (heightForSplitting > 50) {
when (block) { when (block) {
is ParagraphBlock -> { is ParagraphBlock -> {
if (!block.style.pageBreakInsideAvoid) { if (!block.style.pageBreakInsideAvoid) {
measurementProvider.split(block, heightForSplitting)?.let { (part1, part2) -> measurementProvider.split(block, heightForSplitting)
if (part1.content.isNotEmpty()) { ?.let { (part1, part2) ->
val collapsedMarginDp = with(density) { spaceBetweenBlocks.toDp() } 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()) { if (currentPageContent.isNotEmpty()) {
val prevBlock = currentPageContent.last() val prevBlock = currentPageContent.last()
val newPrevStyle = prevBlock.style.copy(margin = prevBlock.style.margin.copy(bottom = 0.dp)) val newPrevStyle = prevBlock.style.copy(
currentPageContent[currentPageContent.size - 1] = copyBlockWithNewStyle(prevBlock, newPrevStyle) 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 newPart1Style =
val finalPart1 = part1.copy(style = 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) currentPageContent.add(finalPart1)
if (part2.content.isNotEmpty()) remainingBlocks.add(0, part2) if (part2.isNotEmpty()) {
remainingBlocks.addAll(0, part2)
}
wasSplit = true wasSplit = true
} }
} }
}
} }
is WrappingContentBlock -> {
measurementProvider.split(block, heightForSplitting)?.let { (part1, part2) -> is TableBlock -> {
if (part1.paragraphsToWrap.any { it.content.isNotBlank() }) { measurementProvider.split(block, heightForSplitting)
?.let { (part1, part2) ->
val collapsedMarginDp = with(density) { spaceBetweenBlocks.toDp() } val collapsedMarginDp = with(density) { spaceBetweenBlocks.toDp() }
if (currentPageContent.isNotEmpty()) { if (currentPageContent.isNotEmpty()) {
val prevBlock = currentPageContent.last() val prevBlock = currentPageContent.last()
val newPrevStyle = prevBlock.style.copy(margin = prevBlock.style.margin.copy(bottom = 0.dp)) val newPrevStyle = prevBlock.style.copy(
currentPageContent[currentPageContent.size - 1] = copyBlockWithNewStyle(prevBlock, newPrevStyle) 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) currentPageContent.add(finalPart1)
if (part2.isNotEmpty()) { remainingBlocks.add(0, part2)
remainingBlocks.addAll(0, part2)
}
wasSplit = true 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 -> { is FlexContainerBlock -> {
measurementProvider.split(block, heightForSplitting)?.let { (part1, part2) -> measurementProvider.split(block, heightForSplitting)
val collapsedMarginDp = with(density) { spaceBetweenBlocks.toDp() } ?.let { (part1, part2) ->
currentPageContent.add(copyBlockWithNewStyle(part1, part1.style.copy(margin = part1.style.margin.copy(top = collapsedMarginDp)))) val collapsedMarginDp = with(density) { spaceBetweenBlocks.toDp() }
remainingBlocks.add(0, part2) if (currentPageContent.isNotEmpty()) {
wasSplit = true 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 { } else {
Timber.d("Page ${pageIndex + 1}: Not enough height for splitting ($heightForSplitting <= 50).") Timber.d("Page ${pageIndex + 1}: Not enough height for splitting ($heightForSplitting <= 50).")
@ -521,14 +631,20 @@ suspend fun paginate(
if (!wasSplit) { if (!wasSplit) {
if (currentPageContent.isEmpty()) { 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)") Timber.tag("PAGINATION_DEBUG")
currentPageContent.add(block) .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 { } 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) remainingBlocks.add(0, block)
} }
} }
zeroOutBottomMargin(currentPageContent)
pages.add(Page(content = currentPageContent.toList())) pages.add(Page(content = currentPageContent.toList()))
pageIndex++ pageIndex++
currentPageContent = mutableListOf() currentPageContent = mutableListOf()
@ -536,6 +652,8 @@ suspend fun paginate(
} }
} }
if (currentPageContent.isNotEmpty()) { if (currentPageContent.isNotEmpty()) {
zeroOutBottomMargin(currentPageContent)
pages.add(Page(content = currentPageContent.toList())) pages.add(Page(content = currentPageContent.toList()))
} }
@ -569,7 +687,6 @@ private suspend fun measureBlockHeight(
val specifiedWidthDp = block.style.width val specifiedWidthDp = block.style.width
val specifiedMaxWidthDp = block.style.maxWidth val specifiedMaxWidthDp = block.style.maxWidth
// 1. Determine the block's final outer width.
val blockOuterWidthPx = with(density) { val blockOuterWidthPx = with(density) {
var effectiveWidthPx = constraints.maxWidth.toFloat() var effectiveWidthPx = constraints.maxWidth.toFloat()
if (specifiedWidthDp != Dp.Unspecified) { if (specifiedWidthDp != Dp.Unspecified) {
@ -584,18 +701,18 @@ private suspend fun measureBlockHeight(
effectiveWidthPx.coerceAtMost(constraints.maxWidth.toFloat()) effectiveWidthPx.coerceAtMost(constraints.maxWidth.toFloat())
} }
// 2. Determine the width available for the content itself. val contentMaxWidth = if (specifiedWidthDp == Dp.Unspecified) {
val contentMaxWidth = if (isBorderBox) { (blockOuterWidthPx - horizontalPaddingPx - horizontalBorderPx)
} else if (isBorderBox) {
(blockOuterWidthPx - horizontalPaddingPx - horizontalBorderPx) (blockOuterWidthPx - horizontalPaddingPx - horizontalBorderPx)
} else { } else {
blockOuterWidthPx blockOuterWidthPx
} }
val adjustedConstraints = constraints.copy( 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) { val contentHeight = when (block) {
is ParagraphBlock -> { is ParagraphBlock -> {
val height = withContext(Dispatchers.Main) { val height = withContext(Dispatchers.Main) {
@ -634,20 +751,19 @@ private suspend fun measureBlockHeight(
contentMaxWidth contentMaxWidth
} }
val height = (imageRenderWidthPx * aspectRatio).toInt() val height = (imageRenderWidthPx * aspectRatio).roundToInt()
height height
} else { } else {
Timber.w("Image at '${block.path}' has no valid intrinsic dimensions, falling back to fixed height.") Timber.w("Image at '${block.path}' has no valid intrinsic dimensions, falling back to fixed height.")
if (block.style.height != Dp.Unspecified) { if (block.style.height != Dp.Unspecified) {
with(density) { block.style.height.toPx() }.toInt() with(density) { block.style.height.toPx().roundToInt() }
} else { } else {
with(density) { 250.dp.toPx() }.toInt() with(density) { 250.dp.toPx().roundToInt() }
} }
} }
} }
is SpacerBlock -> { is SpacerBlock -> {
// A spacer's height property defines its content height. val height = with(density) { block.height.toPx().roundToInt() }
val height = with(density) { block.height.toPx() }.toInt()
height height
} }
is QuoteBlock -> { is QuoteBlock -> {
@ -673,7 +789,7 @@ private suspend fun measureBlockHeight(
).size.height ).size.height
} }
val markerImageHeight = if (block.itemMarkerImage != null) { 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 { } else {
0 0
} }
@ -689,8 +805,8 @@ private suspend fun measureBlockHeight(
row.forEach { cell -> row.forEach { cell ->
val cellBlockStyle = cell.style.blockStyle val cellBlockStyle = cell.style.blockStyle
val cellMaxWidth = when { val cellMaxWidth = when {
cellBlockStyle.width.isSpecified -> with(density) { cellBlockStyle.width.toPx() }.toInt() cellBlockStyle.width.isSpecified -> with(density) { cellBlockStyle.width.toPx().roundToInt() }
else -> (adjustedConstraints.maxWidth * (cell.colspan.toFloat() / totalColspan)).toInt() else -> (adjustedConstraints.maxWidth * (cell.colspan.toFloat() / totalColspan)).roundToInt()
} }
val cellConstraints = adjustedConstraints.copy(maxWidth = cellMaxWidth.coerceAtLeast(0)) 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() cellDecorationHeight = cellBlockStyle.padding.top.toPx() + cellBlockStyle.padding.bottom.toPx()
cellBlockStyle.border?.let { cellDecorationHeight += it.width.toPx() * 2 } cellBlockStyle.border?.let { cellDecorationHeight += it.width.toPx() * 2 }
} }
maxRowHeight = maxOf(maxRowHeight, (cellContentHeight + cellDecorationHeight).toInt()) maxRowHeight = maxOf(maxRowHeight, (cellContentHeight + cellDecorationHeight).roundToInt())
} }
totalHeight += maxRowHeight totalHeight += maxRowHeight
} }
@ -820,13 +936,12 @@ private suspend fun measureBlockHeight(
textOffset += firstLineEndOffset textOffset += firstLineEndOffset
// Skip leading whitespace for the next iteration.
while (textOffset < fullText.length && fullText[textOffset].isWhitespace()) { while (textOffset < fullText.length && fullText[textOffset].isWhitespace()) {
textOffset++ textOffset++
} }
} }
val height = maxOf(currentY, imageHeightPx).toInt() val height = maxOf(currentY, imageHeightPx).roundToInt()
height height
} }
is FlexContainerBlock -> { is FlexContainerBlock -> {
@ -848,36 +963,26 @@ private suspend fun measureBlockHeight(
val heightPx = parseSvgDimension(block.svgHeight, fontSizePx, containerWidthPx, density) val heightPx = parseSvgDimension(block.svgHeight, fontSizePx, containerWidthPx, density)
val finalHeight = if (heightPx != null) { val finalHeight = if (heightPx != null) {
Timber.d("Paginator measuring MathBlock '${block.elementId}': Using explicit height '${block.svgHeight}' -> ${heightPx.toInt()}px") heightPx.roundToInt()
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
}
} else { } else {
val fallbackHeight = with(density) { (defaultStyle.fontSize.value * 3).sp.toPx() }.toInt() val viewBoxParts = block.svgViewBox?.split(' ', ',')?.mapNotNull { it.toFloatOrNull() }
Timber.w("Paginator measuring MathBlock '${block.elementId}': No usable dimensions (width='${block.svgWidth}', height='${block.svgHeight}'). Using fallback height ${fallbackHeight}px") if (viewBoxParts != null && viewBoxParts.size == 4 && viewBoxParts[2] > 0) {
fallbackHeight 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 finalHeight
} }
} }
// 4. Calculate the final total height based on box-sizing.
val specifiedHeightDp = block.style.height val specifiedHeightDp = block.style.height
val finalHeight = if (isBorderBox && specifiedHeightDp != Dp.Unspecified) { val finalHeight = if (isBorderBox && specifiedHeightDp != Dp.Unspecified) {
with(density) { specifiedHeightDp.toPx() }.toInt() with(density) { specifiedHeightDp.toPx().roundToInt() }
} else { } 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") 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) { val decorationTop = with(density) {
block.style.padding.top.toPx() + (block.style.border?.width?.toPx() ?: 0f) block.style.padding.top.toPx() + (block.style.border?.width?.toPx() ?: 0f)
} }.roundToInt()
val decorationBottom = with(density) { val decorationBottom = with(density) {
block.style.padding.bottom.toPx() + (block.style.border?.width?.toPx() ?: 0f) block.style.padding.bottom.toPx() + (block.style.border?.width?.toPx() ?: 0f)
} }.roundToInt()
val availableTextHeight = availableHeight - decorationTop - decorationBottom val availableTextHeight = availableHeight - decorationTop - decorationBottom
@ -928,13 +1033,13 @@ private suspend fun splitParagraphBlock(
return null return null
} }
var lastVisibleLine = layoutResult.getLineForVerticalPosition(availableTextHeight) var lastVisibleLine = layoutResult.getLineForVerticalPosition(availableTextHeight.toFloat())
if (layoutResult.getLineBottom(lastVisibleLine) > availableHeight.toFloat()) { if (layoutResult.getLineBottom(lastVisibleLine) > availableHeight.toFloat()) {
lastVisibleLine-- lastVisibleLine--
} }
if (lastVisibleLine < 0) { // Safety check after decrementing if (lastVisibleLine < 0) {
return null return null
} }
@ -1031,7 +1136,7 @@ internal fun parseSvgDimension(
dimension.endsWith("ex") -> dimension.removeSuffix("ex").toFloatOrNull()?.let { it * 0.5f * fontSizePx } dimension.endsWith("ex") -> dimension.removeSuffix("ex").toFloatOrNull()?.let { it * 0.5f * fontSizePx }
dimension.endsWith("em") -> dimension.removeSuffix("em").toFloatOrNull()?.let { it * fontSizePx } dimension.endsWith("em") -> dimension.removeSuffix("em").toFloatOrNull()?.let { it * fontSizePx }
dimension.endsWith("px") -> dimension.removeSuffix("px").toFloatOrNull() 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 } dimension.endsWith("%") -> dimension.removeSuffix("%").toFloatOrNull()?.let { (it / 100f) * containerWidthPx }
else -> dimension.toFloatOrNull() else -> dimension.toFloatOrNull()
} }
@ -1056,12 +1161,22 @@ private suspend fun calculateContentHeightWithMargins(
} else { } else {
child.style.margin.top.toPx() child.style.margin.top.toPx()
} }
} }.roundToInt()
totalHeight += (childHeight + margin).toInt() totalHeight += (childHeight + margin)
Timber.tag("PAGINATION_DEBUG").v(" Internal Child ${child::class.simpleName}: h=$childHeight, margin=$margin, runningTotal=$totalHeight") Timber.tag("PAGINATION_DEBUG").v(" Internal Child ${child::class.simpleName}: h=$childHeight, margin=$margin, runningTotal=$totalHeight")
} }
if (children.isNotEmpty()) { 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 return totalHeight
}
private fun zeroOutBottomMargin(blocks: MutableList<ContentBlock>) {
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
}
} }