feat(epub): overhaul border system to support individual sides and radii (#44)

- Refactored `BlockStyle` to replace unified border/radius fields with explicit properties for all four sides and corners.
- Overhauled `CssParser` to correctly handle CSS border shorthands (`border-bottom`, `border-width`, etc.) and multi-value `border-radius`.
- Implemented `drawCssBorders` custom modifier to render side-specific borders, styles (dashed/dotted), and background clipping.
- Updated `Paginator` measurement logic to calculate block height and splitting points based on individual side widths.
This commit is contained in:
Aryan 2026-03-08 10:31:07 +05:30 committed by GitHub
parent 68c4611187
commit dff236fa7f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 434 additions and 206 deletions

View file

@ -221,11 +221,20 @@ class ContentStyler(
} else {
original.backgroundColor
}
val newBorder = original.border?.let {
val newBorderColor = CssParser.adaptColorForTheme(it.color, isDarkTheme, isBackground = false)
it.copy(color = newBorderColor)
fun themeBorder(b: BorderStyle?): BorderStyle? {
if (b == null) return null
val newColor = CssParser.adaptColorForTheme(b.color, isDarkTheme, isBackground = false)
return b.copy(color = newColor)
}
original.copy(backgroundColor = newBgColor, border = newBorder)
original.copy(
backgroundColor = newBgColor,
borderTop = themeBorder(original.borderTop),
borderRight = themeBorder(original.borderRight),
borderBottom = themeBorder(original.borderBottom),
borderLeft = themeBorder(original.borderLeft)
)
}
return style.copy(spanStyle = newSpanStyle, blockStyle = newBlockStyle)

View file

@ -385,6 +385,26 @@ object CssParser {
var marginBottomStr: String? = null
var marginLeftStr: String? = null
var borderTopWidth: Dp? = null
var borderRightWidth: Dp? = null
var borderBottomWidth: Dp? = null
var borderLeftWidth: Dp? = null
var borderTopStyle: String? = null
var borderRightStyle: String? = null
var borderBottomStyle: String? = null
var borderLeftStyle: String? = null
var borderTopColor: Color? = null
var borderRightColor: Color? = null
var borderBottomColor: Color? = null
var borderLeftColor: Color? = null
var borderTopLeftRadius: Dp = 0.dp
var borderTopRightRadius: Dp = 0.dp
var borderBottomRightRadius: Dp = 0.dp
var borderBottomLeftRadius: Dp = 0.dp
splitDeclarations(properties).filter { it.isNotBlank() }.forEach { prop ->
val parts = prop.split(':', limit = 2).map { it.trim() }
if (parts.size == 2) {
@ -401,7 +421,6 @@ object CssParser {
valueWithImportant
}
// Helper to update border props ONLY if this border is significant
fun updateUnifiedBorder(
widthStr: String?,
colorStr: String?,
@ -410,12 +429,6 @@ object CssParser {
val parsedWidth = widthStr?.let { parseCssSizeToDp(it, baseFontSizeSp, density, containerWidthPx) } ?: 0.dp
val parsedColor = colorStr?.let { parseColor(it) }?.let { this@CssParser.adaptColorForTheme(it, isDarkTheme, isBackground = false) }
// We update the unified style if:
// 1. We found a width larger than what we've seen (prioritize visible borders)
// 2. Or we haven't seen any width yet and this is the first definition
// 3. Or the specific property is just setting style/color and we want to take the last one defined (standard CSS cascade behavior for same-specificity)
// However, for separate sides (left vs bottom), we strictly prioritize the one with width.
val isExplicitWidth = widthStr != null
if (parsedWidth > maxBorderWidthFound) {
@ -423,19 +436,15 @@ object CssParser {
if (parsedColor != null) finalBorderColor = parsedColor
if (styleStr != null) finalBorderStyle = styleStr
} else if (parsedWidth == maxBorderWidthFound && maxBorderWidthFound > 0.dp) {
// If equal non-zero width, let last defined win (cascade)
if (parsedColor != null) finalBorderColor = parsedColor
if (styleStr != null) finalBorderStyle = styleStr
} else if (!isExplicitWidth) {
// Just updating color or style without width
if (parsedColor != null) finalBorderColor = parsedColor
if (styleStr != null) finalBorderStyle = styleStr
}
}
when (key) {
// ... [Keep existing cases for font-family, font-size, font-weight, font-style, color, text-align, line-height, text-indent, text-decoration, letter-spacing, text-transform, font-variant, margin, margin-*, padding, padding-*, width, max-width, height, background-color] ...
"font-family" -> {
fontFamilies = value.split(',')
.map { it.trim().removeSurrounding("\"").removeSurrounding("'").lowercase() }
@ -581,40 +590,78 @@ object CssParser {
backgroundColor = this@CssParser.adaptColorForTheme(originalColor, isDarkTheme, isBackground = true)
}
// Border Properties - Logic Updated
"border-width" -> updateUnifiedBorder(value, null, null)
"border-color" -> updateUnifiedBorder(null, value, null)
"border-style" -> updateUnifiedBorder(null, null, value)
"border-top-width", "border-bottom-width", "border-left-width", "border-right-width" -> {
updateUnifiedBorder(value, null, null)
// Border Properties
"border-width" -> {
val widths = parseShorthand4(value, baseFontSizeSp, density, containerWidthPx)
borderTopWidth = widths[0]; borderRightWidth = widths[1]; borderBottomWidth = widths[2]; borderLeftWidth = widths[3]
}
"border-top-color", "border-bottom-color", "border-left-color", "border-right-color" -> {
updateUnifiedBorder(null, value, null)
"border-style" -> {
val styles = parseShorthand4Strings(value)
borderTopStyle = styles[0]; borderRightStyle = styles[1]; borderBottomStyle = styles[2]; borderLeftStyle = styles[3]
}
"border-top-style", "border-bottom-style", "border-left-style", "border-right-style" -> {
updateUnifiedBorder(null, null, value)
"border-color" -> {
val colors = parseShorthand4Colors(value)
borderTopColor = colors[0]; borderRightColor = colors[1]; borderBottomColor = colors[2]; borderLeftColor = colors[3]
}
"border-bottom", "border-top", "border-left", "border-right", "border" -> {
val borderParts = value.split(" ").filter { it.isNotBlank() }
var widthVal: String? = null
var colorVal: String? = null
var styleVal: String? = null
"border-top-width" -> borderTopWidth = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx)
"border-right-width" -> borderRightWidth = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx)
"border-bottom-width" -> borderBottomWidth = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx)
"border-left-width" -> borderLeftWidth = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx)
borderParts.forEach { part ->
val parsedWidth = parseCssSizeToDp(part, baseFontSizeSp, density, containerWidthPx)
if (parsedWidth > 0.dp || part == "0" || part == "0px" || BORDER_WIDTH_KEYWORDS.containsKey(part)) {
widthVal = part
} else if (part in listOf("solid", "dotted", "dashed", "double", "groove", "ridge", "inset", "outset")) {
styleVal = part
} else if (parseColor(part) != null) {
colorVal = part
}
}
updateUnifiedBorder(widthVal, colorVal, styleVal)
"border-top-style" -> borderTopStyle = value
"border-right-style" -> borderRightStyle = value
"border-bottom-style" -> borderBottomStyle = value
"border-left-style" -> borderLeftStyle = value
"border-top-color" -> borderTopColor = parseColor(value)
"border-right-color" -> borderRightColor = parseColor(value)
"border-bottom-color" -> borderBottomColor = parseColor(value)
"border-left-color" -> borderLeftColor = parseColor(value)
"border-top" -> {
val (w, s, c) = parseBorderShorthand(value, baseFontSizeSp, density, containerWidthPx)
if (w != null) borderTopWidth = w
if (s != null) borderTopStyle = s
if (c != null) borderTopColor = c
}
// End Border Properties
"border-right" -> {
val (w, s, c) = parseBorderShorthand(value, baseFontSizeSp, density, containerWidthPx)
if (w != null) borderRightWidth = w
if (s != null) borderRightStyle = s
if (c != null) borderRightColor = c
}
"border-bottom" -> {
val (w, s, c) = parseBorderShorthand(value, baseFontSizeSp, density, containerWidthPx)
if (w != null) borderBottomWidth = w
if (s != null) borderBottomStyle = s
if (c != null) borderBottomColor = c
}
"border-left" -> {
val (w, s, c) = parseBorderShorthand(value, baseFontSizeSp, density, containerWidthPx)
if (w != null) borderLeftWidth = w
if (s != null) borderLeftStyle = s
if (c != null) borderLeftColor = c
}
"border" -> {
val (w, s, c) = parseBorderShorthand(value, baseFontSizeSp, density, containerWidthPx)
if (w != null) { borderTopWidth = w; borderRightWidth = w; borderBottomWidth = w; borderLeftWidth = w }
if (s != null) { borderTopStyle = s; borderRightStyle = s; borderBottomStyle = s; borderLeftStyle = s }
if (c != null) { borderTopColor = c; borderRightColor = c; borderBottomColor = c; borderLeftColor = c }
}
"border-radius" -> {
val radii = parseShorthand4(value, baseFontSizeSp, density, containerWidthPx)
borderTopLeftRadius = radii[0]
borderTopRightRadius = radii[1]
borderBottomRightRadius = radii[2]
borderBottomLeftRadius = radii[3]
}
"border-top-left-radius" -> borderTopLeftRadius = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx)
"border-top-right-radius" -> borderTopRightRadius = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx)
"border-bottom-right-radius" -> borderBottomRightRadius = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx)
"border-bottom-left-radius" -> borderBottomLeftRadius = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx)
"border-collapse" -> {
if (value in listOf("collapse", "separate")) {
@ -624,9 +671,6 @@ object CssParser {
"border-spacing" -> {
borderSpacing = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx)
}
"border-radius" -> borderRadius = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx)
// ... [Keep existing cases for list-style-*, page-break-*, display, flex-*, filter, box-sizing, content, position, top/left/etc, float, hyphens, etc] ...
"list-style-type" -> {
listStyleType = value
@ -727,7 +771,6 @@ object CssParser {
null
}
// Updated: Use the maxBorderWidthFound and corresponding colors
val finalBorder = if (maxBorderWidthFound > 0.dp && finalBorderStyle != null) {
val borderColor = finalBorderColor ?: spanStyle.color.takeIf { it.isSpecified } ?: Color.Black
BorderStyle(
@ -737,9 +780,35 @@ object CssParser {
)
} else null
fun makeBorder(width: Dp?, style: String?, color: Color?): BorderStyle? {
val finalWidth = width ?: if (style != null && style != "none" && style != "hidden") 3.dp else 0.dp
val finalStyle = style ?: "none"
val finalColor = color ?: spanStyle.color.takeIf { it.isSpecified } ?: Color.Black
val adaptedColor = this@CssParser.adaptColorForTheme(finalColor, isDarkTheme, isBackground = false)
if (finalWidth > 0.dp && finalStyle != "none" && finalStyle != "hidden") {
return BorderStyle(finalWidth, adaptedColor, finalStyle)
}
return null
}
val finalBorderTop = makeBorder(borderTopWidth, borderTopStyle, borderTopColor)
val finalBorderRight = makeBorder(borderRightWidth, borderRightStyle, borderRightColor)
val finalBorderBottom = makeBorder(borderBottomWidth, borderBottomStyle, borderBottomColor)
val finalBorderLeft = makeBorder(borderLeftWidth, borderLeftStyle, borderLeftColor)
val blockStyle = BlockStyle(
margin = margin, padding = padding, width = width, maxWidth = maxWidth, height = height,
backgroundColor = backgroundColor, border = finalBorder,
backgroundColor = backgroundColor,
borderTop = finalBorderTop,
borderRight = finalBorderRight,
borderBottom = finalBorderBottom,
borderLeft = finalBorderLeft,
borderTopLeftRadius = borderTopLeftRadius,
borderTopRightRadius = borderTopRightRadius,
borderBottomRightRadius = borderBottomRightRadius,
borderBottomLeftRadius = borderBottomLeftRadius,
listStyleType = listStyleType,
listStyleImage = listStyleImage,
pageBreakInsideAvoid = pageBreakInsideAvoid,
@ -759,12 +828,75 @@ object CssParser {
horizontalAlign = finalHorizontalAlign,
filter = filter,
borderCollapse = borderCollapse,
borderSpacing = borderSpacing,
borderRadius = borderRadius
borderSpacing = borderSpacing
)
return CssStyle(spanStyle, paragraphStyle, blockStyle, fontFamilies, display, fontSize, textTransform, boxSizing, content, hyphens, fontVariantNumeric, textEmphasis)
}
private fun parseShorthand4(value: String, baseFontSize: Float, density: Float, containerWidth: Int): List<Dp> {
val parts = value.split(' ').filter { it.isNotBlank() }
val dps = parts.map { parseCssSizeToDp(it, baseFontSize, density, containerWidth) }
return when (dps.size) {
1 -> listOf(dps[0], dps[0], dps[0], dps[0])
2 -> listOf(dps[0], dps[1], dps[0], dps[1]) // Top/Bottom, Left/Right
3 -> listOf(dps[0], dps[1], dps[2], dps[1]) // Top, Left/Right, Bottom
4 -> listOf(dps[0], dps[1], dps[2], dps[3]) // Top, Right, Bottom, Left
else -> listOf(0.dp, 0.dp, 0.dp, 0.dp)
}
}
private fun parseShorthand4Strings(value: String): List<String?> {
val parts = value.split(' ').filter { it.isNotBlank() }
return when (parts.size) {
1 -> listOf(parts[0], parts[0], parts[0], parts[0])
2 -> listOf(parts[0], parts[1], parts[0], parts[1])
3 -> listOf(parts[0], parts[1], parts[2], parts[1])
4 -> listOf(parts[0], parts[1], parts[2], parts[3])
else -> listOf(null, null, null, null)
}
}
private fun parseShorthand4Colors(value: String): List<Color?> {
if (value.contains("(") || value.contains(",")) {
val c = parseColor(value)
return listOf(c, c, c, c)
}
val parts = value.split(' ').filter { it.isNotBlank() }
val colors = parts.map { parseColor(it) }
return when (colors.size) {
1 -> listOf(colors[0], colors[0], colors[0], colors[0])
2 -> listOf(colors[0], colors[1], colors[0], colors[1])
3 -> listOf(colors[0], colors[1], colors[2], colors[1])
4 -> listOf(colors[0], colors[1], colors[2], colors[3])
else -> listOf(null, null, null, null)
}
}
private fun parseBorderShorthand(value: String, baseFontSize: Float, density: Float, containerWidth: Int): Triple<Dp?, String?, Color?> {
val parts = value.split(" ").filter { it.isNotBlank() }
var w: Dp? = null
var s: String? = null
var c: Color? = null
val keywords = mapOf("thin" to 1.dp, "medium" to 3.dp, "thick" to 5.dp)
parts.forEach { part ->
val lower = part.lowercase()
if (keywords.containsKey(lower)) {
w = keywords[lower]
} else if (lower.endsWith("px") || lower.endsWith("em") || lower.endsWith("rem") || lower.endsWith("%") || lower.first().isDigit()) {
val parsed = parseCssSizeToDp(part, baseFontSize, density, containerWidth)
if (parsed > 0.dp || part == "0") w = parsed
} else if (lower in listOf("solid", "dashed", "dotted", "double", "none", "hidden", "groove", "ridge", "inset", "outset")) {
s = lower
} else {
val parsedColor = parseColor(part)
if (parsedColor != null) c = parsedColor
}
}
return Triple(w, s, c)
}
// ADD the parseCssSizeToDp function here at the bottom of the object or file
internal fun parseCssSizeToDp(
size: String,

View file

@ -256,9 +256,15 @@ private class SemanticHtmlParser(
val result = when (val tagName = element.tagName().lowercase()) {
"div", "header", "section", "article", "aside", "main", "footer", "nav", "figure" -> {
val hasBoxStyles = elementStyle.blockStyle.backgroundColor.isSpecified ||
elementStyle.blockStyle.border != null ||
elementStyle.blockStyle.borderTop != null ||
elementStyle.blockStyle.borderRight != null ||
elementStyle.blockStyle.borderBottom != null ||
elementStyle.blockStyle.borderLeft != null ||
elementStyle.blockStyle.padding != BoxBorders() ||
elementStyle.blockStyle.borderRadius > 0.dp
elementStyle.blockStyle.borderTopLeftRadius > 0.dp ||
elementStyle.blockStyle.borderTopRightRadius > 0.dp ||
elementStyle.blockStyle.borderBottomRightRadius > 0.dp ||
elementStyle.blockStyle.borderBottomLeftRadius > 0.dp
if (hasBoxStyles) {
val children = element.children().flatMap { child ->

View file

@ -88,11 +88,11 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.toRect
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
@ -100,6 +100,7 @@ import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.drawscope.clipRect
@ -1588,25 +1589,9 @@ internal fun PaginatedReaderContent(
val styleModifier = alignModifier
.then(if (block.style.horizontalAlign == "center") widthModifier 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
)
} 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
.drawCssBorders(
blockStyle = block.style,
density = density
)
val diagnosticModifier = Modifier
@ -1636,12 +1621,11 @@ internal fun PaginatedReaderContent(
.then(styleModifier)
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
start = block.style.padding.left.coerceAtLeast(0.dp) + (block.style.borderLeft?.width ?: 0.dp),
top = block.style.padding.top.coerceAtLeast(0.dp) + (block.style.borderTop?.width ?: 0.dp),
end = block.style.padding.right.coerceAtLeast(0.dp) + (block.style.borderRight?.width ?: 0.dp),
bottom = block.style.padding.bottom.coerceAtLeast(0.dp) + (block.style.borderBottom?.width ?: 0.dp)
).then(
if (block.style.horizontalAlign != "center") widthModifier else Modifier.fillMaxWidth()
)
@ -2322,61 +2306,16 @@ internal fun PaginatedReaderContent(
}
is SpacerBlock -> {
val border = block.style.border
if (border != null && border.width > 0.dp) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(
border.width
)
.drawBehind {
val strokeWidth =
border.width.toPx()
val pathEffect =
when (border.style) {
"dotted" -> PathEffect.dashPathEffect(
floatArrayOf(
strokeWidth,
strokeWidth * 2f
), 0f
)
"dashed" -> PathEffect.dashPathEffect(
floatArrayOf(
strokeWidth * 3f,
strokeWidth * 2f
), 0f
)
else -> null
}
drawLine(
color = border.color,
start = Offset(
0f, strokeWidth / 2f
),
end = Offset(
size.width,
strokeWidth / 2f
),
strokeWidth = strokeWidth,
pathEffect = pathEffect
)
})
} else {
Spacer(Modifier.height(block.height))
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(block.height)
.drawCssBorders(block.style, density)
)
}
is TableBlock -> {
// Table-level margin/background/border/padding
// are already
// applied by the outer Box wrapper. Only set
// width here.
val tableModifier = paddingModifier
Column(modifier = tableModifier) {
Column(modifier = paddingModifier) {
block.rows.forEach { tableRow ->
Row(
Modifier
@ -2428,14 +2367,7 @@ internal fun PaginatedReaderContent(
Modifier
}
)
.then(cellStyle.border?.let { border ->
Modifier.border(
BorderStroke(
border.width,
border.color
)
)
} ?: Modifier)
.drawCssBorders(cellStyle, density)
.padding(
start = cellStyle.padding.left.coerceAtLeast(
0.dp
@ -2508,23 +2440,11 @@ internal fun PaginatedReaderContent(
}
is SpacerBlock -> {
val spacerModifier =
if (blockInCell.style.border != null) {
Modifier
.fillMaxWidth()
.height(
blockInCell.style.border.width
)
.background(
blockInCell.style.border.color
)
} else {
Modifier.height(
blockInCell.height
)
}
Spacer(
modifier = spacerModifier
modifier = Modifier
.fillMaxWidth()
.height(blockInCell.height)
.drawCssBorders(blockInCell.style, density)
)
}
@ -3311,17 +3231,12 @@ private fun RenderFlexChildBlock(
}
is SpacerBlock -> {
val border = childBlock.style.border
if (border != null && border.width > 0.dp) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(border.width)
.background(border.color)
)
} else {
Spacer(Modifier.height(childBlock.height))
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(childBlock.height)
.drawCssBorders(childBlock.style, density)
)
}
is TableBlock -> {
@ -3353,11 +3268,7 @@ private fun RenderFlexChildBlock(
)
else Modifier
)
.then(cellStyle.border?.let {
Modifier.border(
BorderStroke(it.width, it.color)
)
} ?: Modifier)
.drawCssBorders(cellStyle, density)
.padding(
start = cellStyle.padding.left.coerceAtLeast(
0.dp
@ -3587,3 +3498,160 @@ private fun Modifier.realisticBookPage(
}
}
}
@Suppress("KotlinConstantConditions")
@Composable
fun Modifier.drawCssBorders(
blockStyle: BlockStyle,
@Suppress("unused") density: Density
): Modifier = this.drawBehind {
val topWidth = blockStyle.borderTop?.width?.toPx() ?: 0f
val rightWidth = blockStyle.borderRight?.width?.toPx() ?: 0f
val bottomWidth = blockStyle.borderBottom?.width?.toPx() ?: 0f
val leftWidth = blockStyle.borderLeft?.width?.toPx() ?: 0f
val tlRadius = blockStyle.borderTopLeftRadius.toPx()
val trRadius = blockStyle.borderTopRightRadius.toPx()
val brRadius = blockStyle.borderBottomRightRadius.toPx()
val blRadius = blockStyle.borderBottomLeftRadius.toPx()
if (blockStyle.backgroundColor.isSpecified && blockStyle.backgroundColor != Color.Transparent) {
val bgPath = Path().apply {
addRoundRect(
androidx.compose.ui.geometry.RoundRect(
rect = size.toRect(),
topLeft = androidx.compose.ui.geometry.CornerRadius(tlRadius, tlRadius),
topRight = androidx.compose.ui.geometry.CornerRadius(trRadius, trRadius),
bottomRight = androidx.compose.ui.geometry.CornerRadius(brRadius, brRadius),
bottomLeft = androidx.compose.ui.geometry.CornerRadius(blRadius, blRadius)
)
)
}
drawPath(bgPath, color = blockStyle.backgroundColor, style = Fill)
}
// 2. Helper for PathEffects
fun getPathEffect(style: String?, width: Float): PathEffect? {
return when (style) {
"dashed" -> PathEffect.dashPathEffect(floatArrayOf(width * 3f, width * 2f), 0f)
"dotted" -> PathEffect.dashPathEffect(floatArrayOf(width, width), 0f)
else -> null
}
}
// TOP
if (topWidth > 0f && blockStyle.borderTop != null) {
val color = blockStyle.borderTop.color
val effect = getPathEffect(blockStyle.borderTop.style, topWidth)
val offset = topWidth / 2f
val startX = if (tlRadius > 0) tlRadius else 0f
val endX = if (trRadius > 0) size.width - trRadius else size.width
drawLine(
color = color,
start = Offset(startX, offset),
end = Offset(endX, offset),
strokeWidth = topWidth,
pathEffect = effect
)
}
// BOTTOM
if (bottomWidth > 0f && blockStyle.borderBottom != null) {
val color = blockStyle.borderBottom.color
val effect = getPathEffect(blockStyle.borderBottom.style, bottomWidth)
val offset = size.height - (bottomWidth / 2f)
val startX = if (blRadius > 0) blRadius else 0f
val endX = if (brRadius > 0) size.width - brRadius else size.width
drawLine(
color = color,
start = Offset(startX, offset),
end = Offset(endX, offset),
strokeWidth = bottomWidth,
pathEffect = effect
)
}
// LEFT
if (leftWidth > 0f && blockStyle.borderLeft != null) {
val color = blockStyle.borderLeft.color
val effect = getPathEffect(blockStyle.borderLeft.style, leftWidth)
val offset = leftWidth / 2f
val startY = if (tlRadius > 0) tlRadius else 0f
val endY = if (blRadius > 0) size.height - blRadius else size.height
drawLine(
color = color,
start = Offset(offset, startY),
end = Offset(offset, endY),
strokeWidth = leftWidth,
pathEffect = effect
)
}
// RIGHT
if (rightWidth > 0f && blockStyle.borderRight != null) {
val color = blockStyle.borderRight.color
val effect = getPathEffect(blockStyle.borderRight.style, rightWidth)
val offset = size.width - (rightWidth / 2f)
val startY = if (trRadius > 0) trRadius else 0f
val endY = if (brRadius > 0) size.height - brRadius else size.height
drawLine(
color = color,
start = Offset(offset, startY),
end = Offset(offset, endY),
strokeWidth = rightWidth,
pathEffect = effect
)
}
if (tlRadius > 0f && topWidth > 0f && leftWidth > 0f && blockStyle.borderTop != null) {
drawArc(
color = blockStyle.borderTop.color,
startAngle = 180f, sweepAngle = 90f,
useCenter = false,
topLeft = Offset(leftWidth/2f, topWidth/2f),
size = androidx.compose.ui.geometry.Size(tlRadius * 2 - leftWidth, tlRadius * 2 - topWidth),
style = Stroke(width = topWidth)
)
}
if (trRadius > 0f && topWidth > 0f && rightWidth > 0f && blockStyle.borderTop != null) {
drawArc(
color = blockStyle.borderTop.color,
startAngle = 270f, sweepAngle = 90f,
useCenter = false,
topLeft = Offset(size.width - (trRadius * 2) + (rightWidth/2f), topWidth/2f),
size = androidx.compose.ui.geometry.Size(trRadius * 2 - rightWidth, trRadius * 2 - topWidth),
style = Stroke(width = topWidth)
)
}
if (brRadius > 0f && bottomWidth > 0f && rightWidth > 0f && blockStyle.borderBottom != null) {
drawArc(
color = blockStyle.borderBottom.color,
startAngle = 0f, sweepAngle = 90f,
useCenter = false,
topLeft = Offset(size.width - (brRadius * 2) + (rightWidth/2f), size.height - (brRadius * 2) + (bottomWidth/2f)),
size = androidx.compose.ui.geometry.Size(brRadius * 2 - rightWidth, brRadius * 2 - bottomWidth),
style = Stroke(width = bottomWidth)
)
}
if (blRadius > 0f && bottomWidth > 0f && leftWidth > 0f && blockStyle.borderBottom != null) {
drawArc(
color = blockStyle.borderBottom.color,
startAngle = 90f, sweepAngle = 90f,
useCenter = false,
topLeft = Offset(leftWidth/2f, size.height - (blRadius * 2) + (bottomWidth/2f)),
size = androidx.compose.ui.geometry.Size(blRadius * 2 - leftWidth, blRadius * 2 - bottomWidth),
style = Stroke(width = bottomWidth)
)
}
}

View file

@ -51,28 +51,34 @@ data class BlockStyle(
@ProtoNumber(4) @Serializable(with = DpSerializer::class) val maxWidth: Dp = Dp.Unspecified,
@ProtoNumber(5) @Serializable(with = DpSerializer::class) val height: Dp = Dp.Unspecified,
@ProtoNumber(6) @Serializable(with = ColorSerializer::class) val backgroundColor: Color = Color.Unspecified,
@ProtoNumber(7) val border: BorderStyle? = null,
@ProtoNumber(8) val listStyleType: String? = null,
@ProtoNumber(9) val listStyleImage: String? = null,
@ProtoNumber(10) val pageBreakInsideAvoid: Boolean = false,
@ProtoNumber(11) val pageBreakAfterAvoid: Boolean = false,
@ProtoNumber(12) val boxSizing: String? = null,
@ProtoNumber(13) val float: String? = null,
@ProtoNumber(14) val clear: String? = null,
@ProtoNumber(15) val position: String? = null,
@ProtoNumber(16) @Serializable(with = DpSerializer::class) val top: Dp = Dp.Unspecified,
@ProtoNumber(17) @Serializable(with = DpSerializer::class) val right: Dp = Dp.Unspecified,
@ProtoNumber(18) @Serializable(with = DpSerializer::class) val bottom: Dp = Dp.Unspecified,
@ProtoNumber(19) @Serializable(with = DpSerializer::class) val left: Dp = Dp.Unspecified,
@ProtoNumber(20) val display: String? = null,
@ProtoNumber(21) val flexDirection: String? = null,
@ProtoNumber(22) val justifyContent: String? = null,
@ProtoNumber(23) val alignItems: String? = null,
@ProtoNumber(24) val horizontalAlign: String? = null,
@ProtoNumber(25) val filter: String? = null,
@ProtoNumber(26) val borderCollapse: String? = null,
@ProtoNumber(27) @Serializable(with = DpSerializer::class) val borderSpacing: Dp = 0.dp,
@ProtoNumber(28) @Serializable(with = DpSerializer::class) val borderRadius: Dp = 0.dp
@ProtoNumber(7) val borderTop: BorderStyle? = null,
@ProtoNumber(8) val borderRight: BorderStyle? = null,
@ProtoNumber(9) val borderBottom: BorderStyle? = null,
@ProtoNumber(10) val borderLeft: BorderStyle? = null,
@ProtoNumber(11) val listStyleType: String? = null,
@ProtoNumber(12) val listStyleImage: String? = null,
@ProtoNumber(13) val pageBreakInsideAvoid: Boolean = false,
@ProtoNumber(14) val pageBreakAfterAvoid: Boolean = false,
@ProtoNumber(15) val boxSizing: String? = null,
@ProtoNumber(16) val float: String? = null,
@ProtoNumber(17) val clear: String? = null,
@ProtoNumber(18) val position: String? = null,
@ProtoNumber(19) @Serializable(with = DpSerializer::class) val top: Dp = Dp.Unspecified,
@ProtoNumber(20) @Serializable(with = DpSerializer::class) val right: Dp = Dp.Unspecified,
@ProtoNumber(21) @Serializable(with = DpSerializer::class) val bottom: Dp = Dp.Unspecified,
@ProtoNumber(22) @Serializable(with = DpSerializer::class) val left: Dp = Dp.Unspecified,
@ProtoNumber(23) val display: String? = null,
@ProtoNumber(24) val flexDirection: String? = null,
@ProtoNumber(25) val justifyContent: String? = null,
@ProtoNumber(26) val alignItems: String? = null,
@ProtoNumber(27) val horizontalAlign: String? = null,
@ProtoNumber(28) val filter: String? = null,
@ProtoNumber(29) val borderCollapse: String? = null,
@ProtoNumber(30) @Serializable(with = DpSerializer::class) val borderTopLeftRadius: Dp = 0.dp,
@ProtoNumber(31) @Serializable(with = DpSerializer::class) val borderTopRightRadius: Dp = 0.dp,
@ProtoNumber(32) @Serializable(with = DpSerializer::class) val borderBottomRightRadius: Dp = 0.dp,
@ProtoNumber(33) @Serializable(with = DpSerializer::class) val borderBottomLeftRadius: Dp = 0.dp,
@ProtoNumber(34) @Serializable(with = DpSerializer::class) val borderSpacing: Dp = 0.dp
) {
fun merge(other: BlockStyle): BlockStyle {
return BlockStyle(
@ -92,7 +98,14 @@ data class BlockStyle(
maxWidth = if (other.maxWidth != Dp.Unspecified) other.maxWidth else this.maxWidth,
height = if (other.height != Dp.Unspecified) other.height else this.height,
backgroundColor = if (other.backgroundColor.isSpecified) other.backgroundColor else this.backgroundColor,
border = other.border ?: this.border,
borderTop = other.borderTop ?: this.borderTop,
borderRight = other.borderRight ?: this.borderRight,
borderBottom = other.borderBottom ?: this.borderBottom,
borderLeft = other.borderLeft ?: this.borderLeft,
borderTopLeftRadius = if (other.borderTopLeftRadius != 0.dp) other.borderTopLeftRadius else this.borderTopLeftRadius,
borderTopRightRadius = if (other.borderTopRightRadius != 0.dp) other.borderTopRightRadius else this.borderTopRightRadius,
borderBottomRightRadius = if (other.borderBottomRightRadius != 0.dp) other.borderBottomRightRadius else this.borderBottomRightRadius,
borderBottomLeftRadius = if (other.borderBottomLeftRadius != 0.dp) other.borderBottomLeftRadius else this.borderBottomLeftRadius,
listStyleType = other.listStyleType ?: this.listStyleType,
listStyleImage = other.listStyleImage ?: this.listStyleImage,
pageBreakInsideAvoid = this.pageBreakInsideAvoid || other.pageBreakInsideAvoid,
@ -112,8 +125,7 @@ data class BlockStyle(
horizontalAlign = other.horizontalAlign ?: this.horizontalAlign,
filter = other.filter ?: this.filter,
borderCollapse = other.borderCollapse ?: this.borderCollapse,
borderSpacing = if (other.borderSpacing != 0.dp) other.borderSpacing else this.borderSpacing,
borderRadius = if (other.borderRadius != 0.dp) other.borderRadius else this.borderRadius
borderSpacing = if (other.borderSpacing != 0.dp) other.borderSpacing else this.borderSpacing
)
}
}

View file

@ -288,11 +288,11 @@ class SuspendingAndroidBlockMeasurementProvider(
var splitRowIndex = -1
val decorationTop = with(density) {
block.style.padding.top.toPx() + (block.style.border?.width?.toPx() ?: 0f)
block.style.padding.top.toPx() + (block.style.borderTop?.width?.toPx() ?: 0f)
}.roundToInt()
val decorationBottom = with(density) {
block.style.padding.bottom.toPx() + (block.style.border?.width?.toPx() ?: 0f)
block.style.padding.bottom.toPx() + (block.style.borderBottom?.width?.toPx() ?: 0f)
}.roundToInt()
Timber.tag("PAGINATION_DEBUG").d("SplitTable: avail=$availableHeight, topDec=$decorationTop, botDec=$decorationBottom")
@ -313,7 +313,8 @@ 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
(cell.style.blockStyle.borderTop?.width?.toPx() ?: 0f) +
(cell.style.blockStyle.borderBottom?.width?.toPx() ?: 0f)
}.roundToInt()
maxRowHeight = maxOf(maxRowHeight, cellHeight + cellDecoration)
}
@ -344,11 +345,11 @@ class SuspendingAndroidBlockMeasurementProvider(
var splitChildIndex = -1
val decorationTop = with(density) {
block.style.padding.top.toPx() + (block.style.border?.width?.toPx() ?: 0f)
block.style.padding.top.toPx() + (block.style.borderTop?.width?.toPx() ?: 0f)
}.roundToInt()
val decorationBottom = with(density) {
block.style.padding.bottom.toPx() + (block.style.border?.width?.toPx() ?: 0f)
block.style.padding.bottom.toPx() + (block.style.borderBottom?.width?.toPx() ?: 0f)
}.roundToInt()
currentHeight += decorationTop
@ -677,10 +678,9 @@ private suspend fun measureBlockHeight(
with(density) {
verticalPaddingPx = block.style.padding.top.toPx() + block.style.padding.bottom.toPx()
horizontalPaddingPx = block.style.padding.left.toPx() + block.style.padding.right.toPx()
block.style.border?.let {
verticalBorderPx = it.width.toPx() * 2
horizontalBorderPx = it.width.toPx() * 2
}
verticalBorderPx = (block.style.borderTop?.width?.toPx() ?: 0f) + (block.style.borderBottom?.width?.toPx() ?: 0f)
horizontalBorderPx = (block.style.borderLeft?.width?.toPx() ?: 0f) + (block.style.borderRight?.width?.toPx() ?: 0f)
}
val isBorderBox = block.style.boxSizing == "border-box"
@ -817,7 +817,8 @@ private suspend fun measureBlockHeight(
var cellDecorationHeight = 0f
with(density) {
cellDecorationHeight = cellBlockStyle.padding.top.toPx() + cellBlockStyle.padding.bottom.toPx()
cellBlockStyle.border?.let { cellDecorationHeight += it.width.toPx() * 2 }
cellDecorationHeight += (cellBlockStyle.borderTop?.width?.toPx() ?: 0f)
cellDecorationHeight += (cellBlockStyle.borderBottom?.width?.toPx() ?: 0f)
}
maxRowHeight = maxOf(maxRowHeight, (cellContentHeight + cellDecorationHeight).roundToInt())
}
@ -1002,11 +1003,11 @@ private suspend fun splitParagraphBlock(
if (text.isEmpty()) return null
val decorationTop = with(density) {
block.style.padding.top.toPx() + (block.style.border?.width?.toPx() ?: 0f)
block.style.padding.top.toPx() + (block.style.borderTop?.width?.toPx() ?: 0f)
}.roundToInt()
val decorationBottom = with(density) {
block.style.padding.bottom.toPx() + (block.style.border?.width?.toPx() ?: 0f)
block.style.padding.bottom.toPx() + (block.style.borderBottom?.width?.toPx() ?: 0f)
}.roundToInt()
val availableTextHeight = availableHeight - decorationTop - decorationBottom