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 { } else {
original.backgroundColor original.backgroundColor
} }
val newBorder = original.border?.let {
val newBorderColor = CssParser.adaptColorForTheme(it.color, isDarkTheme, isBackground = false) fun themeBorder(b: BorderStyle?): BorderStyle? {
it.copy(color = newBorderColor) 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) return style.copy(spanStyle = newSpanStyle, blockStyle = newBlockStyle)

View file

@ -385,6 +385,26 @@ object CssParser {
var marginBottomStr: String? = null var marginBottomStr: String? = null
var marginLeftStr: 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 -> splitDeclarations(properties).filter { it.isNotBlank() }.forEach { prop ->
val parts = prop.split(':', limit = 2).map { it.trim() } val parts = prop.split(':', limit = 2).map { it.trim() }
if (parts.size == 2) { if (parts.size == 2) {
@ -401,7 +421,6 @@ object CssParser {
valueWithImportant valueWithImportant
} }
// Helper to update border props ONLY if this border is significant
fun updateUnifiedBorder( fun updateUnifiedBorder(
widthStr: String?, widthStr: String?,
colorStr: String?, colorStr: String?,
@ -410,12 +429,6 @@ object CssParser {
val parsedWidth = widthStr?.let { parseCssSizeToDp(it, baseFontSizeSp, density, containerWidthPx) } ?: 0.dp 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) } 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 val isExplicitWidth = widthStr != null
if (parsedWidth > maxBorderWidthFound) { if (parsedWidth > maxBorderWidthFound) {
@ -423,19 +436,15 @@ object CssParser {
if (parsedColor != null) finalBorderColor = parsedColor if (parsedColor != null) finalBorderColor = parsedColor
if (styleStr != null) finalBorderStyle = styleStr if (styleStr != null) finalBorderStyle = styleStr
} else if (parsedWidth == maxBorderWidthFound && maxBorderWidthFound > 0.dp) { } else if (parsedWidth == maxBorderWidthFound && maxBorderWidthFound > 0.dp) {
// If equal non-zero width, let last defined win (cascade)
if (parsedColor != null) finalBorderColor = parsedColor if (parsedColor != null) finalBorderColor = parsedColor
if (styleStr != null) finalBorderStyle = styleStr if (styleStr != null) finalBorderStyle = styleStr
} else if (!isExplicitWidth) { } else if (!isExplicitWidth) {
// Just updating color or style without width
if (parsedColor != null) finalBorderColor = parsedColor if (parsedColor != null) finalBorderColor = parsedColor
if (styleStr != null) finalBorderStyle = styleStr if (styleStr != null) finalBorderStyle = styleStr
} }
} }
when (key) { 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" -> { "font-family" -> {
fontFamilies = value.split(',') fontFamilies = value.split(',')
.map { it.trim().removeSurrounding("\"").removeSurrounding("'").lowercase() } .map { it.trim().removeSurrounding("\"").removeSurrounding("'").lowercase() }
@ -581,40 +590,78 @@ object CssParser {
backgroundColor = this@CssParser.adaptColorForTheme(originalColor, isDarkTheme, isBackground = true) backgroundColor = this@CssParser.adaptColorForTheme(originalColor, isDarkTheme, isBackground = true)
} }
// Border Properties - Logic Updated // Border Properties
"border-width" -> updateUnifiedBorder(value, null, null) "border-width" -> {
"border-color" -> updateUnifiedBorder(null, value, null) val widths = parseShorthand4(value, baseFontSizeSp, density, containerWidthPx)
"border-style" -> updateUnifiedBorder(null, null, value) borderTopWidth = widths[0]; borderRightWidth = widths[1]; borderBottomWidth = widths[2]; borderLeftWidth = widths[3]
"border-top-width", "border-bottom-width", "border-left-width", "border-right-width" -> {
updateUnifiedBorder(value, null, null)
} }
"border-top-color", "border-bottom-color", "border-left-color", "border-right-color" -> { "border-style" -> {
updateUnifiedBorder(null, value, null) 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" -> { "border-color" -> {
updateUnifiedBorder(null, null, value) 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" -> { "border-top-width" -> borderTopWidth = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx)
val borderParts = value.split(" ").filter { it.isNotBlank() } "border-right-width" -> borderRightWidth = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx)
var widthVal: String? = null "border-bottom-width" -> borderBottomWidth = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx)
var colorVal: String? = null "border-left-width" -> borderLeftWidth = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx)
var styleVal: String? = null
borderParts.forEach { part -> "border-top-style" -> borderTopStyle = value
val parsedWidth = parseCssSizeToDp(part, baseFontSizeSp, density, containerWidthPx) "border-right-style" -> borderRightStyle = value
if (parsedWidth > 0.dp || part == "0" || part == "0px" || BORDER_WIDTH_KEYWORDS.containsKey(part)) { "border-bottom-style" -> borderBottomStyle = value
widthVal = part "border-left-style" -> borderLeftStyle = value
} else if (part in listOf("solid", "dotted", "dashed", "double", "groove", "ridge", "inset", "outset")) {
styleVal = part "border-top-color" -> borderTopColor = parseColor(value)
} else if (parseColor(part) != null) { "border-right-color" -> borderRightColor = parseColor(value)
colorVal = part "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
} }
"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
} }
updateUnifiedBorder(widthVal, colorVal, styleVal) "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
} }
// End Border Properties "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" -> { "border-collapse" -> {
if (value in listOf("collapse", "separate")) { if (value in listOf("collapse", "separate")) {
@ -624,9 +671,6 @@ object CssParser {
"border-spacing" -> { "border-spacing" -> {
borderSpacing = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx) 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" -> { "list-style-type" -> {
listStyleType = value listStyleType = value
@ -727,7 +771,6 @@ object CssParser {
null null
} }
// Updated: Use the maxBorderWidthFound and corresponding colors
val finalBorder = if (maxBorderWidthFound > 0.dp && finalBorderStyle != null) { val finalBorder = if (maxBorderWidthFound > 0.dp && finalBorderStyle != null) {
val borderColor = finalBorderColor ?: spanStyle.color.takeIf { it.isSpecified } ?: Color.Black val borderColor = finalBorderColor ?: spanStyle.color.takeIf { it.isSpecified } ?: Color.Black
BorderStyle( BorderStyle(
@ -737,9 +780,35 @@ object CssParser {
) )
} else null } 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( val blockStyle = BlockStyle(
margin = margin, padding = padding, width = width, maxWidth = maxWidth, height = height, 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, listStyleType = listStyleType,
listStyleImage = listStyleImage, listStyleImage = listStyleImage,
pageBreakInsideAvoid = pageBreakInsideAvoid, pageBreakInsideAvoid = pageBreakInsideAvoid,
@ -759,12 +828,75 @@ object CssParser {
horizontalAlign = finalHorizontalAlign, horizontalAlign = finalHorizontalAlign,
filter = filter, filter = filter,
borderCollapse = borderCollapse, borderCollapse = borderCollapse,
borderSpacing = borderSpacing, borderSpacing = borderSpacing
borderRadius = borderRadius
) )
return CssStyle(spanStyle, paragraphStyle, blockStyle, fontFamilies, display, fontSize, textTransform, boxSizing, content, hyphens, fontVariantNumeric, textEmphasis) 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 // ADD the parseCssSizeToDp function here at the bottom of the object or file
internal fun parseCssSizeToDp( internal fun parseCssSizeToDp(
size: String, size: String,

View file

@ -256,9 +256,15 @@ private class SemanticHtmlParser(
val result = when (val tagName = element.tagName().lowercase()) { val result = when (val tagName = element.tagName().lowercase()) {
"div", "header", "section", "article", "aside", "main", "footer", "nav", "figure" -> { "div", "header", "section", "article", "aside", "main", "footer", "nav", "figure" -> {
val hasBoxStyles = elementStyle.blockStyle.backgroundColor.isSpecified || 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.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) { if (hasBoxStyles) {
val children = element.children().flatMap { child -> 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.composed import androidx.compose.ui.composed
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.toRect
import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color 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.ColorMatrix
import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect 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.Stroke
import androidx.compose.ui.graphics.drawscope.clipPath import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.drawscope.clipRect import androidx.compose.ui.graphics.drawscope.clipRect
@ -1588,25 +1589,9 @@ internal fun PaginatedReaderContent(
val styleModifier = alignModifier val styleModifier = alignModifier
.then(if (block.style.horizontalAlign == "center") widthModifier else Modifier) .then(if (block.style.horizontalAlign == "center") widthModifier else Modifier)
.then( .drawCssBorders(
if (block.style.borderRadius > 0.dp) Modifier.clip(RoundedCornerShape(block.style.borderRadius)) blockStyle = block.style,
else Modifier density = density
)
.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
) )
val diagnosticModifier = Modifier val diagnosticModifier = Modifier
@ -1636,12 +1621,11 @@ internal fun PaginatedReaderContent(
.then(styleModifier) .then(styleModifier)
Box(modifier = diagnosticModifier) { Box(modifier = diagnosticModifier) {
val borderWidth = block.style.border?.width ?: 0.dp
val paddingModifier = Modifier.padding( val paddingModifier = Modifier.padding(
start = block.style.padding.left.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) + borderWidth, top = block.style.padding.top.coerceAtLeast(0.dp) + (block.style.borderTop?.width ?: 0.dp),
end = block.style.padding.right.coerceAtLeast(0.dp) + borderWidth, end = block.style.padding.right.coerceAtLeast(0.dp) + (block.style.borderRight?.width ?: 0.dp),
bottom = block.style.padding.bottom.coerceAtLeast(0.dp) + borderWidth bottom = block.style.padding.bottom.coerceAtLeast(0.dp) + (block.style.borderBottom?.width ?: 0.dp)
).then( ).then(
if (block.style.horizontalAlign != "center") widthModifier else Modifier.fillMaxWidth() if (block.style.horizontalAlign != "center") widthModifier else Modifier.fillMaxWidth()
) )
@ -2322,61 +2306,16 @@ internal fun PaginatedReaderContent(
} }
is SpacerBlock -> { is SpacerBlock -> {
val border = block.style.border
if (border != null && border.width > 0.dp) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height( .height(block.height)
border.width .drawCssBorders(block.style, density)
) )
.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))
}
} }
is TableBlock -> { is TableBlock -> {
// Table-level margin/background/border/padding Column(modifier = paddingModifier) {
// are already
// applied by the outer Box wrapper. Only set
// width here.
val tableModifier = paddingModifier
Column(modifier = tableModifier) {
block.rows.forEach { tableRow -> block.rows.forEach { tableRow ->
Row( Row(
Modifier Modifier
@ -2428,14 +2367,7 @@ internal fun PaginatedReaderContent(
Modifier Modifier
} }
) )
.then(cellStyle.border?.let { border -> .drawCssBorders(cellStyle, density)
Modifier.border(
BorderStroke(
border.width,
border.color
)
)
} ?: Modifier)
.padding( .padding(
start = cellStyle.padding.left.coerceAtLeast( start = cellStyle.padding.left.coerceAtLeast(
0.dp 0.dp
@ -2508,23 +2440,11 @@ internal fun PaginatedReaderContent(
} }
is SpacerBlock -> { 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( Spacer(
modifier = spacerModifier modifier = Modifier
.fillMaxWidth()
.height(blockInCell.height)
.drawCssBorders(blockInCell.style, density)
) )
} }
@ -3311,17 +3231,12 @@ private fun RenderFlexChildBlock(
} }
is SpacerBlock -> { is SpacerBlock -> {
val border = childBlock.style.border
if (border != null && border.width > 0.dp) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(border.width) .height(childBlock.height)
.background(border.color) .drawCssBorders(childBlock.style, density)
) )
} else {
Spacer(Modifier.height(childBlock.height))
}
} }
is TableBlock -> { is TableBlock -> {
@ -3353,11 +3268,7 @@ private fun RenderFlexChildBlock(
) )
else Modifier else Modifier
) )
.then(cellStyle.border?.let { .drawCssBorders(cellStyle, density)
Modifier.border(
BorderStroke(it.width, it.color)
)
} ?: Modifier)
.padding( .padding(
start = cellStyle.padding.left.coerceAtLeast( start = cellStyle.padding.left.coerceAtLeast(
0.dp 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(4) @Serializable(with = DpSerializer::class) val maxWidth: Dp = Dp.Unspecified,
@ProtoNumber(5) @Serializable(with = DpSerializer::class) val height: 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(6) @Serializable(with = ColorSerializer::class) val backgroundColor: Color = Color.Unspecified,
@ProtoNumber(7) val border: BorderStyle? = null, @ProtoNumber(7) val borderTop: BorderStyle? = null,
@ProtoNumber(8) val listStyleType: String? = null, @ProtoNumber(8) val borderRight: BorderStyle? = null,
@ProtoNumber(9) val listStyleImage: String? = null, @ProtoNumber(9) val borderBottom: BorderStyle? = null,
@ProtoNumber(10) val pageBreakInsideAvoid: Boolean = false, @ProtoNumber(10) val borderLeft: BorderStyle? = null,
@ProtoNumber(11) val pageBreakAfterAvoid: Boolean = false, @ProtoNumber(11) val listStyleType: String? = null,
@ProtoNumber(12) val boxSizing: String? = null, @ProtoNumber(12) val listStyleImage: String? = null,
@ProtoNumber(13) val float: String? = null, @ProtoNumber(13) val pageBreakInsideAvoid: Boolean = false,
@ProtoNumber(14) val clear: String? = null, @ProtoNumber(14) val pageBreakAfterAvoid: Boolean = false,
@ProtoNumber(15) val position: String? = null, @ProtoNumber(15) val boxSizing: String? = null,
@ProtoNumber(16) @Serializable(with = DpSerializer::class) val top: Dp = Dp.Unspecified, @ProtoNumber(16) val float: String? = null,
@ProtoNumber(17) @Serializable(with = DpSerializer::class) val right: Dp = Dp.Unspecified, @ProtoNumber(17) val clear: String? = null,
@ProtoNumber(18) @Serializable(with = DpSerializer::class) val bottom: Dp = Dp.Unspecified, @ProtoNumber(18) val position: String? = null,
@ProtoNumber(19) @Serializable(with = DpSerializer::class) val left: Dp = Dp.Unspecified, @ProtoNumber(19) @Serializable(with = DpSerializer::class) val top: Dp = Dp.Unspecified,
@ProtoNumber(20) val display: String? = null, @ProtoNumber(20) @Serializable(with = DpSerializer::class) val right: Dp = Dp.Unspecified,
@ProtoNumber(21) val flexDirection: String? = null, @ProtoNumber(21) @Serializable(with = DpSerializer::class) val bottom: Dp = Dp.Unspecified,
@ProtoNumber(22) val justifyContent: String? = null, @ProtoNumber(22) @Serializable(with = DpSerializer::class) val left: Dp = Dp.Unspecified,
@ProtoNumber(23) val alignItems: String? = null, @ProtoNumber(23) val display: String? = null,
@ProtoNumber(24) val horizontalAlign: String? = null, @ProtoNumber(24) val flexDirection: String? = null,
@ProtoNumber(25) val filter: String? = null, @ProtoNumber(25) val justifyContent: String? = null,
@ProtoNumber(26) val borderCollapse: String? = null, @ProtoNumber(26) val alignItems: String? = null,
@ProtoNumber(27) @Serializable(with = DpSerializer::class) val borderSpacing: Dp = 0.dp, @ProtoNumber(27) val horizontalAlign: String? = null,
@ProtoNumber(28) @Serializable(with = DpSerializer::class) val borderRadius: Dp = 0.dp @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 { fun merge(other: BlockStyle): BlockStyle {
return BlockStyle( return BlockStyle(
@ -92,7 +98,14 @@ data class BlockStyle(
maxWidth = if (other.maxWidth != Dp.Unspecified) other.maxWidth else this.maxWidth, maxWidth = if (other.maxWidth != Dp.Unspecified) other.maxWidth else this.maxWidth,
height = if (other.height != Dp.Unspecified) other.height else this.height, height = if (other.height != Dp.Unspecified) other.height else this.height,
backgroundColor = if (other.backgroundColor.isSpecified) other.backgroundColor else this.backgroundColor, 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, listStyleType = other.listStyleType ?: this.listStyleType,
listStyleImage = other.listStyleImage ?: this.listStyleImage, listStyleImage = other.listStyleImage ?: this.listStyleImage,
pageBreakInsideAvoid = this.pageBreakInsideAvoid || other.pageBreakInsideAvoid, pageBreakInsideAvoid = this.pageBreakInsideAvoid || other.pageBreakInsideAvoid,
@ -112,8 +125,7 @@ data class BlockStyle(
horizontalAlign = other.horizontalAlign ?: this.horizontalAlign, horizontalAlign = other.horizontalAlign ?: this.horizontalAlign,
filter = other.filter ?: this.filter, filter = other.filter ?: this.filter,
borderCollapse = other.borderCollapse ?: this.borderCollapse, borderCollapse = other.borderCollapse ?: this.borderCollapse,
borderSpacing = if (other.borderSpacing != 0.dp) other.borderSpacing else this.borderSpacing, borderSpacing = if (other.borderSpacing != 0.dp) other.borderSpacing else this.borderSpacing
borderRadius = if (other.borderRadius != 0.dp) other.borderRadius else this.borderRadius
) )
} }
} }

View file

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