Epub improvements (#162)

* Added a "Remove Edge Padding" option in "Visual Options"

* Added support for paragraph gap settings in the EPUB vertical reader.

* Added support for adjustable paragraph gaps in the paginated reader.

* fix(epub-pagination): ensure custom fonts override embedded epub fonts

* feat(epub): allow overlapping highlights and resolve gesture selection conflicts
This commit is contained in:
Aryan 2026-04-10 17:43:01 +05:30 committed by GitHub
parent 291504fd90
commit 49e08cc9f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 315 additions and 264 deletions

View file

@ -118,7 +118,8 @@ class BookPaginator(
private val allFontFaces: List<FontFaceInfo>,
private val context: Context,
private val mathMLRenderer: MathMLRenderer,
private val userTextAlign: TextAlign?
private val userTextAlign: TextAlign?,
private val paragraphGapMultiplier: Float
) : IPaginator {
override var totalPageCount by mutableIntStateOf(0)
private set
@ -271,7 +272,7 @@ class BookPaginator(
}
private fun generateConfigurationHash(): Int {
val configString = "w:${constraints.maxWidth}-h:${constraints.maxHeight}-fs:${textStyle.fontSize.value}-ta:$userTextAlign"
val configString = "w:${constraints.maxWidth}-h:${constraints.maxHeight}-fs:${textStyle.fontSize.value}-ta:$userTextAlign-pg:$paragraphGapMultiplier"
val hash = configString.hashCode()
return hash
}
@ -323,6 +324,8 @@ class BookPaginator(
)
bookCacheDao.insertConfigurationCache(newCache)
bookCacheDao.cleanupOldConfigurations(bookId)
if (finalizedChapterCounts.size >= chapters.size) {
pageCountsAreAccurate = true
}
@ -418,7 +421,8 @@ class BookPaginator(
themeTextColor = themeTextColor,
chapterAbsPath = chapter.absPath,
extractionBasePath = extractionBasePath,
userTextAlign = userTextAlign
userTextAlign = userTextAlign,
paragraphGapMultiplier = paragraphGapMultiplier
)
bookCacheDao.getProcessedChapter(bookId, chapterIndex)?.let { cachedChapter ->

View file

@ -54,7 +54,8 @@ class ContentStyler(
private val themeTextColor: Color,
private val chapterAbsPath: String,
private val extractionBasePath: String,
private val userTextAlign: TextAlign?
private val userTextAlign: TextAlign?,
private val paragraphGapMultiplier: Float
) {
fun style(semanticBlocks: List<SemanticBlock>): List<ContentBlock> {
@ -102,6 +103,18 @@ class ContentStyler(
private fun styleBlock(block: SemanticBlock): ContentBlock? {
val themedStyle = applyThemeToStyle(block.style)
val finalBlockStyle = if (block is SemanticParagraph) {
val originalMargin = themedStyle.blockStyle.margin
val newMargin = originalMargin.copy(
top = originalMargin.top * paragraphGapMultiplier,
bottom = originalMargin.bottom * paragraphGapMultiplier
)
themedStyle.blockStyle.copy(margin = newMargin)
} else {
themedStyle.blockStyle
}
return when (block) {
is SemanticParagraph -> {
val computedTextAlign = userTextAlign ?: themedStyle.paragraphStyle.textAlign
@ -109,7 +122,7 @@ class ContentStyler(
ParagraphBlock(
content = buildAnnotatedString(block, themedStyle),
textAlign = computedTextAlign,
style = themedStyle.blockStyle,
style = finalBlockStyle,
elementId = block.elementId,
cfi = block.cfi,
startCharOffsetInSource = block.startCharOffsetInSource,
@ -353,14 +366,20 @@ class ContentStyler(
textMotion = mergedParagraphStyle.textMotion
)
var initialSpanStyle = baseTextStyle.toSpanStyle()
.merge(blockStyle.spanStyle)
.copy(fontFamily = baseTextStyle.fontFamily)
val isCustomFont = baseTextStyle.fontFamily != null && baseTextStyle.fontFamily != FontFamily.Default
if (rootFontFamily == FontFamily.Monospace) {
initialSpanStyle = initialSpanStyle.copy(fontFamily = rootFontFamily)
val effectiveBlockFontFamily = if (rootFontFamily == FontFamily.Monospace) {
FontFamily.Monospace
} else if (isCustomFont) {
baseTextStyle.fontFamily
} else {
rootFontFamily ?: baseTextStyle.fontFamily
}
val initialSpanStyle = baseTextStyle.toSpanStyle()
.merge(blockStyle.spanStyle)
.copy(fontFamily = effectiveBlockFontFamily)
Timber.d("ContentStyler: InitialSpanStyle. BaseFontSize=${baseTextStyle.fontSize}, BlockFontSize=${blockStyle.spanStyle.fontSize} -> Merged=${initialSpanStyle.fontSize}")
withStyle(finalParagraphStyle) {
@ -368,14 +387,23 @@ class ContentStyler(
append(block.text)
block.spans.sortedBy { it.start }.forEach { span ->
val themedSpanStyle = applyThemeToStyle(span.style)
val fontFamily = findFirstAvailableFontFamily(themedSpanStyle.fontFamilies, fontFamilyMap)
val spanFontFamily = findFirstAvailableFontFamily(themedSpanStyle.fontFamilies, fontFamilyMap)
val effectiveSpanFontFamily = if (spanFontFamily == FontFamily.Monospace) {
FontFamily.Monospace
} else if (isCustomFont) {
baseTextStyle.fontFamily
} else {
spanFontFamily
}
val baselineShift = when (span.tag) {
"sub" -> BaselineShift.Subscript
"sup" -> BaselineShift.Superscript
else -> null
}
val finalSpanStyle = themedSpanStyle.spanStyle.copy(
fontFamily = fontFamily,
fontFamily = effectiveSpanFontFamily,
baselineShift = baselineShift
)
addStyle(initialSpanStyle.merge(finalSpanStyle), span.start, span.end)

View file

@ -491,6 +491,7 @@ private fun WrappingContentLayout(
@OptIn(ExperimentalFoundationApi::class, ExperimentalSerializationApi::class, FlowPreview::class)
@Composable
fun PaginatedReaderScreen(
modifier: Modifier = Modifier,
book: EpubBook,
isDarkTheme: Boolean,
effectiveBg: Color,
@ -500,11 +501,12 @@ fun PaginatedReaderScreen(
searchQuery: String,
fontSizeMultiplier: Float,
lineHeightMultiplier: Float,
paragraphGapMultiplier: Float,
fontFamily: FontFamily,
textAlign: ReaderTextAlign,
ttsHighlightInfo: TtsHighlightInfo?,
initialChapterIndexInBook: Int?,
modifier: Modifier = Modifier,
removeEdgePadding: Boolean = false,
onPaginatorReady: (IPaginator) -> Unit,
onTap: (Offset?) -> Unit,
isProUser: Boolean,
@ -553,6 +555,7 @@ fun PaginatedReaderScreen(
var debouncedFontSizeMult by remember { mutableFloatStateOf(fontSizeMultiplier) }
var debouncedLineHeightMult by remember { mutableFloatStateOf(lineHeightMultiplier) }
var debouncedParagraphGapMult by remember { mutableFloatStateOf(paragraphGapMultiplier) }
var debouncedFontFamily by remember { mutableStateOf(fontFamily) }
var debouncedTextAlign by remember { mutableStateOf(textAlign) }
@ -611,8 +614,8 @@ fun PaginatedReaderScreen(
)
}
LaunchedEffect(fontSizeMultiplier, lineHeightMultiplier, fontFamily, textAlign) {
if (fontSizeMultiplier != debouncedFontSizeMult || lineHeightMultiplier != debouncedLineHeightMult || fontFamily != debouncedFontFamily || textAlign != debouncedTextAlign) {
LaunchedEffect(fontSizeMultiplier, lineHeightMultiplier, paragraphGapMultiplier, fontFamily, textAlign) {
if (fontSizeMultiplier != debouncedFontSizeMult || lineHeightMultiplier != debouncedLineHeightMult || paragraphGapMultiplier != debouncedParagraphGapMult || fontFamily != debouncedFontFamily || textAlign != debouncedTextAlign) {
Timber.d("Formatting changed. Waiting for debounce.")
delay(400L)
@ -627,6 +630,7 @@ fun PaginatedReaderScreen(
debouncedFontSizeMult = fontSizeMultiplier
debouncedLineHeightMult = lineHeightMultiplier
debouncedParagraphGapMult = paragraphGapMultiplier
debouncedFontFamily = fontFamily
debouncedTextAlign = textAlign
Timber.d("Debounce complete. Applying new format settings.")
@ -642,7 +646,7 @@ fun PaginatedReaderScreen(
}
val density = LocalDensity.current
val horizontalPadding = 16.dp
val horizontalPadding = if (removeEdgePadding) 0.dp else 16.dp
val verticalPadding = 16.dp
val textConstraints =
@ -673,8 +677,8 @@ fun PaginatedReaderScreen(
remember(initialChapterIndexInBook, anchorLocatorForReconfig) {
anchorLocatorForReconfig?.chapterIndex ?: initialChapterIndexInBook ?: 0
}
val paginator = remember(book, textConstraints, isDarkTheme, textStyle, userTextAlign, effectiveBg, effectiveText) {
val userAgentStylesheet = UserAgentStylesheet.default
val paginator = remember(book, textConstraints, isDarkTheme, textStyle, userTextAlign, effectiveBg, effectiveText, debouncedParagraphGapMult) {
val userAgentStylesheet = UserAgentStylesheet.default
var allRules = OptimizedCssRules()
val allFontFaces = mutableListOf<FontFaceInfo>()
@ -739,7 +743,8 @@ fun PaginatedReaderScreen(
allFontFaces = allFontFaces,
context = context.applicationContext,
mathMLRenderer = mathMLRenderer,
userTextAlign = userTextAlign
userTextAlign = userTextAlign,
paragraphGapMultiplier = debouncedParagraphGapMult
)
}
@ -1171,7 +1176,6 @@ private fun TextWithEmphasis(
is HeaderBlock -> block.startCharOffsetInSource
is QuoteBlock -> block.startCharOffsetInSource
is ListItemBlock -> block.startCharOffsetInSource
else -> 0
}
val isStart = block.blockIndex == activeSelection.startBlockIndex && currentBlockAbs == activeSelection.startBlockCharOffset
@ -1276,76 +1280,6 @@ private fun TextWithEmphasis(
}
.then(customDrawer)
.pointerInput(userHighlights, text) {
awaitEachGesture {
val down = awaitFirstDown(
pass = PointerEventPass.Initial, requireUnconsumed = false
)
val layout = textLayoutResult
if (layout != null) {
val hit = getHighlightAt(down.position, layout)
if (hit != null) {
down.consume()
val startPosition = down.position
var dragDistance = 0f
var isFinished = false
val longPressJob = scope.launch {
delay(500)
pressedHighlightCfi = hit.first.cfi
}
try {
while (true) {
val event = awaitPointerEvent(
pass = PointerEventPass.Initial
)
val change = event.changes.firstOrNull {
it.id == down.id
}
if (change == null) {
longPressJob.cancel()
pressedHighlightCfi = null
break
}
change.consume()
if (change.pressed) {
dragDistance = (change.position - startPosition).getDistance()
if (dragDistance >= viewConfiguration.touchSlop) {
longPressJob.cancel()
pressedHighlightCfi = null
}
} else {
isFinished = true
longPressJob.cancel()
pressedHighlightCfi = null
break
}
}
} catch (_: Exception) {
longPressJob.cancel()
pressedHighlightCfi = null
}
if (isFinished && dragDistance < viewConfiguration.touchSlop) {
val (highlight, localRect) = hit
val globalRect = layoutCoordinates?.let { coords ->
if (coords.isAttached) {
val topLeft = coords.localToWindow(
localRect.topLeft
)
val bottomRight = coords.localToWindow(
localRect.bottomRight
)
Rect(topLeft, bottomRight)
} else null
} ?: localRect
onHighlightClick(highlight, globalRect)
}
}
}
}
}
.pointerInput(text) {
detectTapGestures(
onLongPress = { offset ->
textLayoutResult?.let { layout ->
@ -1355,7 +1289,6 @@ private fun TextWithEmphasis(
var start = wordBoundary.start
var end = wordBoundary.end
// Trim trailing/leading punctuations for a cleaner word selection
val textStr = text.text
while (start < end && start < textStr.length && !textStr[start].isLetterOrDigit()) start++
while (end > start && end <= textStr.length && !textStr[end - 1].isLetterOrDigit()) end--
@ -1377,7 +1310,6 @@ private fun TextWithEmphasis(
is HeaderBlock -> block.startCharOffsetInSource
is QuoteBlock -> block.startCharOffsetInSource
is ListItemBlock -> block.startCharOffsetInSource
else -> 0
}
onSelectionChange(
@ -1402,14 +1334,27 @@ private fun TextWithEmphasis(
},
onTap = { offset ->
textLayoutResult?.let { layout ->
val hit = getHighlightAt(offset, layout)
if (hit != null) {
val (highlight, localRect) = hit
val globalRect = layoutCoordinates?.let { coords ->
if (coords.isAttached) {
val topLeft = coords.localToWindow(localRect.topLeft)
val bottomRight = coords.localToWindow(localRect.bottomRight)
Rect(topLeft, bottomRight)
} else null
} ?: localRect
onHighlightClick(highlight, globalRect)
return@detectTapGestures
}
val charOffset = layout.getOffsetForPosition(offset)
val urlAnnotation = text.getStringAnnotations(
"URL", charOffset, charOffset
).firstOrNull()
val urlAnnotation = text.getStringAnnotations("URL", charOffset, charOffset).firstOrNull()
if (urlAnnotation != null) onLinkClick(urlAnnotation.item)
else onGeneralTap(offset)
}
})
}
)
}, onTextLayout = {
textLayoutResult = it
if (layoutCoordinates != null && block.cfi != null) {
@ -1713,7 +1658,6 @@ internal fun PaginatedReaderContent(
is HeaderBlock -> firstTextBlock.startCharOffsetInSource
is QuoteBlock -> firstTextBlock.startCharOffsetInSource
is ListItemBlock -> firstTextBlock.startCharOffsetInSource
else -> 0
}
val newTextPerBlock = (previousSel?.textPerBlock ?: emptyMap()).toMutableMap()
@ -2853,7 +2797,6 @@ internal fun PaginatedReaderContent(
is HeaderBlock -> block.startCharOffsetInSource
is QuoteBlock -> block.startCharOffsetInSource
is ListItemBlock -> block.startCharOffsetInSource
else -> 0
}
val isStartBlockPart = block.blockIndex == sel.startBlockIndex && currentBlockAbs == sel.startBlockCharOffset
val isEndBlockPart = block.blockIndex == sel.endBlockIndex && currentBlockAbs == sel.endBlockCharOffset
@ -3008,7 +2951,6 @@ internal fun PaginatedReaderContent(
is HeaderBlock -> block.startCharOffsetInSource
is QuoteBlock -> block.startCharOffsetInSource
is ListItemBlock -> block.startCharOffsetInSource
else -> 0
}
var newStartBlockAbs = if (isStartHandle) currentBlockAbs else sel.startBlockCharOffset
var newEndBlockAbs = if (!isStartHandle) currentBlockAbs else sel.endBlockCharOffset
@ -3044,7 +2986,6 @@ internal fun PaginatedReaderContent(
is HeaderBlock -> (b.third as HeaderBlock).startCharOffsetInSource
is QuoteBlock -> (b.third as QuoteBlock).startCharOffsetInSource
is ListItemBlock -> (b.third as ListItemBlock).startCharOffsetInSource
else -> 0
}
}))
@ -3061,7 +3002,6 @@ internal fun PaginatedReaderContent(
is HeaderBlock -> b.third.startCharOffsetInSource
is QuoteBlock -> b.third.startCharOffsetInSource
is ListItemBlock -> b.third.startCharOffsetInSource
else -> 0
}
val isStartBlockPart = b.third.blockIndex == newStartIdx && bAbs == newStartBlockAbs
val isEndBlockPart = b.third.blockIndex == newEndIdx && bAbs == newEndBlockAbs
@ -3092,7 +3032,6 @@ internal fun PaginatedReaderContent(
is HeaderBlock -> it.third.startCharOffsetInSource
is QuoteBlock -> it.third.startCharOffsetInSource
is ListItemBlock -> it.third.startCharOffsetInSource
else -> 0
}
abs == newStartBlockAbs
}
@ -3187,7 +3126,6 @@ internal fun PaginatedReaderContent(
is HeaderBlock -> block.startCharOffsetInSource
is QuoteBlock -> block.startCharOffsetInSource
is ListItemBlock -> block.startCharOffsetInSource
else -> 0
}
blockAbs == targetBlockAbs
}

View file

@ -76,7 +76,8 @@ class PaginatedReaderViewModel : ViewModel() {
themeTextColor: androidx.compose.ui.graphics.Color,
context: Context,
initialChapterToPaginate: Int?,
mathMLRenderer: MathMLRenderer
mathMLRenderer: MathMLRenderer,
paragraphGapMultiplier: Float
) {
if (paginator != null) return
@ -142,7 +143,8 @@ class PaginatedReaderViewModel : ViewModel() {
allFontFaces = allFontFaces,
context = context.applicationContext,
mathMLRenderer = mathMLRenderer,
userTextAlign = null
userTextAlign = null,
paragraphGapMultiplier = paragraphGapMultiplier
)
paginator = newPaginator

View file

@ -178,6 +178,16 @@ abstract class BookCacheDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract suspend fun insertConfigurationCache(cache: ConfigurationCache)
@Query("""
DELETE FROM configuration_cache
WHERE bookId = :bookId AND configHash NOT IN (
SELECT configHash FROM configuration_cache
WHERE bookId = :bookId
ORDER BY rowid DESC LIMIT 3
)
""")
abstract suspend fun cleanupOldConfigurations(bookId: String)
}
@Database(