Compare commits
4 Commits
8e7dcc0307
...
3e83185e4f
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e83185e4f | |||
| 65925f2a07 | |||
| 17c885b3f5 | |||
| 89b1115169 |
1
.gitignore
vendored
@ -18,3 +18,4 @@ captures
|
|||||||
**/xcshareddata/WorkspaceSettings.xcsettings
|
**/xcshareddata/WorkspaceSettings.xcsettings
|
||||||
node_modules/
|
node_modules/
|
||||||
/composeApp/release/
|
/composeApp/release/
|
||||||
|
/test_books/
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
|
|||||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
|
||||||
val appVersionName = "1.0"
|
val appVersionName = "1.0"
|
||||||
val appVersionCode = 3
|
val appVersionCode = 4
|
||||||
val appVersionDisplay = "$appVersionName.$appVersionCode"
|
val appVersionDisplay = "$appVersionName.$appVersionCode"
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
|
|||||||
@ -46,6 +46,11 @@ private data class PendingLibraryDelete(
|
|||||||
val restore: () -> Unit,
|
val restore: () -> Unit,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
internal data class LibraryItemRefreshRequest(
|
||||||
|
val id: Long,
|
||||||
|
val fileId: String,
|
||||||
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@Preview
|
@Preview
|
||||||
fun App() {
|
fun App() {
|
||||||
@ -165,15 +170,23 @@ private fun BookReaderApp(
|
|||||||
) -> Unit,
|
) -> Unit,
|
||||||
) {
|
) {
|
||||||
var state by remember { mutableStateOf<AppState>(AppState.LoadingStartup) }
|
var state by remember { mutableStateOf<AppState>(AppState.LoadingStartup) }
|
||||||
|
var libraryBackState by remember { mutableStateOf<AppState.Library?>(null) }
|
||||||
var activeScan by remember { mutableStateOf<LibraryScanProgress?>(null) }
|
var activeScan by remember { mutableStateOf<LibraryScanProgress?>(null) }
|
||||||
var scanJob by remember { mutableStateOf<Job?>(null) }
|
var scanJob by remember { mutableStateOf<Job?>(null) }
|
||||||
var pendingDelete by remember { mutableStateOf<PendingLibraryDelete?>(null) }
|
var pendingDelete by remember { mutableStateOf<PendingLibraryDelete?>(null) }
|
||||||
var pendingDeleteJob by remember { mutableStateOf<Job?>(null) }
|
var pendingDeleteJob by remember { mutableStateOf<Job?>(null) }
|
||||||
var hiddenDeletedFileIds by remember { mutableStateOf<Set<String>>(emptySet()) }
|
var hiddenDeletedFileIds by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||||
var nextDeleteId by remember { mutableStateOf(0L) }
|
var nextDeleteId by remember { mutableStateOf(0L) }
|
||||||
|
var nextLibraryItemRefreshId by remember { mutableStateOf(0L) }
|
||||||
|
var libraryItemRefreshRequest by remember { mutableStateOf<LibraryItemRefreshRequest?>(null) }
|
||||||
var imageViewer by remember { mutableStateOf<ViewedBookImage?>(null) }
|
var imageViewer by remember { mutableStateOf<ViewedBookImage?>(null) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
fun refreshLibraryItem(fileId: String) {
|
||||||
|
nextLibraryItemRefreshId += 1
|
||||||
|
libraryItemRefreshRequest = LibraryItemRefreshRequest(nextLibraryItemRefreshId, fileId)
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
state = loadStartupState()
|
state = loadStartupState()
|
||||||
}
|
}
|
||||||
@ -279,7 +292,11 @@ private fun BookReaderApp(
|
|||||||
)
|
)
|
||||||
is AppState.Reader -> {
|
is AppState.Reader -> {
|
||||||
scope.launch { saveActiveReadingFileId(null) }
|
scope.launch { saveActiveReadingFileId(null) }
|
||||||
AppState.Library(current.libraryItems, current.scanPath, current.message)
|
refreshLibraryItem(current.fileId)
|
||||||
|
val backState = libraryBackState?.copy(message = current.message)
|
||||||
|
?: AppState.Library(current.libraryItems, current.scanPath, current.message)
|
||||||
|
libraryBackState = null
|
||||||
|
backState
|
||||||
}
|
}
|
||||||
is AppState.Scan -> AppState.Library(current.items, current.scanPath, current.message)
|
is AppState.Scan -> AppState.Library(current.items, current.scanPath, current.message)
|
||||||
is AppState.Error -> AppState.LoadingStartup
|
is AppState.Error -> AppState.LoadingStartup
|
||||||
@ -340,17 +357,37 @@ private fun BookReaderApp(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
when (val current = state) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
AppState.LoadingStartup -> LoadingScreen(strings.loadingOpeningBook)
|
val currentLibraryState = when (val current = state) {
|
||||||
is AppState.Library -> LibraryScreen(
|
is AppState.Library -> current
|
||||||
state = current,
|
is AppState.Reader, is AppState.BookInfo -> libraryBackState
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
currentLibraryState?.let { libraryState ->
|
||||||
|
LibraryScreen(
|
||||||
|
state = libraryState,
|
||||||
activeScan = activeScan,
|
activeScan = activeScan,
|
||||||
|
itemRefreshRequest = libraryItemRefreshRequest,
|
||||||
hiddenFileIds = hiddenDeletedFileIds + pendingDelete?.request?.fileId?.let(::setOf).orEmpty(),
|
hiddenFileIds = hiddenDeletedFileIds + pendingDelete?.request?.fileId?.let(::setOf).orEmpty(),
|
||||||
onStateChange = { state = it },
|
onStateChange = { next ->
|
||||||
onNavigateToScan = { state = AppState.Scan(current.items, current.scanPath, current.message) },
|
val currentState = state
|
||||||
|
if (next is AppState.Reader && currentState is AppState.Library) {
|
||||||
|
libraryBackState = currentState
|
||||||
|
}
|
||||||
|
state = next
|
||||||
|
},
|
||||||
|
onNavigateToScan = {
|
||||||
|
libraryBackState = null
|
||||||
|
state = AppState.Scan(libraryState.items, libraryState.scanPath, libraryState.message)
|
||||||
|
},
|
||||||
onStartScan = ::startScan,
|
onStartScan = ::startScan,
|
||||||
onDeleteRequested = ::requestDelete,
|
onDeleteRequested = ::requestDelete,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
when (val current = state) {
|
||||||
|
AppState.LoadingStartup -> LoadingScreen(strings.loadingOpeningBook)
|
||||||
|
is AppState.Library -> Unit
|
||||||
is AppState.Scan -> ScanScreen(
|
is AppState.Scan -> ScanScreen(
|
||||||
state = current,
|
state = current,
|
||||||
activeScan = activeScan,
|
activeScan = activeScan,
|
||||||
@ -365,6 +402,7 @@ private fun BookReaderApp(
|
|||||||
book = current.book,
|
book = current.book,
|
||||||
onImageOpen = { imageViewer = it },
|
onImageOpen = { imageViewer = it },
|
||||||
onThemeToggle = onThemeToggle,
|
onThemeToggle = onThemeToggle,
|
||||||
|
onBookChanged = ::refreshLibraryItem,
|
||||||
onBookInfo = {
|
onBookInfo = {
|
||||||
state = AppState.BookInfo(
|
state = AppState.BookInfo(
|
||||||
fileId = current.fileId,
|
fileId = current.fileId,
|
||||||
@ -375,7 +413,9 @@ private fun BookReaderApp(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
onDeleted = { message ->
|
onDeleted = { message ->
|
||||||
state = AppState.Library(emptyList(), current.scanPath, message)
|
state = libraryBackState?.copy(message = message)
|
||||||
|
?: AppState.Library(emptyList(), current.scanPath, message)
|
||||||
|
libraryBackState = null
|
||||||
},
|
},
|
||||||
onDeleteRequested = ::requestDelete,
|
onDeleteRequested = ::requestDelete,
|
||||||
onBack = ::navigateBack,
|
onBack = ::navigateBack,
|
||||||
@ -388,6 +428,7 @@ private fun BookReaderApp(
|
|||||||
)
|
)
|
||||||
is AppState.Error -> ErrorScreen(current.message, onBack = ::navigateBack)
|
is AppState.Error -> ErrorScreen(current.message, onBack = ::navigateBack)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
imageViewer?.let { image ->
|
imageViewer?.let { image ->
|
||||||
ImageViewer(
|
ImageViewer(
|
||||||
|
|||||||
@ -82,6 +82,7 @@ import kotlinx.coroutines.launch
|
|||||||
internal fun LibraryScreen(
|
internal fun LibraryScreen(
|
||||||
state: AppState.Library,
|
state: AppState.Library,
|
||||||
activeScan: LibraryScanProgress?,
|
activeScan: LibraryScanProgress?,
|
||||||
|
itemRefreshRequest: LibraryItemRefreshRequest?,
|
||||||
hiddenFileIds: Set<String>,
|
hiddenFileIds: Set<String>,
|
||||||
onStateChange: (AppState) -> Unit,
|
onStateChange: (AppState) -> Unit,
|
||||||
onNavigateToScan: () -> Unit,
|
onNavigateToScan: () -> Unit,
|
||||||
@ -147,6 +148,13 @@ internal fun LibraryScreen(
|
|||||||
recentlyAddedItems = loadRecentlyAddedLibraryItems(since, RecentlyAddedLimit)
|
recentlyAddedItems = loadRecentlyAddedLibraryItems(since, RecentlyAddedLimit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun refreshLibraryItem(fileId: String) {
|
||||||
|
val updatedItem = loadLibraryItem(fileId) ?: return
|
||||||
|
items = items.replaceLibraryItem(updatedItem)
|
||||||
|
searchResults = searchResults.replaceLibraryItem(updatedItem)
|
||||||
|
recentlyAddedItems = recentlyAddedItems.replaceLibraryItem(updatedItem)
|
||||||
|
}
|
||||||
|
|
||||||
fun refresh(nextMessage: String? = message) {
|
fun refresh(nextMessage: String? = message) {
|
||||||
message = nextMessage
|
message = nextMessage
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@ -247,6 +255,10 @@ internal fun LibraryScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(itemRefreshRequest) {
|
||||||
|
itemRefreshRequest?.let { refreshLibraryItem(it.fileId) }
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(searchText) {
|
LaunchedEffect(searchText) {
|
||||||
val query = searchText
|
val query = searchText
|
||||||
if (query.isBlank()) {
|
if (query.isBlank()) {
|
||||||
|
|||||||
@ -46,6 +46,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.drawWithContent
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
import androidx.compose.ui.geometry.Size
|
import androidx.compose.ui.geometry.Size
|
||||||
import androidx.compose.ui.graphics.BlendMode
|
import androidx.compose.ui.graphics.BlendMode
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
@ -79,6 +80,7 @@ import androidx.compose.ui.text.style.LineBreak
|
|||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.text.withStyle
|
import androidx.compose.ui.text.withStyle
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.isSpecified
|
import androidx.compose.ui.unit.isSpecified
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
@ -96,7 +98,6 @@ import net.sergeych.toread.fb2.Fb2TextSpan
|
|||||||
import net.sergeych.toread.fb2.Fb2TextStyle
|
import net.sergeych.toread.fb2.Fb2TextStyle
|
||||||
import net.sergeych.toread.text.HyphenationRegistry
|
import net.sergeych.toread.text.HyphenationRegistry
|
||||||
import net.sergeych.toread.text.SoftHyphen
|
import net.sergeych.toread.text.SoftHyphen
|
||||||
import kotlin.math.min
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
@ -117,7 +118,7 @@ internal fun ContinuousBookReader(
|
|||||||
val hyphenation = remember { HyphenationRegistry() }
|
val hyphenation = remember { HyphenationRegistry() }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val textLineMetricsByItem = remember(contentPlan) { mutableStateMapOf<Int, TextLineMetrics>() }
|
val textLineMetricsByItem = remember(contentPlan) { mutableStateMapOf<Int, TextLineMetrics>() }
|
||||||
val contentPadding = PaddingValues(6.dp)
|
val contentPadding = PaddingValues(top=6.dp, bottom = 6.dp, start = 4.dp, end = 6.dp)
|
||||||
val userScrollConnection = remember(onUserScroll) {
|
val userScrollConnection = remember(onUserScroll) {
|
||||||
object : NestedScrollConnection {
|
object : NestedScrollConnection {
|
||||||
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
||||||
@ -945,6 +946,13 @@ private val isDesktopPlatform: Boolean by lazy {
|
|||||||
getPlatform().name.startsWith("Java")
|
getPlatform().name.startsWith("Java")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val MinimumBookImageMaxDimension = 800.dp
|
||||||
|
|
||||||
|
private fun minimumBookImageMaxDimension(availableWidth: Dp): Dp {
|
||||||
|
val relativeMinimum = availableWidth * 0.55f
|
||||||
|
return if (MinimumBookImageMaxDimension < relativeMinimum) MinimumBookImageMaxDimension else relativeMinimum
|
||||||
|
}
|
||||||
|
|
||||||
private fun TextLayoutResult.endsAtSoftHyphen(text: String, line: Int): Boolean {
|
private fun TextLayoutResult.endsAtSoftHyphen(text: String, line: Int): Boolean {
|
||||||
val end = getLineEnd(line, visibleEnd = false)
|
val end = getLineEnd(line, visibleEnd = false)
|
||||||
return text.getOrNull(end - 1) == SoftHyphen || text.getOrNull(end) == SoftHyphen
|
return text.getOrNull(end - 1) == SoftHyphen || text.getOrNull(end) == SoftHyphen
|
||||||
@ -996,8 +1004,17 @@ private fun BookImage(
|
|||||||
val imageAspectRatio = bitmap.width.toFloat() / bitmap.height.coerceAtLeast(1).toFloat()
|
val imageAspectRatio = bitmap.width.toFloat() / bitmap.height.coerceAtLeast(1).toFloat()
|
||||||
val imageModifier = if (fitBackgroundToBitmapBounds) {
|
val imageModifier = if (fitBackgroundToBitmapBounds) {
|
||||||
val bitmapWidth = with(density) { bitmap.width.toDp() }
|
val bitmapWidth = with(density) { bitmap.width.toDp() }
|
||||||
|
val bitmapHeight = with(density) { bitmap.height.toDp() }
|
||||||
|
val bitmapMaxDimension = if (bitmapWidth > bitmapHeight) bitmapWidth else bitmapHeight
|
||||||
|
val minimumMaxDimension = minimumBookImageMaxDimension(maxWidth)
|
||||||
|
val displayWidth = when {
|
||||||
|
bitmapMaxDimension < minimumMaxDimension ->
|
||||||
|
bitmapWidth * (minimumMaxDimension.value / bitmapMaxDimension.value)
|
||||||
|
bitmapWidth < maxWidth -> bitmapWidth
|
||||||
|
else -> maxWidth
|
||||||
|
}
|
||||||
Modifier
|
Modifier
|
||||||
.width(if (bitmapWidth < maxWidth) bitmapWidth else maxWidth)
|
.width(displayWidth)
|
||||||
.aspectRatio(imageAspectRatio)
|
.aspectRatio(imageAspectRatio)
|
||||||
} else {
|
} else {
|
||||||
Modifier.fillMaxSize()
|
Modifier.fillMaxSize()
|
||||||
@ -1081,7 +1098,14 @@ private fun Modifier.readerLinkTapHandler(
|
|||||||
): Modifier = pointerInput(annotatedText, textLayout, onLinkOpen) {
|
): Modifier = pointerInput(annotatedText, textLayout, onLinkOpen) {
|
||||||
awaitEachGesture {
|
awaitEachGesture {
|
||||||
val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Main)
|
val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Main)
|
||||||
val link = textLayout?.readerLinkAt(annotatedText, down.position)
|
val link = textLayout?.readerLinkAt(
|
||||||
|
text = annotatedText,
|
||||||
|
position = down.position,
|
||||||
|
minimumTouchWidthPx = ReaderLinkMinimumTouchWidth.toPx(),
|
||||||
|
minimumTouchHeightPx = ReaderLinkMinimumTouchHeight.toPx(),
|
||||||
|
horizontalPaddingPx = ReaderLinkHorizontalTouchPadding.toPx(),
|
||||||
|
verticalPaddingPx = ReaderLinkVerticalTouchPadding.toPx(),
|
||||||
|
)
|
||||||
if (link == null) {
|
if (link == null) {
|
||||||
waitForUpOrCancellation(pass = PointerEventPass.Main)
|
waitForUpOrCancellation(pass = PointerEventPass.Main)
|
||||||
return@awaitEachGesture
|
return@awaitEachGesture
|
||||||
@ -1096,11 +1120,78 @@ private fun Modifier.readerLinkTapHandler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun TextLayoutResult.readerLinkAt(text: AnnotatedString, position: Offset): String? {
|
private fun TextLayoutResult.readerLinkAt(
|
||||||
val offset = getOffsetForPosition(position)
|
text: AnnotatedString,
|
||||||
return text.getStringAnnotations(ReaderLinkAnnotationTag, offset, offset)
|
position: Offset,
|
||||||
.firstOrNull()
|
minimumTouchWidthPx: Float,
|
||||||
|
minimumTouchHeightPx: Float,
|
||||||
|
horizontalPaddingPx: Float,
|
||||||
|
verticalPaddingPx: Float,
|
||||||
|
): String? =
|
||||||
|
text.getStringAnnotations(ReaderLinkAnnotationTag, 0, text.length)
|
||||||
|
.firstOrNull { annotation ->
|
||||||
|
readerLinkTouchBounds(
|
||||||
|
start = annotation.start,
|
||||||
|
end = annotation.end,
|
||||||
|
minimumTouchWidthPx = minimumTouchWidthPx,
|
||||||
|
minimumTouchHeightPx = minimumTouchHeightPx,
|
||||||
|
horizontalPaddingPx = horizontalPaddingPx,
|
||||||
|
verticalPaddingPx = verticalPaddingPx,
|
||||||
|
).any { it.contains(position) }
|
||||||
|
}
|
||||||
?.item
|
?.item
|
||||||
|
|
||||||
|
private fun TextLayoutResult.readerLinkTouchBounds(
|
||||||
|
start: Int,
|
||||||
|
end: Int,
|
||||||
|
minimumTouchWidthPx: Float,
|
||||||
|
minimumTouchHeightPx: Float,
|
||||||
|
horizontalPaddingPx: Float,
|
||||||
|
verticalPaddingPx: Float,
|
||||||
|
): List<Rect> {
|
||||||
|
if (start >= end) return emptyList()
|
||||||
|
|
||||||
|
val boundsByLine = linkedMapOf<Int, Rect>()
|
||||||
|
val safeEnd = min(end, layoutInput.text.length)
|
||||||
|
for (offset in start until safeEnd) {
|
||||||
|
val line = getLineForOffset(offset)
|
||||||
|
val bounds = getBoundingBox(offset)
|
||||||
|
boundsByLine[line] = boundsByLine[line]?.union(bounds) ?: bounds
|
||||||
|
}
|
||||||
|
|
||||||
|
return boundsByLine.values.map { bounds ->
|
||||||
|
bounds.withCenteredTouchPadding(
|
||||||
|
minimumWidth = minimumTouchWidthPx,
|
||||||
|
minimumHeight = minimumTouchHeightPx,
|
||||||
|
horizontalPadding = horizontalPaddingPx,
|
||||||
|
verticalPadding = verticalPaddingPx,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Rect.union(other: Rect): Rect =
|
||||||
|
Rect(
|
||||||
|
left = min(left, other.left),
|
||||||
|
top = min(top, other.top),
|
||||||
|
right = max(right, other.right),
|
||||||
|
bottom = max(bottom, other.bottom),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun Rect.withCenteredTouchPadding(
|
||||||
|
minimumWidth: Float,
|
||||||
|
minimumHeight: Float,
|
||||||
|
horizontalPadding: Float,
|
||||||
|
verticalPadding: Float,
|
||||||
|
): Rect {
|
||||||
|
val touchWidth = max(width + horizontalPadding * 2f, minimumWidth)
|
||||||
|
val touchHeight = max(height + verticalPadding * 2f, minimumHeight)
|
||||||
|
val center = center
|
||||||
|
return Rect(
|
||||||
|
left = center.x - touchWidth / 2f,
|
||||||
|
top = center.y - touchHeight / 2f,
|
||||||
|
right = center.x + touchWidth / 2f,
|
||||||
|
bottom = center.y + touchHeight / 2f,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun AnnotatedString.hasReaderLinks(): Boolean =
|
private fun AnnotatedString.hasReaderLinks(): Boolean =
|
||||||
@ -1255,12 +1346,12 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan {
|
|||||||
epigraph.textAuthors.forEach { addTextSentences(itemIndex, it) }
|
epigraph.textAuthors.forEach { addTextSentences(itemIndex, it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addSection(section: Fb2Section, depth: Int) {
|
fun addSection(section: Fb2Section, readerDepth: Int) {
|
||||||
if (section.title.isNullOrBlank()) {
|
if (section.title.isNullOrBlank()) {
|
||||||
elements += ReaderElement.SectionSeparator
|
elements += ReaderElement.SectionSeparator
|
||||||
} else {
|
} else {
|
||||||
val itemIndex = elements.size
|
val itemIndex = elements.size
|
||||||
elements += ReaderElement.SectionTitle(section.title!!, depth)
|
elements += ReaderElement.SectionTitle(section.title!!, readerDepth)
|
||||||
sentences += ReadAloudSentence(
|
sentences += ReadAloudSentence(
|
||||||
index = sentences.size,
|
index = sentences.size,
|
||||||
itemIndex = itemIndex,
|
itemIndex = itemIndex,
|
||||||
@ -1272,18 +1363,19 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan {
|
|||||||
)
|
)
|
||||||
pendingPauseBeforeMillis = 0L
|
pendingPauseBeforeMillis = 0L
|
||||||
}
|
}
|
||||||
section.readableBlocks().forEach { block ->
|
val blocks = section.readableBlocks()
|
||||||
|
blocks.forEach { block ->
|
||||||
val itemIndex = elements.size
|
val itemIndex = elements.size
|
||||||
when (block) {
|
when (block) {
|
||||||
Fb2Block.EmptyLine -> elements += ReaderElement.FixedSpacer(16)
|
Fb2Block.EmptyLine -> elements += ReaderElement.FixedSpacer(16)
|
||||||
is Fb2Block.Image -> elements += ReaderElement.BookImage(block.image)
|
is Fb2Block.Image -> elements += ReaderElement.BookImage(block.image)
|
||||||
is Fb2Block.Paragraph -> {
|
is Fb2Block.Paragraph -> {
|
||||||
addTextSentences(itemIndex, block.content)
|
addTextSentences(itemIndex, block.content)
|
||||||
elements += ReaderElement.Paragraph(block.content, depth)
|
elements += ReaderElement.Paragraph(block.content, readerDepth)
|
||||||
}
|
}
|
||||||
is Fb2Block.Poem -> {
|
is Fb2Block.Poem -> {
|
||||||
addPoemSentences(itemIndex, block.poem)
|
addPoemSentences(itemIndex, block.poem)
|
||||||
elements += ReaderElement.Poem(block.poem, depth)
|
elements += ReaderElement.Poem(block.poem, readerDepth)
|
||||||
}
|
}
|
||||||
is Fb2Block.Subtitle -> {
|
is Fb2Block.Subtitle -> {
|
||||||
addTextSentences(
|
addTextSentences(
|
||||||
@ -1296,15 +1388,16 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan {
|
|||||||
}
|
}
|
||||||
is Fb2Block.Epigraph -> {
|
is Fb2Block.Epigraph -> {
|
||||||
addEpigraphSentences(itemIndex, block.epigraph)
|
addEpigraphSentences(itemIndex, block.epigraph)
|
||||||
elements += ReaderElement.Epigraph(block.epigraph, depth)
|
elements += ReaderElement.Epigraph(block.epigraph, readerDepth)
|
||||||
}
|
}
|
||||||
is Fb2Block.Cite -> {
|
is Fb2Block.Cite -> {
|
||||||
addCiteSentences(itemIndex, block.cite)
|
addCiteSentences(itemIndex, block.cite)
|
||||||
elements += ReaderElement.Cite(block.cite, depth)
|
elements += ReaderElement.Cite(block.cite, readerDepth)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
section.sections.forEach { addSection(it, depth + 1) }
|
val childDepth = if (blocks.isEmpty()) readerDepth else readerDepth + 1
|
||||||
|
section.sections.forEach { addSection(it, childDepth) }
|
||||||
}
|
}
|
||||||
|
|
||||||
elements += ReaderElement.Cover
|
elements += ReaderElement.Cover
|
||||||
@ -1521,6 +1614,10 @@ private const val CombiningAcuteAccent = '\u0301'
|
|||||||
private const val ReadAloudStressableLetters = "аеёиоуыэюяАЕЁИОУЫЭЮЯaeiouyAEIOUY"
|
private const val ReadAloudStressableLetters = "аеёиоуыэюяАЕЁИОУЫЭЮЯaeiouyAEIOUY"
|
||||||
private const val ReaderLinkAnnotationTag = "fb2-link"
|
private const val ReaderLinkAnnotationTag = "fb2-link"
|
||||||
private val LinkTextColor = Color(0xFF0B57D0)
|
private val LinkTextColor = Color(0xFF0B57D0)
|
||||||
|
private val ReaderLinkMinimumTouchWidth = 44.dp
|
||||||
|
private val ReaderLinkMinimumTouchHeight = 40.dp
|
||||||
|
private val ReaderLinkHorizontalTouchPadding = 10.dp
|
||||||
|
private val ReaderLinkVerticalTouchPadding = 6.dp
|
||||||
|
|
||||||
private val ReadAloudHardcodedTextReplacements = listOf(
|
private val ReadAloudHardcodedTextReplacements = listOf(
|
||||||
ReadAloudTextReplacement(from = "Господа,", to = "Господ/а,", caseSensitive = true),
|
ReadAloudTextReplacement(from = "Господа,", to = "Господ/а,", caseSensitive = true),
|
||||||
|
|||||||
@ -66,6 +66,7 @@ internal fun BookView(
|
|||||||
book: Fb2Book,
|
book: Fb2Book,
|
||||||
onImageOpen: (ViewedBookImage) -> Unit,
|
onImageOpen: (ViewedBookImage) -> Unit,
|
||||||
onThemeToggle: () -> Unit,
|
onThemeToggle: () -> Unit,
|
||||||
|
onBookChanged: (String) -> Unit,
|
||||||
onBookInfo: () -> Unit,
|
onBookInfo: () -> Unit,
|
||||||
onDeleted: (String?) -> Unit,
|
onDeleted: (String?) -> Unit,
|
||||||
onDeleteRequested: (
|
onDeleteRequested: (
|
||||||
@ -110,6 +111,7 @@ internal fun BookView(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
if (markLibraryReadingStatus(fileId, status)) {
|
if (markLibraryReadingStatus(fileId, status)) {
|
||||||
libraryItem = loadLibraryItem(fileId) ?: libraryItem?.copy(readingStatus = status)
|
libraryItem = loadLibraryItem(fileId) ?: libraryItem?.copy(readingStatus = status)
|
||||||
|
onBookChanged(fileId)
|
||||||
if (status == BookReadingStatus.READ) markedRead = true
|
if (status == BookReadingStatus.READ) markedRead = true
|
||||||
if (status == BookReadingStatus.NEW) markedRead = false
|
if (status == BookReadingStatus.NEW) markedRead = false
|
||||||
if (status == BookReadingStatus.NOT_INTERESTED) {
|
if (status == BookReadingStatus.NOT_INTERESTED) {
|
||||||
@ -134,6 +136,7 @@ internal fun BookView(
|
|||||||
markedRead = item?.readingStatus == BookReadingStatus.READ
|
markedRead = item?.readingStatus == BookReadingStatus.READ
|
||||||
if (item?.readingStatus?.shouldBecomeReadingOnOpen() == true && markLibraryReadingStatus(fileId, BookReadingStatus.READING)) {
|
if (item?.readingStatus?.shouldBecomeReadingOnOpen() == true && markLibraryReadingStatus(fileId, BookReadingStatus.READING)) {
|
||||||
libraryItem = loadLibraryItem(fileId) ?: item.copy(readingStatus = BookReadingStatus.READING)
|
libraryItem = loadLibraryItem(fileId) ?: item.copy(readingStatus = BookReadingStatus.READING)
|
||||||
|
onBookChanged(fileId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -182,6 +185,7 @@ internal fun BookView(
|
|||||||
markedRead = true
|
markedRead = true
|
||||||
if (markLibraryReadingStatus(fileId, BookReadingStatus.READ)) {
|
if (markLibraryReadingStatus(fileId, BookReadingStatus.READ)) {
|
||||||
libraryItem = loadLibraryItem(fileId) ?: libraryItem?.copy(readingStatus = BookReadingStatus.READ)
|
libraryItem = loadLibraryItem(fileId) ?: libraryItem?.copy(readingStatus = BookReadingStatus.READ)
|
||||||
|
onBookChanged(fileId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -230,6 +234,7 @@ internal fun BookView(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
if (markLibraryFavorite(fileId, favorite)) {
|
if (markLibraryFavorite(fileId, favorite)) {
|
||||||
libraryItem = loadLibraryItem(fileId) ?: libraryItem?.copy(favorite = favorite)
|
libraryItem = loadLibraryItem(fileId) ?: libraryItem?.copy(favorite = favorite)
|
||||||
|
onBookChanged(fileId)
|
||||||
showMessage(if (favorite) strings.addedToFavorites() else strings.removedFromFavorites())
|
showMessage(if (favorite) strings.addedToFavorites() else strings.removedFromFavorites())
|
||||||
} else {
|
} else {
|
||||||
showMessage(strings.couldNotUpdateBook)
|
showMessage(strings.couldNotUpdateBook)
|
||||||
|
|||||||
@ -216,6 +216,95 @@ class ReadAloudContentPlanTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun wrapperOnlyTopSectionDoesNotIndentChildParagraphs() {
|
||||||
|
val plan = buildReaderContentPlan(
|
||||||
|
Fb2Book(
|
||||||
|
title = "Book",
|
||||||
|
sections = listOf(
|
||||||
|
Fb2Section(
|
||||||
|
title = "Wrapper title",
|
||||||
|
sections = listOf(
|
||||||
|
Fb2Section(
|
||||||
|
title = "Chapter",
|
||||||
|
blocks = listOf(paragraph("Chapter text.")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(0, plan.elements.filterIsInstance<ReaderElement.Paragraph>().single().depth)
|
||||||
|
assertEquals(
|
||||||
|
listOf(0, 0),
|
||||||
|
plan.elements.filterIsInstance<ReaderElement.SectionTitle>().map { it.depth },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun titleOnlyPartSectionDoesNotIndentChaptersAfterFrontMatter() {
|
||||||
|
val plan = buildReaderContentPlan(
|
||||||
|
Fb2Book(
|
||||||
|
title = "Book",
|
||||||
|
sections = listOf(
|
||||||
|
Fb2Section(
|
||||||
|
title = "Copyright",
|
||||||
|
blocks = listOf(paragraph("Copyright text.")),
|
||||||
|
),
|
||||||
|
Fb2Section(
|
||||||
|
title = "Part one",
|
||||||
|
sections = listOf(
|
||||||
|
Fb2Section(
|
||||||
|
title = "Chapter one",
|
||||||
|
blocks = listOf(paragraph("Chapter text.")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf(0, 0),
|
||||||
|
plan.elements.filterIsInstance<ReaderElement.Paragraph>().map { it.depth },
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
listOf(0, 0, 0),
|
||||||
|
plan.elements.filterIsInstance<ReaderElement.SectionTitle>().map { it.depth },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun realNestedSectionsKeepRelativeIndent() {
|
||||||
|
val plan = buildReaderContentPlan(
|
||||||
|
Fb2Book(
|
||||||
|
title = "Book",
|
||||||
|
sections = listOf(
|
||||||
|
Fb2Section(
|
||||||
|
title = "Part",
|
||||||
|
blocks = listOf(paragraph("Part text.")),
|
||||||
|
sections = listOf(
|
||||||
|
Fb2Section(
|
||||||
|
title = "Nested chapter",
|
||||||
|
blocks = listOf(paragraph("Nested text.")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf(0, 1),
|
||||||
|
plan.elements.filterIsInstance<ReaderElement.Paragraph>().map { it.depth },
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
listOf(0, 1),
|
||||||
|
plan.elements.filterIsInstance<ReaderElement.SectionTitle>().map { it.depth },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun paragraph(text: String): Fb2Block.Paragraph =
|
private fun paragraph(text: String): Fb2Block.Paragraph =
|
||||||
Fb2Block.Paragraph(text(text))
|
Fb2Block.Paragraph(text(text))
|
||||||
|
|
||||||
|
|||||||
@ -20,7 +20,7 @@ The common API is `Fb2Format.parse(input: ByteArray, fileName: String? = null)`.
|
|||||||
Import detection:
|
Import detection:
|
||||||
|
|
||||||
- A file is treated as ZIP when its bytes start with the ZIP local-file signature `PK\003\004` or the provided filename ends with `.zip`.
|
- A file is treated as ZIP when its bytes start with the ZIP local-file signature `PK\003\004` or the provided filename ends with `.zip`.
|
||||||
- Otherwise bytes are decoded according to the XML declaration when supported. UTF-8 is the default, and unsupported or missing encodings fall back to UTF-8. `windows-1251` is supported for legacy FB2 files.
|
- Otherwise bytes are decoded according to the XML declaration when supported. UTF-8 is the default, and unsupported or missing encodings fall back to UTF-8. `windows-1251` and `windows-1252` are supported for legacy FB2 files.
|
||||||
- In ZIP archives, the first entry ending with `.fb2` is used. If no such entry exists, the first non-directory entry is used.
|
- In ZIP archives, the first entry ending with `.fb2` is used. If no such entry exists, the first non-directory entry is used.
|
||||||
|
|
||||||
ZIP support:
|
ZIP support:
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 215 KiB |
|
Before Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 171 KiB |
BIN
screenshots/Screenshot_20260524_220504.png
Normal file
|
After Width: | Height: | Size: 208 KiB |
BIN
screenshots/Screenshot_20260524_220656.png
Normal file
|
After Width: | Height: | Size: 392 KiB |
BIN
screenshots/Screenshot_20260524_220732.png
Normal file
|
After Width: | Height: | Size: 398 KiB |
BIN
screenshots/Screenshot_20260524_220803.png
Normal file
|
After Width: | Height: | Size: 212 KiB |
BIN
screenshots/Screenshot_20260524_220818.png
Normal file
|
After Width: | Height: | Size: 203 KiB |
@ -6,6 +6,7 @@ internal object Fb2XmlEncoding {
|
|||||||
fun decodeXml(bytes: ByteArray): String =
|
fun decodeXml(bytes: ByteArray): String =
|
||||||
when (declaredEncoding(bytes)?.lowercase()) {
|
when (declaredEncoding(bytes)?.lowercase()) {
|
||||||
"windows-1251" -> decodeWindows1251(bytes)
|
"windows-1251" -> decodeWindows1251(bytes)
|
||||||
|
"windows-1252" -> decodeWindows1252(bytes)
|
||||||
else -> bytes.decodeToString()
|
else -> bytes.decodeToString()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -30,6 +31,12 @@ internal object Fb2XmlEncoding {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun decodeWindows1252(bytes: ByteArray): String = buildString(bytes.size) {
|
||||||
|
bytes.forEach { byte ->
|
||||||
|
append(windows1252Char(byte.toInt() and 0xff))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun windows1251Char(value: Int): Char =
|
private fun windows1251Char(value: Int): Char =
|
||||||
when (value) {
|
when (value) {
|
||||||
in 0x00..0x7f -> value.toChar()
|
in 0x00..0x7f -> value.toChar()
|
||||||
@ -100,4 +107,43 @@ internal object Fb2XmlEncoding {
|
|||||||
in 0xc0..0xff -> (0x0410 + value - 0xc0).toChar()
|
in 0xc0..0xff -> (0x0410 + value - 0xc0).toChar()
|
||||||
else -> '\uFFFD'
|
else -> '\uFFFD'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun windows1252Char(value: Int): Char =
|
||||||
|
when (value) {
|
||||||
|
in 0x00..0x7f -> value.toChar()
|
||||||
|
0x80 -> '\u20ac'
|
||||||
|
0x81 -> '\uFFFD'
|
||||||
|
0x82 -> '\u201a'
|
||||||
|
0x83 -> '\u0192'
|
||||||
|
0x84 -> '\u201e'
|
||||||
|
0x85 -> '\u2026'
|
||||||
|
0x86 -> '\u2020'
|
||||||
|
0x87 -> '\u2021'
|
||||||
|
0x88 -> '\u02c6'
|
||||||
|
0x89 -> '\u2030'
|
||||||
|
0x8a -> '\u0160'
|
||||||
|
0x8b -> '\u2039'
|
||||||
|
0x8c -> '\u0152'
|
||||||
|
0x8d -> '\uFFFD'
|
||||||
|
0x8e -> '\u017d'
|
||||||
|
0x8f -> '\uFFFD'
|
||||||
|
0x90 -> '\uFFFD'
|
||||||
|
0x91 -> '\u2018'
|
||||||
|
0x92 -> '\u2019'
|
||||||
|
0x93 -> '\u201c'
|
||||||
|
0x94 -> '\u201d'
|
||||||
|
0x95 -> '\u2022'
|
||||||
|
0x96 -> '\u2013'
|
||||||
|
0x97 -> '\u2014'
|
||||||
|
0x98 -> '\u02dc'
|
||||||
|
0x99 -> '\u2122'
|
||||||
|
0x9a -> '\u0161'
|
||||||
|
0x9b -> '\u203a'
|
||||||
|
0x9c -> '\u0153'
|
||||||
|
0x9d -> '\uFFFD'
|
||||||
|
0x9e -> '\u017e'
|
||||||
|
0x9f -> '\u0178'
|
||||||
|
in 0xa0..0xff -> value.toChar()
|
||||||
|
else -> '\uFFFD'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -60,6 +60,23 @@ class Fb2FormatTest {
|
|||||||
assertEquals("Привет, мир.", book.sections.single().paragraphs.single())
|
assertEquals("Привет, мир.", book.sections.single().paragraphs.single())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parsesWindows1252PlainXml() {
|
||||||
|
val book = Fb2Format.parse(windows1252Xml.encodeWindows1252(), "legacy.fb2")
|
||||||
|
|
||||||
|
assertEquals("The “Test” Book", book.title)
|
||||||
|
assertEquals("It’s 25€ for café — OK.", book.sections.single().paragraphs.single())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parsesWindows1252StoredZip() {
|
||||||
|
val zip = Fb2Zip.createStoredZip("legacy.fb2", windows1252Xml.encodeWindows1252())
|
||||||
|
val book = Fb2Format.parse(zip, "legacy.fb2.zip")
|
||||||
|
|
||||||
|
assertEquals("The “Test” Book", book.title)
|
||||||
|
assertEquals("It’s 25€ for café — OK.", book.sections.single().paragraphs.single())
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun fallsBackToUtf8ForUnknownEncoding() {
|
fun fallsBackToUtf8ForUnknownEncoding() {
|
||||||
val xml = sampleXml.replace("encoding=\"UTF-8\"", "encoding=\"KOI8-R\"")
|
val xml = sampleXml.replace("encoding=\"UTF-8\"", "encoding=\"KOI8-R\"")
|
||||||
@ -273,6 +290,26 @@ class Fb2FormatTest {
|
|||||||
</FictionBook>
|
</FictionBook>
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
|
private val windows1252Xml = """
|
||||||
|
<?xml version="1.0" encoding="windows-1252"?>
|
||||||
|
<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<description>
|
||||||
|
<title-info>
|
||||||
|
<author><nickname>Author</nickname></author>
|
||||||
|
<book-title>The “Test” Book</book-title>
|
||||||
|
<lang>en</lang>
|
||||||
|
</title-info>
|
||||||
|
<document-info>
|
||||||
|
<author><nickname>Toread</nickname></author>
|
||||||
|
<date>2026-05-12</date>
|
||||||
|
<id>legacy-1252</id>
|
||||||
|
<version>1.0</version>
|
||||||
|
</document-info>
|
||||||
|
</description>
|
||||||
|
<body><section><p>It’s 25€ for café — OK.</p></section></body>
|
||||||
|
</FictionBook>
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
private val notesXml = """
|
private val notesXml = """
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0" xmlns:l="http://www.w3.org/1999/xlink">
|
<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0" xmlns:l="http://www.w3.org/1999/xlink">
|
||||||
@ -314,4 +351,23 @@ class Fb2FormatTest {
|
|||||||
}
|
}
|
||||||
value.toByte()
|
value.toByte()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun String.encodeWindows1252(): ByteArray =
|
||||||
|
ByteArray(length) { index ->
|
||||||
|
val char = this[index]
|
||||||
|
val value = when {
|
||||||
|
char.code <= 0x7f -> char.code
|
||||||
|
char.code in 0xa0..0xff -> char.code
|
||||||
|
char == '€' -> 0x80
|
||||||
|
char == '‘' -> 0x91
|
||||||
|
char == '’' -> 0x92
|
||||||
|
char == '“' -> 0x93
|
||||||
|
char == '”' -> 0x94
|
||||||
|
char == '–' -> 0x96
|
||||||
|
char == '—' -> 0x97
|
||||||
|
char == '™' -> 0x99
|
||||||
|
else -> error("Test character $char is not mapped to windows-1252")
|
||||||
|
}
|
||||||
|
value.toByte()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||