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:
parent
e4634ed251
commit
6651169ce2
60 changed files with 14457 additions and 468 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue