Desktop update (#362)

* Update documentation images

* Introduce native vertical EPUB reader for Desktop

* Add libsecret support for Linux secret storage

* Update localized strings and plurals across multiple languages

* Update PDF annotation UI and highlighter settings on desktop
This commit is contained in:
Aryan 2026-06-03 09:45:50 +05:30 committed by GitHub
parent e4634ed251
commit 6651169ce2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
60 changed files with 14457 additions and 468 deletions

View file

@ -194,7 +194,7 @@ object SharedPdfAnnotationDefaults {
0xFFFFFFFF.toInt()
)
val highlighterPalette: List<Int> = SharedPdfAndroidHighlightColors.palette.take(4)
val highlighterPalette: List<Int> = SharedPdfAndroidHighlightColors.palette.take(5)
fun configFor(tool: PdfInkTool): PdfToolConfig {
return when (tool) {
@ -235,7 +235,7 @@ data class SharedPdfHighlighterPalette(
companion object {
const val DefaultAlpha: Int = 0x8C
const val MaxColors: Int = 4
const val MaxColors: Int = 5
val defaultColors: List<Int>
get() = SharedPdfAnnotationDefaults.highlighterPalette
.take(MaxColors)

View file

@ -0,0 +1,54 @@
package com.aryan.reader.shared.reader
import androidx.compose.ui.text.style.TextAlign
internal fun resolveSharedReaderTextAlign(
cssTextAlign: TextAlign,
fallbackTextAlign: TextAlign
): TextAlign {
return when {
fallbackTextAlign.isExplicitSharedReaderTextAlign() -> fallbackTextAlign
cssTextAlign == TextAlign.Unspecified -> fallbackTextAlign
cssTextAlign == TextAlign.Justify -> TextAlign.Left
else -> cssTextAlign
}
}
private fun TextAlign.isExplicitSharedReaderTextAlign(): Boolean {
return this != TextAlign.Start &&
this != TextAlign.Left &&
this != TextAlign.Unspecified
}
internal fun resolveSharedReaderFontFeatureSettings(
existingSettings: String?,
fontVariantNumeric: String?
): String? {
val existing = existingSettings
?.trim()
?.takeIf { it.isNotBlank() }
val numericFeatures = fontVariantNumeric
?.lowercase()
?.split(Regex("\\s+"))
?.mapNotNull { token ->
when (token) {
"lining-nums" -> "lnum"
"oldstyle-nums" -> "onum"
"proportional-nums" -> "pnum"
"tabular-nums" -> "tnum"
"diagonal-fractions" -> "frac"
"stacked-fractions" -> "afrc"
"ordinal" -> "ordn"
"slashed-zero" -> "zero"
else -> null
}
}
?.distinct()
?.map { feature -> "\"$feature\" on" }
?.takeIf { it.isNotEmpty() }
?.joinToString(", ")
return listOfNotNull(existing, numericFeatures)
.joinToString(", ")
.takeIf { it.isNotBlank() }
}

View file

@ -8,6 +8,7 @@ import com.aryan.reader.shared.reader.ReaderPage
import com.aryan.reader.shared.reader.ReaderReadingMode
import com.aryan.reader.shared.reader.ReaderSearchOptions
import com.aryan.reader.shared.reader.ReaderSettings
import com.aryan.reader.shared.reader.SharedEpubBook
data class ReaderContentNavigationTarget(
val locator: ReaderLocator?,
@ -44,4 +45,18 @@ sealed interface ReaderContentRenderPlan {
override val navigationTarget: ReaderContentNavigationTarget,
override val highlights: List<UserHighlight>
) : ReaderContentRenderPlan
data class NativeVerticalPages(
val book: SharedEpubBook,
val pages: List<ReaderPage>,
val currentPageIndex: Int,
val settings: ReaderSettings,
val searchQuery: String,
val searchOptions: ReaderSearchOptions,
val highlightPalette: ReaderHighlightPalette,
override val background: Color,
override val foreground: Color,
override val navigationTarget: ReaderContentNavigationTarget,
override val highlights: List<UserHighlight>
) : ReaderContentRenderPlan
}

View file

@ -63,17 +63,20 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathMeasure
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.DrawScope
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.translate
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.toArgb
@ -700,13 +703,11 @@ private fun SharedPdfAnnotationToolSettingsPanel(
penPalette.ifEmpty { SharedPdfAnnotationDefaults.penPalette }
}
val selectedPaletteIndex = remember(activePalette, selectedColor, isHighlighter) {
activePalette.indexOfFirst { paletteColor ->
if (isHighlighter) {
Color(paletteColor).copy(alpha = 1f) == Color(selectedColor).copy(alpha = 1f)
} else {
paletteColor == selectedColor
}
}
sharedPdfSettingsSelectedPaletteIndex(
activePalette = activePalette,
selectedColor = selectedColor,
matchRgbOnly = isHighlighter
)
}
fun colorPickerPalette(): List<Int> {
@ -810,6 +811,8 @@ private fun SharedPdfAnnotationToolSettingsPanel(
}
}
Spacer(Modifier.height(16.dp))
if (isHighlighter) {
Row(
modifier = Modifier.fillMaxWidth(),
@ -978,6 +981,20 @@ private fun SharedPdfAnnotationToolSettingsPanel(
}
}
internal fun sharedPdfSettingsSelectedPaletteIndex(
activePalette: List<Int>,
selectedColor: Int,
matchRgbOnly: Boolean
): Int {
return activePalette.indexOfFirst { paletteColor ->
if (matchRgbOnly) {
(paletteColor and 0x00FFFFFF) == (selectedColor and 0x00FFFFFF)
} else {
paletteColor == selectedColor
}
}
}
@Composable
private fun SharedPdfHighlighterPalettePreview(
colors: List<Int>,
@ -1076,10 +1093,7 @@ private fun SharedPdfStyledPropertySlider(
thumbColor: Color,
activeColor: Color
) {
val displayValue = remember(value, valueRange) {
val fraction = (value - valueRange.start) / (valueRange.endInclusive - valueRange.start)
(fraction * 100f).roundToInt().coerceIn(1, 100)
}
val displayValue = remember(value, valueRange) { sharedPdfSettingsDisplayPercent(value, valueRange) }
val onePercentDelta = (valueRange.endInclusive - valueRange.start) / 100f
val canDecrease = value > valueRange.start + 0.0001f
val canIncrease = value < valueRange.endInclusive - 0.0001f
@ -1097,7 +1111,7 @@ private fun SharedPdfStyledPropertySlider(
contentAlignment = Alignment.Center
) {
Text(
text = "-",
text = "\u2014",
color = if (canDecrease) Color.White else Color.White.copy(alpha = 0.3f),
fontSize = 18.sp,
fontWeight = FontWeight.Bold
@ -1137,36 +1151,66 @@ private fun SharedPdfStyledPropertySlider(
}
}
},
track = { sliderState ->
val range = sliderState.valueRange.endInclusive - sliderState.valueRange.start
val fraction = if (range == 0f) {
0f
} else {
((sliderState.value - sliderState.valueRange.start) / range).coerceIn(0f, 1f)
}
val activeTrackColor = when {
isOpacity -> activeColor.copy(alpha = 1f)
activeColor.luminance() < 0.18f -> Color.White.copy(alpha = 0.88f)
else -> activeColor.copy(alpha = 0.95f)
}
val inactiveTrackColor = if (isOpacity) {
Color.White.copy(alpha = 0.24f)
} else {
trackColor.copy(alpha = 0.85f)
}
Box(
track = { _ ->
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(4.dp)
.background(inactiveTrackColor, RoundedCornerShape(2.dp)),
contentAlignment = Alignment.CenterStart
.height(16.dp)
) {
Box(
modifier = Modifier
.fillMaxWidth(fraction)
.height(4.dp)
.background(activeTrackColor, RoundedCornerShape(2.dp))
)
val trackHeight = size.height
val cornerRadius = CornerRadius(trackHeight / 2f)
if (isOpacity) {
drawRoundRect(
color = Color.Gray,
size = size,
cornerRadius = cornerRadius
)
val roundedClipPath = Path().apply {
addRoundRect(
RoundRect(
rect = Rect(Offset.Zero, size),
cornerRadius = cornerRadius
)
)
}
clipPath(roundedClipPath) {
val boxSize = 12f
val columns = (size.width / boxSize).toInt() + 1
val rows = (size.height / boxSize).toInt() + 1
for (column in 0 until columns) {
for (row in 0 until rows) {
drawRect(
color = if ((column + row) % 2 == 0) {
Color(0xFF555555)
} else {
Color(0xFF333333)
},
topLeft = Offset(column * boxSize, row * boxSize),
size = Size(boxSize, boxSize)
)
}
}
drawRect(color = activeColor)
}
} else {
drawRoundRect(
color = trackColor.copy(alpha = 0.5f),
size = size,
cornerRadius = cornerRadius
)
val dotRadius = 1.5.dp.toPx()
val padding = trackHeight / 2f
val availableWidth = size.width - (padding * 2f)
val dotCount = 8
val spacing = availableWidth / (dotCount - 1)
for (index in 0 until dotCount) {
drawCircle(
color = Color.White.copy(alpha = 0.2f),
radius = dotRadius,
center = Offset(padding + (index * spacing), size.height / 2f)
)
}
}
}
}
)
@ -1192,6 +1236,15 @@ private fun SharedPdfStyledPropertySlider(
}
}
internal fun sharedPdfSettingsDisplayPercent(
value: Float,
valueRange: ClosedFloatingPointRange<Float>
): Int {
val range = valueRange.endInclusive - valueRange.start
val fraction = if (range == 0f) 0f else (value - valueRange.start) / range
return (fraction * 100f).roundToInt().coerceIn(1, 100)
}
@Composable
private fun SharedPdfInkColorPalette(
colors: List<Int>,
@ -2174,7 +2227,7 @@ private fun SharedPdfPenIcon(
val animatedInkColor by animateColorAsState(targetValue = inkColor, label = "shared_ink_color")
val inkProgress by animateFloatAsState(
targetValue = if (isSelected) 1f else 0f,
animationSpec = tween(durationMillis = 450, easing = LinearEasing),
animationSpec = tween(durationMillis = 600, easing = LinearEasing),
label = "shared_ink_progress"
)
@ -2211,12 +2264,18 @@ private fun SharedPdfPenIcon(
}
if (inkProgress > 0.01f) {
val tipY = when (tool) {
PdfInkTool.HIGHLIGHTER -> topPadding
PdfInkTool.HIGHLIGHTER_ROUND -> topPadding + tipHeight * 0.15f
else -> topPadding
}
drawInkPreview(
tool = tool,
color = animatedInkColor,
progress = inkProgress,
startPoint = Offset(size.width / 2f, topPadding - 1f),
strokeWidth = strokeWidth
startPoint = Offset(size.width / 2f, tipY),
strokeWidth = strokeWidth,
isStraight = showHighlighterSnap
)
}
if (showHighlighterSnap && tool.isDesktopHighlighter) {
@ -2545,23 +2604,59 @@ private fun DrawScope.drawInkPreview(
color: Color,
progress: Float,
startPoint: Offset,
strokeWidth: Float
strokeWidth: Float,
isStraight: Boolean = false
) {
val x = startPoint.x
val y = startPoint.y - 2f
val path = Path().apply {
moveTo(startPoint.x, startPoint.y)
moveTo(x, y)
if (tool.isHighlighter) {
val waveWidth = 46f
cubicTo(startPoint.x + waveWidth * 0.35f, startPoint.y - 12f, startPoint.x + waveWidth * 0.65f, startPoint.y + 12f, startPoint.x + waveWidth, startPoint.y)
val waveWidth = 70f
if (isStraight) {
lineTo(x + waveWidth, y)
} else {
val amplitude = 20f
cubicTo(
x + waveWidth * 0.35f,
y - amplitude,
x + waveWidth * 0.65f,
y + amplitude,
x + waveWidth,
y
)
}
} else {
cubicTo(startPoint.x + 22f, startPoint.y - 24f, startPoint.x - 22f, startPoint.y - 52f, startPoint.x - 9f, startPoint.y - 28f)
cubicTo(startPoint.x - 3f, startPoint.y - 8f, startPoint.x + 32f, startPoint.y - 16f, startPoint.x + 44f, startPoint.y - 34f)
cubicTo(
x + 16f,
y - 18f,
x - 18f,
y - 34f,
x - 7f,
y - 21f
)
cubicTo(
x - 2f,
y - 5f,
x + 22f,
y - 11f,
x + 31f,
y - 26f
)
}
}
val revealProgress = sharedPdfInkPreviewRevealProgress(progress)
val pathMeasure = PathMeasure()
pathMeasure.setPath(path, false)
val revealedPath = Path()
val targetLength = pathMeasure.length * revealProgress
if (targetLength <= 0f || !pathMeasure.getSegment(0f, targetLength, revealedPath, true)) return
val width = SharedPdfInkRenderer.effectiveStrokeWidthPx(strokeWidth, pageWidthPx = 700f)
.coerceIn(if (tool.isHighlighter) 5f else 1.2f, if (tool.isHighlighter) 16f else 5f)
drawPath(
path = path,
color = color.copy(alpha = color.alpha * progress),
path = revealedPath,
color = color,
style = Stroke(
width = width,
cap = if (tool == PdfInkTool.HIGHLIGHTER) StrokeCap.Butt else StrokeCap.Round,
@ -2571,6 +2666,10 @@ private fun DrawScope.drawInkPreview(
)
}
internal fun sharedPdfInkPreviewRevealProgress(progress: Float): Float {
return progress.coerceIn(0f, 1f)
}
private val PdfInkTool.isDesktopPenTool: Boolean
get() = this == PdfInkTool.FOUNTAIN_PEN || this == PdfInkTool.PEN || this == PdfInkTool.PENCIL

View file

@ -242,6 +242,7 @@ fun SharedReaderScreen(
readerTexturePreviewContent: (@Composable (String, Modifier) -> Unit)? = null,
readerCustomTextureIds: List<String> = emptyList(),
onImportReaderTexture: ((ReaderSettings) -> ReaderSettings?)? = null,
preferNativeVerticalReader: Boolean = false,
bottomChromeExtraContent: @Composable ColumnScope.() -> Unit = {},
useDetachedChromeLayer: Boolean = true,
useDetachedPanelLayer: Boolean = true,
@ -597,6 +598,21 @@ fun SharedReaderScreen(
ttsRequestId = ttsRequestId
)
val renderPlan = if (settings.readingMode == ReaderReadingMode.VERTICAL) {
if (preferNativeVerticalReader) {
ReaderContentRenderPlan.NativeVerticalPages(
book = readerState.book,
pages = readerState.pages,
currentPageIndex = readerState.currentPageIndex,
settings = settings,
searchQuery = session.searchQuery,
searchOptions = session.searchOptions,
highlightPalette = highlightPalette,
background = background,
foreground = foreground,
navigationTarget = navigationTarget,
highlights = session.highlights
)
} else {
val lastChapterIndex = readerState.book.chapters.lastIndex
val activeChapterIndex = if (lastChapterIndex >= 0) {
readerState.currentPage?.chapterIndex?.takeIf { it in 0..lastChapterIndex }
@ -761,6 +777,7 @@ fun SharedReaderScreen(
navigationTarget = navigationTarget,
highlights = session.highlights
)
}
} else {
ReaderContentRenderPlan.NativePaginatedPages(
visiblePages = readerState.visiblePages,

View file

@ -78,9 +78,9 @@ class ReaderAppearanceModelsTest {
@Test
fun `pdf highlighter defaults follow android pdf highlight slots`() {
val expectedPdfColors = SharedPdfAndroidHighlightColors.palette.take(4)
val expectedPdfColors = SharedPdfAndroidHighlightColors.palette.take(5)
assertEquals(4, SharedPdfHighlighterPalette.MaxColors)
assertEquals(5, SharedPdfHighlighterPalette.MaxColors)
assertEquals(expectedPdfColors, SharedPdfHighlighterPalette.defaultColors)
assertEquals(expectedPdfColors[0], SharedPdfAnnotationDefaults.configFor(PdfInkTool.HIGHLIGHTER).colorArgb)
assertEquals(expectedPdfColors[1], SharedPdfAnnotationDefaults.configFor(PdfInkTool.HIGHLIGHTER_ROUND).colorArgb)
@ -88,7 +88,7 @@ class ReaderAppearanceModelsTest {
val custom = SharedPdfHighlighterPalette(
colors = expectedPdfColors + listOf(0xFFFF00FF.toInt())
).sanitized()
assertEquals(4, custom.colors.size)
assertEquals(5, custom.colors.size)
assertEquals(expectedPdfColors, custom.colors)
}

View file

@ -0,0 +1,324 @@
package com.aryan.reader.shared.ui
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import com.aryan.reader.paginatedreader.BlockStyle
import com.aryan.reader.paginatedreader.BorderStyle
import com.aryan.reader.paginatedreader.CssStyle
import com.aryan.reader.paginatedreader.SemanticImage
import com.aryan.reader.paginatedreader.SemanticMath
import com.aryan.reader.paginatedreader.SemanticParagraph
import com.aryan.reader.paginatedreader.SemanticWrappingBlock
import com.aryan.reader.shared.reader.ReaderPage
import com.aryan.reader.shared.reader.SharedEpubBook
import com.aryan.reader.shared.reader.SharedEpubChapter
import com.aryan.reader.shared.reader.resolveSharedReaderFontFeatureSettings
import com.aryan.reader.shared.reader.resolveSharedReaderTextAlign
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNotNull
class SharedNativeVerticalReaderFlowTest {
@Test
fun `shared native visibility hides css hidden blocks`() {
assertEquals(true, BlockStyle(visibility = "hidden").isSharedNativeVisibilityHidden())
assertEquals(false, BlockStyle(visibility = "visible").isSharedNativeVisibilityHidden())
assertEquals(false, BlockStyle().isSharedNativeVisibilityHidden())
}
@Test
fun `shared native background image extracts usable image paths`() {
assertEquals(
"images/paper.png",
BlockStyle(backgroundImage = """url("images/paper.png")""").sharedNativeBackgroundImagePath()
)
assertEquals(
"images/paper.png",
BlockStyle(backgroundImage = """url( "images/paper.png" )""").sharedNativeBackgroundImagePath()
)
assertEquals(
"data:image/png;base64,abc",
BlockStyle(backgroundImage = "data:image/png;base64,abc").sharedNativeBackgroundImagePath()
)
assertEquals(
null,
BlockStyle(backgroundImage = "NONE").sharedNativeBackgroundImagePath()
)
assertEquals(
null,
BlockStyle(backgroundImage = "linear-gradient(red, blue)").sharedNativeBackgroundImagePath()
)
}
@Test
fun `shared native background image keeps fit and position styles`() {
val image = BlockStyle(
backgroundImage = """url("images/paper.png")""",
objectFit = "cover",
objectPosition = "left top",
filter = "invert(100%)"
).toSharedNativeBackgroundImage(blockIndex = 9)
assertNotNull(image)
assertEquals("images/paper.png", image.path)
assertEquals("", image.altText)
assertEquals("cover", image.style.blockStyle.objectFit)
assertEquals("left top", image.style.blockStyle.objectPosition)
assertEquals("invert(100%)", image.style.blockStyle.filter)
assertEquals(9, image.blockIndex)
}
@Test
fun `shared native link style makes links visible`() {
val style = sharedNativeReaderLinkSpanStyle(
isDarkTheme = false,
themeBackgroundColor = Color.White,
themeTextColor = Color.Black
)
assertEquals(true, style.color.isSpecified)
assertEquals(true, style.background.isSpecified)
assertEquals(true, style.textDecoration?.contains(TextDecoration.Underline))
}
@Test
fun `css justify downgrades unless shared setting explicitly forces alignment`() {
assertEquals(
TextAlign.Left,
resolveSharedReaderTextAlign(
cssTextAlign = TextAlign.Justify,
fallbackTextAlign = TextAlign.Start
)
)
assertEquals(
TextAlign.Justify,
resolveSharedReaderTextAlign(
cssTextAlign = TextAlign.Justify,
fallbackTextAlign = TextAlign.Justify
)
)
assertEquals(
TextAlign.Right,
resolveSharedReaderTextAlign(
cssTextAlign = TextAlign.Center,
fallbackTextAlign = TextAlign.Right
)
)
}
@Test
fun `css numeric font variants map to shared native font features`() {
assertEquals(
""""tnum" on, "zero" on""",
resolveSharedReaderFontFeatureSettings(
existingSettings = null,
fontVariantNumeric = "tabular-nums slashed-zero"
)
)
assertEquals(
""""smcp" on, "onum" on, "pnum" on""",
resolveSharedReaderFontFeatureSettings(
existingSettings = """"smcp" on""",
fontVariantNumeric = "oldstyle-nums proportional-nums"
)
)
}
@Test
fun `semantic images and styles stay in native vertical flow`() {
val paragraphStyle = CssStyle(
blockStyle = BlockStyle(
backgroundColor = Color(0xFFEFEFEF),
borderTop = BorderStyle(width = 1.dp, color = Color.Red)
)
)
val paragraph = SemanticParagraph(
text = "Styled paragraph",
spans = emptyList(),
style = paragraphStyle,
elementId = "p1",
cfi = "/4/2",
startCharOffsetInSource = 0,
blockIndex = 1
)
val imageStyle = CssStyle(
blockStyle = BlockStyle(
width = 120.dp,
height = 80.dp,
objectFit = "cover"
)
)
val image = SemanticImage(
path = "data:image/png;base64,iVBORw0KGgo=",
altText = "cover",
intrinsicWidth = 120f,
intrinsicHeight = 80f,
style = imageStyle,
elementId = "img1",
cfi = "/4/4",
blockIndex = 2
)
val book = SharedEpubBook(
id = "book",
fileName = "book.epub",
title = "Book",
chapters = listOf(
SharedEpubChapter(
id = "chapter_0",
title = "Chapter",
plainText = "Styled paragraph",
semanticBlocks = listOf(paragraph, image)
)
)
)
val items = buildSharedNativeVerticalFlowItems(book, pages = emptyList())
assertEquals(
listOf(SharedNativeVerticalFlowItemKind.BLOCK, SharedNativeVerticalFlowItemKind.BLOCK),
items.map { it.kind }
)
val imageItem = items.single { it.block is SemanticImage }
val flowImage = assertIs<SemanticImage>(imageItem.block)
assertEquals("data:image/png;base64,iVBORw0KGgo=", flowImage.path)
assertEquals("cover", flowImage.style.blockStyle.objectFit)
assertEquals(2, imageItem.page.semanticBlocks.single().blockIndex)
val paragraphItem = assertNotNull(items.first().block)
assertEquals(Color(0xFFEFEFEF), paragraphItem.style.blockStyle.backgroundColor)
assertEquals(Color.Red, paragraphItem.style.blockStyle.borderTop?.color)
}
@Test
fun `svg math blocks stay in native vertical flow`() {
val math = SemanticMath(
svgContent = """<svg width="24" height="12" viewBox="0 0 24 12"><text>x</text></svg>""",
altText = "Equation",
svgWidth = "24",
svgHeight = "12",
svgViewBox = "0 0 24 12",
isFromMathJax = false,
style = CssStyle(),
elementId = "eq1",
cfi = "/4/6",
blockIndex = 3
)
val book = SharedEpubBook(
id = "book",
fileName = "book.epub",
title = "Book",
chapters = listOf(
SharedEpubChapter(
id = "chapter_0",
title = "Chapter",
plainText = "Equation",
semanticBlocks = listOf(math)
)
)
)
val items = buildSharedNativeVerticalFlowItems(book, pages = emptyList())
assertEquals(1, items.size)
assertEquals(SharedNativeVerticalFlowItemKind.BLOCK, items.single().kind)
val flowMath = assertIs<SemanticMath>(items.single().block)
assertEquals("""<svg width="24" height="12" viewBox="0 0 24 12"><text>x</text></svg>""", flowMath.svgContent)
assertEquals(3, items.single().page.semanticBlocks.single().blockIndex)
}
@Test
fun `floated image wrapping blocks stay grouped in native vertical flow`() {
val image = SemanticImage(
path = "cover.png",
altText = "Cover",
intrinsicWidth = 120f,
intrinsicHeight = 180f,
style = CssStyle(blockStyle = BlockStyle(float = "left")),
elementId = "img1",
cfi = "/4/2",
blockIndex = 4
)
val firstParagraph = SemanticParagraph(
text = "Wrapped first",
spans = emptyList(),
style = CssStyle(),
elementId = "p1",
cfi = "/4/4",
startCharOffsetInSource = 0,
blockIndex = 5
)
val secondParagraph = SemanticParagraph(
text = "Wrapped second",
spans = emptyList(),
style = CssStyle(),
elementId = "p2",
cfi = "/4/6",
startCharOffsetInSource = 14,
blockIndex = 6
)
val wrappingBlock = SemanticWrappingBlock(
floatedImage = image,
paragraphsToWrap = listOf(firstParagraph, secondParagraph),
style = CssStyle(),
elementId = "wrap1",
cfi = "/4/2",
blockIndex = 7
)
val book = SharedEpubBook(
id = "book",
fileName = "book.epub",
title = "Book",
chapters = listOf(
SharedEpubChapter(
id = "chapter_0",
title = "Chapter",
plainText = "Wrapped first\nWrapped second",
semanticBlocks = listOf(wrappingBlock)
)
)
)
val items = buildSharedNativeVerticalFlowItems(book, pages = emptyList())
assertEquals(1, items.size)
assertEquals(SharedNativeVerticalFlowItemKind.BLOCK, items.single().kind)
val flowWrappingBlock = assertIs<SemanticWrappingBlock>(items.single().block)
assertEquals("cover.png", flowWrappingBlock.floatedImage.path)
assertEquals(listOf(5, 6), flowWrappingBlock.paragraphsToWrap.map { it.blockIndex })
assertEquals(listOf(7), items.single().page.semanticBlocks.map { it.blockIndex })
assertEquals("Wrapped first\nWrapped second", items.single().page.text)
}
@Test
fun `plain text pages are used when a chapter has no semantic blocks`() {
val page = ReaderPage(
pageIndex = 0,
chapterIndex = 0,
chapterTitle = "Chapter",
text = "Plain text",
startOffset = 0,
endOffset = 10
)
val book = SharedEpubBook(
id = "book",
fileName = "book.txt",
title = "Book",
chapters = listOf(
SharedEpubChapter(
id = "chapter_0",
title = "Chapter",
plainText = "Plain text"
)
)
)
val items = buildSharedNativeVerticalFlowItems(book, pages = listOf(page))
assertEquals(1, items.size)
assertEquals(SharedNativeVerticalFlowItemKind.TEXT_PAGE, items.single().kind)
assertEquals(page, items.single().page)
}
}

View file

@ -74,4 +74,43 @@ class SharedPdfAnnotationUiTest {
sharedPdfInteractionDockItems(tools = listOf(PdfInkTool.TEXT))
)
}
@Test
fun `tool settings palette matches highlighter colors by rgb`() {
val paletteColor = Color(0xFFFFEB3B).copy(alpha = 0.55f).toArgb()
val selectedColor = Color(0xFFFFEB3B).copy(alpha = 0.25f).toArgb()
assertEquals(
0,
sharedPdfSettingsSelectedPaletteIndex(
activePalette = listOf(paletteColor),
selectedColor = selectedColor,
matchRgbOnly = true
)
)
assertEquals(
-1,
sharedPdfSettingsSelectedPaletteIndex(
activePalette = listOf(paletteColor),
selectedColor = selectedColor,
matchRgbOnly = false
)
)
}
@Test
fun `tool settings slider display percent clamps like android popup`() {
val range = 0.01f..0.06f
assertEquals(1, sharedPdfSettingsDisplayPercent(0.0f, range))
assertEquals(50, sharedPdfSettingsDisplayPercent(0.035f, range))
assertEquals(100, sharedPdfSettingsDisplayPercent(0.10f, range))
}
@Test
fun `ink preview reveal progress clamps to animation range`() {
assertEquals(0f, sharedPdfInkPreviewRevealProgress(-0.5f))
assertEquals(0.45f, sharedPdfInkPreviewRevealProgress(0.45f))
assertEquals(1f, sharedPdfInkPreviewRevealProgress(1.5f))
}
}

View file

@ -1,6 +1,7 @@
package com.aryan.reader.shared.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Spacer
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -9,6 +10,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
@ -18,11 +20,17 @@ import androidx.compose.ui.layout.ContentScale
import com.aryan.reader.paginatedreader.SemanticImage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.skia.Data
import org.jetbrains.skia.Surface
import org.jetbrains.skia.svg.SVGDOM
import org.jetbrains.skia.svg.SVGLengthContext
import org.jetbrains.skia.Color as SkiaColor
import org.jetbrains.skia.Image as SkiaImage
import java.io.ByteArrayInputStream
import java.io.File
import java.util.Base64
import javax.imageio.ImageIO
import kotlin.math.roundToInt
@Composable
fun DesktopEpubNativeImage(
@ -42,14 +50,20 @@ fun DesktopEpubNativeImage(
}
val currentBitmap = bitmap
val isDecorative = image.altText != null && image.altText.isBlank()
if (currentBitmap != null) {
Image(
bitmap = currentBitmap,
contentDescription = image.altText ?: "Image from EPUB",
contentDescription = image.altText
?.takeIf { it.isNotBlank() }
?: if (isDecorative) null else "Image from EPUB",
modifier = modifier,
contentScale = ContentScale.Fit,
contentScale = image.readerImageContentScale(),
alignment = desktopEpubImageContentAlignment(image.style.blockStyle.objectPosition),
colorFilter = image.readerImageColorFilter()
)
} else if (isDecorative) {
Spacer(modifier = modifier)
} else {
Text(
text = image.altText?.takeIf { it.isNotBlank() } ?: image.path.substringAfterLast('/').substringAfterLast('\\'),
@ -108,6 +122,9 @@ private object DesktopEpubNativeImageCache {
private fun decode(source: DesktopEpubImageSource): ImageBitmap? {
val bytes = source.bytes() ?: return null
if (source.isSvg) {
decodeSvg(bytes)?.let { return it }
}
runCatching {
ImageIO.read(ByteArrayInputStream(bytes))?.toComposeImageBitmap()
}.getOrNull()?.let { return it }
@ -116,19 +133,60 @@ private object DesktopEpubNativeImageCache {
SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap()
}.getOrNull()
}
private fun decodeSvg(bytes: ByteArray): ImageBitmap? {
var data: Data? = null
var dom: SVGDOM? = null
var surface: Surface? = null
return runCatching {
data = Data.makeFromBytes(bytes)
dom = SVGDOM(data!!)
val root = dom?.root
val viewBox = root?.viewBox
val intrinsic = root?.getIntrinsicSize(SVGLengthContext(DefaultSvgViewportPx, DefaultSvgViewportPx))
val width = (intrinsic?.x?.takeIf { it.isFinite() && it > 0f }
?: viewBox?.width?.takeIf { it.isFinite() && it > 0f }
?: DefaultSvgViewportPx)
.roundToInt()
.coerceIn(1, MaxSvgRasterDimensionPx)
val height = (intrinsic?.y?.takeIf { it.isFinite() && it > 0f }
?: viewBox?.height?.takeIf { it.isFinite() && it > 0f }
?: DefaultSvgViewportPx)
.roundToInt()
.coerceIn(1, MaxSvgRasterDimensionPx)
dom?.setContainerSize(width.toFloat(), height.toFloat())
surface = Surface.makeRasterN32Premul(width, height)
val canvas = surface!!.canvas
canvas.clear(SkiaColor.TRANSPARENT)
dom?.render(canvas)
surface!!.makeImageSnapshot().toComposeImageBitmap()
}.getOrNull().also {
surface?.close()
dom?.close()
data?.close()
}
}
}
private sealed class DesktopEpubImageSource(
val key: String,
val length: Long?,
val lastModified: Long?
val lastModified: Long?,
val mimeType: String?
) {
abstract fun bytes(): ByteArray?
val isSvg: Boolean
get() = mimeType.equals("image/svg+xml", ignoreCase = true) ||
key.substringBefore('?').substringBefore('#').endsWith(".svg", ignoreCase = true)
data class FileSource(private val file: File) : DesktopEpubImageSource(
key = file.absolutePath,
length = file.length(),
lastModified = file.lastModified()
lastModified = file.lastModified(),
mimeType = file.extension
.takeIf { it.equals("svg", ignoreCase = true) }
?.let { "image/svg+xml" }
) {
override fun bytes(): ByteArray? = runCatching { file.readBytes() }.getOrNull()
}
@ -136,7 +194,11 @@ private sealed class DesktopEpubImageSource(
data class DataUriSource(private val path: String) : DesktopEpubImageSource(
key = path,
length = path.length.toLong(),
lastModified = null
lastModified = null,
mimeType = path.substringAfter("data:", missingDelimiterValue = "")
.substringBefore(';')
.substringBefore(',')
.takeIf { it.isNotBlank() }
) {
override fun bytes(): ByteArray? {
val marker = "base64,"
@ -159,6 +221,9 @@ private sealed class DesktopEpubImageSource(
}
}
private const val DefaultSvgViewportPx = 512f
private const val MaxSvgRasterDimensionPx = 4096
private fun SemanticImage.readerImageColorFilter(): ColorFilter? {
if (style.blockStyle.filter != "invert(100%)") return null
return ColorFilter.colorMatrix(
@ -172,3 +237,90 @@ private fun SemanticImage.readerImageColorFilter(): ColorFilter? {
)
)
}
private fun SemanticImage.readerImageContentScale(): ContentScale {
return when (style.blockStyle.objectFit) {
"cover" -> ContentScale.Crop
"fill" -> ContentScale.FillBounds
"contain", "scale-down" -> ContentScale.Fit
else -> ContentScale.Fit
}
}
internal fun desktopEpubImageContentAlignment(objectPosition: String?): Alignment {
val tokens = objectPosition
?.lowercase()
?.split(Regex("\\s+"))
?.map { it.trim() }
?.filter { it.isNotBlank() }
?: return Alignment.Center
val orderedHorizontal = tokens.getOrNull(0)?.toDesktopObjectPositionHorizontal()
val orderedVertical = tokens.getOrNull(1)?.toDesktopObjectPositionVertical()
val horizontal = orderedHorizontal
?: tokens.firstNotNullOfOrNull { it.toDesktopObjectPositionHorizontalKeyword() }
?: DesktopObjectPositionAxis.CENTER
val vertical = orderedVertical
?: tokens.firstNotNullOfOrNull { it.toDesktopObjectPositionVerticalKeyword() }
?: DesktopObjectPositionAxis.CENTER
return when (vertical) {
DesktopObjectPositionAxis.START -> when (horizontal) {
DesktopObjectPositionAxis.START -> Alignment.TopStart
DesktopObjectPositionAxis.END -> Alignment.TopEnd
else -> Alignment.TopCenter
}
DesktopObjectPositionAxis.END -> when (horizontal) {
DesktopObjectPositionAxis.START -> Alignment.BottomStart
DesktopObjectPositionAxis.END -> Alignment.BottomEnd
else -> Alignment.BottomCenter
}
DesktopObjectPositionAxis.CENTER -> when (horizontal) {
DesktopObjectPositionAxis.START -> Alignment.CenterStart
DesktopObjectPositionAxis.END -> Alignment.CenterEnd
else -> Alignment.Center
}
}
}
private enum class DesktopObjectPositionAxis {
START,
CENTER,
END
}
private fun String.toDesktopObjectPositionHorizontal(): DesktopObjectPositionAxis? {
return when (this) {
"left", "0%" -> DesktopObjectPositionAxis.START
"center", "50%" -> DesktopObjectPositionAxis.CENTER
"right", "100%" -> DesktopObjectPositionAxis.END
else -> null
}
}
private fun String.toDesktopObjectPositionVertical(): DesktopObjectPositionAxis? {
return when (this) {
"top", "0%" -> DesktopObjectPositionAxis.START
"center", "50%" -> DesktopObjectPositionAxis.CENTER
"bottom", "100%" -> DesktopObjectPositionAxis.END
else -> null
}
}
private fun String.toDesktopObjectPositionHorizontalKeyword(): DesktopObjectPositionAxis? {
return when (this) {
"left" -> DesktopObjectPositionAxis.START
"center" -> DesktopObjectPositionAxis.CENTER
"right" -> DesktopObjectPositionAxis.END
else -> null
}
}
private fun String.toDesktopObjectPositionVerticalKeyword(): DesktopObjectPositionAxis? {
return when (this) {
"top" -> DesktopObjectPositionAxis.START
"center" -> DesktopObjectPositionAxis.CENTER
"bottom" -> DesktopObjectPositionAxis.END
else -> null
}
}

View file

@ -1,6 +1,8 @@
package com.aryan.reader.shared.reader
import androidx.compose.ui.text.ParagraphStyle
import androidx.compose.ui.text.style.Hyphens
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextIndent
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@ -138,6 +140,33 @@ class SharedMeasuredEpubPaginatorTest {
assertEquals(0.dp, split.second.style.blockStyle.margin.top)
}
@Test
fun `measurement paragraph style keeps css indent hyphenation and android justify rule`() {
val paragraph = SemanticParagraph(
text = "Indented paragraph",
spans = emptyList(),
style = CssStyle(
paragraphStyle = ParagraphStyle(
textAlign = TextAlign.Justify,
textIndent = TextIndent(firstLine = 20.sp, restLine = 4.sp)
),
hyphens = "auto"
),
elementId = null,
cfi = null,
startCharOffsetInSource = 0,
blockIndex = 1
)
val defaultAlignStyle = paragraph.toMeasurementParagraphStyleForPagination(TextAlign.Start)
val forcedJustifyStyle = paragraph.toMeasurementParagraphStyleForPagination(TextAlign.Justify)
assertEquals(TextAlign.Left, defaultAlignStyle.textAlign)
assertEquals(TextAlign.Justify, forcedJustifyStyle.textAlign)
assertEquals(TextIndent(firstLine = 20.sp, restLine = 4.sp), defaultAlignStyle.textIndent)
assertEquals(Hyphens.Auto, defaultAlignStyle.hyphens)
}
@Test
fun `pagination stack collapses adjacent margins and can ignore trailing bottom margin`() {
val items = listOf(

View file

@ -0,0 +1,16 @@
package com.aryan.reader.shared.ui
import androidx.compose.ui.Alignment
import kotlin.test.Test
import kotlin.test.assertEquals
class DesktopEpubNativeImageTest {
@Test
fun `desktop native epub image maps css object position to compose alignment`() {
assertEquals(Alignment.TopStart, desktopEpubImageContentAlignment("left top"))
assertEquals(Alignment.TopStart, desktopEpubImageContentAlignment("top left"))
assertEquals(Alignment.BottomStart, desktopEpubImageContentAlignment("0% 100%"))
assertEquals(Alignment.CenterEnd, desktopEpubImageContentAlignment("right center"))
assertEquals(Alignment.Center, desktopEpubImageContentAlignment(null))
}
}

View file

@ -1,6 +1,7 @@
package com.aryan.reader.shared.reader
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.ParagraphStyle
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextMeasurer
@ -8,6 +9,8 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.text.style.Hyphens
import androidx.compose.ui.text.style.LineBreak
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.text.style.TextAlign
@ -401,13 +404,7 @@ class SharedMeasuredEpubPaginator(
settings = settings,
includeTrailingBottomMargin = true
)
is SemanticWrappingBlock -> measureBlockStack(
blocks = listOf(block.floatedImage) + block.paragraphsToWrap,
geometry = geometry,
baseStyle = baseStyle,
settings = settings,
includeTrailingBottomMargin = true
)
is SemanticWrappingBlock -> measureWrapping(block, geometry, baseStyle, settings)
is SemanticImage -> measureImage(block, geometry, settings)
is SemanticMath -> measureMath(block, geometry, baseStyle, settings)
is SemanticSpacer -> if (block.isExplicitLineBreak) 8 else 16
@ -445,7 +442,7 @@ class SharedMeasuredEpubPaginator(
): Int {
currentCoroutineContext().ensureActive()
val style = block.textStyle(baseStyle, settings)
val annotated = block.toAnnotatedString(style.fontSize.value)
val annotated = block.toAnnotatedString(style.fontSize.value, style.textAlign)
val minimumLineHeight = style.lineHeight.takeIfSpecified()
?.let { lineHeight -> with(density) { lineHeight.toPx().roundToInt() } }
?: with(density) { (settings.fontSize * settings.lineSpacing).sp.toPx().roundToInt() }
@ -494,6 +491,110 @@ class SharedMeasuredEpubPaginator(
}
}
private suspend fun measureWrapping(
block: SemanticWrappingBlock,
geometry: MeasuredPageGeometry,
baseStyle: TextStyle,
settings: ReaderSettings
): Int {
val contentWidth = block.measuredTextContentWidthPx(geometry)
val imageSize = measureImageSize(block.floatedImage, geometry, settings, maxWidthPx = contentWidth)
if (imageSize.first <= 0 || imageSize.second <= 0) {
return measureBlockStack(
blocks = block.paragraphsToWrap,
geometry = geometry,
baseStyle = baseStyle,
settings = settings,
includeTrailingBottomMargin = true
)
}
val wrappingWidth = (contentWidth - imageSize.first).coerceAtLeast(0)
if (wrappingWidth <= 0) {
val paragraphHeight = measureBlockStack(
blocks = block.paragraphsToWrap,
geometry = geometry,
baseStyle = baseStyle,
settings = settings,
includeTrailingBottomMargin = true
)
return (imageSize.second + paragraphHeight).coerceAtLeast(1)
}
var currentY = 0f
block.paragraphsToWrap.forEachIndexed { index, paragraph ->
val style = paragraph.textStyle(baseStyle, settings)
val annotated = paragraph.toAnnotatedString(style.fontSize.value, style.textAlign)
currentY = measureWrappedParagraphLines(
text = annotated,
style = style,
fullWidthPx = contentWidth,
wrappingWidthPx = wrappingWidth,
currentY = currentY,
imageHeightPx = imageSize.second
)
if (index < block.paragraphsToWrap.lastIndex) {
currentY += block.paragraphsToWrap.measuredCollapsedParagraphGapPx(index, settings)
}
}
return maxOf(currentY.roundToInt(), imageSize.second).coerceAtLeast(1)
}
private suspend fun measureWrappedParagraphLines(
text: AnnotatedString,
style: TextStyle,
fullWidthPx: Int,
wrappingWidthPx: Int,
currentY: Float,
imageHeightPx: Int
): Float {
if (text.text.isBlank()) {
return currentY + (style.lineHeight.takeIfSpecified()?.let { with(density) { it.toPx() } } ?: 1f)
}
var y = currentY
var textOffset = 0
while (textOffset < text.length) {
currentCoroutineContext().ensureActive()
val isBesideImage = y < imageHeightPx
val currentMaxWidth = if (isBesideImage) wrappingWidthPx else fullWidthPx
if (currentMaxWidth <= 0) {
if (isBesideImage) {
y = imageHeightPx.toFloat()
continue
}
break
}
val remaining = text.subSequence(textOffset, text.length)
val measuredRemaining = measureTextLayout(remaining, style, currentMaxWidth)
val firstLineEndOffset = measuredRemaining.getLineEnd(0, visibleEnd = true)
if (firstLineEndOffset == 0 && remaining.length > 0) {
textOffset++
continue
}
if (firstLineEndOffset == 0) break
val lineText = remaining.subSequence(0, firstLineEndOffset)
y += measureTextLayout(lineText, style, currentMaxWidth).size.height
textOffset += firstLineEndOffset
while (textOffset < text.length && text.text[textOffset].isWhitespace()) {
textOffset++
}
}
return y
}
private fun List<SemanticParagraph>.measuredCollapsedParagraphGapPx(
index: Int,
settings: ReaderSettings
): Int {
val current = getOrNull(index) ?: return 0
val next = getOrNull(index + 1) ?: return 0
val explicitGap = maxOf(
current.style.blockStyle.margin.bottom.toPxIfSpecified(),
next.style.blockStyle.margin.top.toPxIfSpecified()
)
return explicitGap.takeIf { it != 0 } ?: settings.renderedDefaultBlockSpacingPx()
}
private suspend fun measureMath(
block: SemanticMath,
geometry: MeasuredPageGeometry,
@ -518,13 +619,23 @@ class SharedMeasuredEpubPaginator(
}
private fun measureImage(block: SemanticImage, geometry: MeasuredPageGeometry, settings: ReaderSettings): Int {
return measureImageSize(block, geometry, settings, maxWidthPx = geometry.pageWidthPx)
.second
}
private fun measureImageSize(
block: SemanticImage,
geometry: MeasuredPageGeometry,
settings: ReaderSettings,
maxWidthPx: Int
): Pair<Int, Int> {
val width = block.intrinsicWidth?.takeIf { it > 0f }
val height = block.intrinsicHeight?.takeIf { it > 0f }
val imageScale = settings.imageScale.coerceIn(0.5f, 2.0f)
val measured = when {
when {
width != null && height != null -> {
val style = block.style.blockStyle
val contentMaxWidth = geometry.pageWidthPx.toFloat()
val contentMaxWidth = maxWidthPx.toFloat()
val baseWidth = if (style.width.isSpecified && style.width > 0.dp) {
style.width.toPxInt().toFloat()
} else {
@ -535,12 +646,30 @@ class SharedMeasuredEpubPaginator(
scaledWidth = scaledWidth.coerceAtMost(style.maxWidth.toPxInt() * imageScale)
}
scaledWidth = scaledWidth.coerceAtMost(contentMaxWidth)
(scaledWidth * (height / width)).roundToInt()
val measuredWidth = scaledWidth.roundToInt().coerceAtLeast(1)
val measuredHeight = (scaledWidth * (height / width)).roundToInt()
return measuredWidth to measuredHeight.coerceIn(
24,
(geometry.pageHeightPx * 0.86f).roundToInt().coerceAtLeast(24)
)
}
block.style.blockStyle.height.isSpecified && block.style.blockStyle.height > 0.dp -> block.style.blockStyle.height.toPxInt()
else -> with(density) { (settings.fontSize * 8f).sp.toPx().roundToInt() }
}
return measured.coerceIn(24, (geometry.pageHeightPx * 0.86f).roundToInt().coerceAtLeast(24))
val style = block.style.blockStyle
val measuredWidth = when {
style.width.isSpecified && style.width > 0.dp -> style.width.toPxInt()
style.maxWidth.isSpecified && style.maxWidth > 0.dp -> minOf(maxWidthPx, style.maxWidth.toPxInt())
else -> maxWidthPx
}.coerceAtLeast(1)
val measuredHeight = if (style.height.isSpecified && style.height > 0.dp) {
style.height.toPxInt()
} else {
with(density) { (settings.fontSize * 8f).sp.toPx().roundToInt() }
}
val coercedHeight = measuredHeight.coerceIn(
24,
(geometry.pageHeightPx * 0.86f).roundToInt().coerceAtLeast(24)
)
return measuredWidth to coercedHeight
}
private suspend fun splitBlock(
@ -579,7 +708,7 @@ class SharedMeasuredEpubPaginator(
if (availableTextHeight <= 0) return null
val layoutResult = measureTextLayout(
text = block.toAnnotatedString(style.fontSize.value),
text = block.toAnnotatedString(style.fontSize.value, style.textAlign),
style = style,
widthPx = contentWidth
)
@ -598,7 +727,7 @@ class SharedMeasuredEpubPaginator(
val remaining = splitSemanticTextBlockAtOffsetForPagination(block, splitOffset)?.second
if (remaining != null && remaining.text.isNotBlank()) {
val remainingLayout = measureTextLayout(
text = remaining.toAnnotatedString(style.fontSize.value),
text = remaining.toAnnotatedString(style.fontSize.value, style.textAlign),
style = style,
widthPx = contentWidth
)
@ -957,19 +1086,60 @@ private fun SemanticBlock.textBlocks(): List<SemanticTextBlock> {
}
}
private fun SemanticTextBlock.toAnnotatedString(blockFontSizeSp: Float): AnnotatedString {
private fun SemanticTextBlock.toAnnotatedString(
blockFontSizeSp: Float,
fallbackTextAlign: TextAlign
): AnnotatedString {
return buildAnnotatedString {
append(text)
withStyle(toMeasurementParagraphStyleForPagination(fallbackTextAlign)) {
append(text)
}
spans.forEach { span ->
val start = span.start.coerceIn(0, text.length)
val end = span.end.coerceIn(start, text.length)
if (start < end) {
addStyle(span.style.toMeasurementSpanStyle(blockFontSizeSp), start, end)
addMeasurementWordSpacing(
text = text,
start = start,
end = end,
wordSpacing = span.style.wordSpacing
)
}
}
}
}
internal fun SemanticTextBlock.toMeasurementParagraphStyleForPagination(fallbackTextAlign: TextAlign): ParagraphStyle {
return ParagraphStyle(
textAlign = resolveSharedReaderTextAlign(
cssTextAlign = style.paragraphStyle.textAlign,
fallbackTextAlign = fallbackTextAlign
),
textIndent = style.paragraphStyle.textIndent,
lineBreak = LineBreak.Paragraph,
hyphens = style.toMeasurementHyphens()
)
}
private fun AnnotatedString.Builder.addMeasurementWordSpacing(
text: String,
start: Int,
end: Int,
wordSpacing: TextUnit
) {
if (!wordSpacing.isSpecified || wordSpacing.value == 0f) return
for (index in start until end) {
if (text[index] == ' ') {
addStyle(SpanStyle(letterSpacing = wordSpacing), index, index + 1)
}
}
}
private fun CssStyle.toMeasurementHyphens(): Hyphens {
return if (hyphens == "auto") Hyphens.Auto else Hyphens.None
}
private fun SemanticTextBlock.textStyle(baseStyle: TextStyle, settings: ReaderSettings): TextStyle {
val fontSize = (style.fontSize.takeIfSpecified()
?: style.spanStyle.fontSize.takeIfSpecified())
@ -989,7 +1159,10 @@ private fun SemanticTextBlock.textStyle(baseStyle: TextStyle, settings: ReaderSe
fontSize = fontSize,
lineHeight = lineHeight,
fontWeight = if (this is SemanticHeader) FontWeight.Bold else baseStyle.fontWeight,
textAlign = style.paragraphStyle.textAlign.takeUnless { it == TextAlign.Unspecified } ?: baseStyle.textAlign
textAlign = resolveSharedReaderTextAlign(
cssTextAlign = style.paragraphStyle.textAlign,
fallbackTextAlign = baseStyle.textAlign
)
).withAndroidPaginationTextMetrics()
}
@ -1023,11 +1196,13 @@ private fun TextUnit.resolveLineHeightSp(fontSizeSp: Float): TextUnit {
private fun CssStyle.toMeasurementSpanStyle(parentFontSizeSp: Float): SpanStyle {
val resolvedFontSize = (spanStyle.fontSize.takeIfSpecified() ?: fontSize.takeIfSpecified())
?.resolveFontSizeSp(parentFontSizeSp)
return if (resolvedFontSize == null) {
spanStyle
} else {
spanStyle.copy(fontSize = resolvedFontSize)
}
return spanStyle.copy(
fontSize = resolvedFontSize ?: spanStyle.fontSize,
fontFeatureSettings = resolveSharedReaderFontFeatureSettings(
existingSettings = spanStyle.fontFeatureSettings,
fontVariantNumeric = fontVariantNumeric
)
)
}
private fun headerScale(level: Int): Float {