Compare commits

...

4 Commits

20 changed files with 210917 additions and 66 deletions

1
.gitignore vendored
View File

@ -18,3 +18,4 @@ captures
**/xcshareddata/WorkspaceSettings.xcsettings **/xcshareddata/WorkspaceSettings.xcsettings
node_modules/ node_modules/
/composeApp/release/ /composeApp/release/
/test_books/

View File

@ -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 {

View File

@ -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(

View File

@ -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()) {

View File

@ -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),

View File

@ -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)

View File

@ -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))

View File

@ -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:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

View File

@ -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'
}
} }

View File

@ -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>Its 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()
}
} }

File diff suppressed because one or more lines are too long