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