footnotes support (as notes)

This commit is contained in:
Sergey Chernov 2026-05-24 11:05:19 +03:00
parent 50f201d3c6
commit 8e7dcc0307
5 changed files with 270 additions and 11 deletions

View File

@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@ -27,10 +28,12 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateMapOf
@ -51,8 +54,10 @@ import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerIcon
import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.input.pointer.PointerType
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
@ -107,6 +112,7 @@ internal fun ContinuousBookReader(
highlightedSentence: ReadAloudSentence? = null, highlightedSentence: ReadAloudSentence? = null,
onUserScroll: () -> Unit = {}, onUserScroll: () -> Unit = {},
onImageOpen: (ViewedBookImage) -> Unit = {}, onImageOpen: (ViewedBookImage) -> Unit = {},
onNoteOpen: (String) -> Unit = {},
) { ) {
val hyphenation = remember { HyphenationRegistry() } val hyphenation = remember { HyphenationRegistry() }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -204,6 +210,7 @@ internal fun ContinuousBookReader(
textAlign = TextAlign.Justify, textAlign = TextAlign.Justify,
modifier = Modifier.padding(start = (element.depth * 8).dp, end = 0.dp), modifier = Modifier.padding(start = (element.depth * 8).dp, end = 0.dp),
onTextLayout = { textLineMetricsByItem[itemIndex] = it.toTextLineMetrics() }, onTextLayout = { textLineMetricsByItem[itemIndex] = it.toTextLineMetrics() },
onLinkOpen = onNoteOpen,
) )
is ReaderElement.Subtitle -> ReaderText( is ReaderElement.Subtitle -> ReaderText(
text = element.text, text = element.text,
@ -214,6 +221,7 @@ internal fun ContinuousBookReader(
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(top = 18.dp, bottom = 8.dp), modifier = Modifier.fillMaxWidth().padding(top = 18.dp, bottom = 8.dp),
onTextLayout = { textLineMetricsByItem[itemIndex] = it.toTextLineMetrics() }, onTextLayout = { textLineMetricsByItem[itemIndex] = it.toTextLineMetrics() },
onLinkOpen = onNoteOpen,
) )
is ReaderElement.Epigraph -> ReaderEpigraph( is ReaderElement.Epigraph -> ReaderEpigraph(
epigraph = element.epigraph, epigraph = element.epigraph,
@ -221,6 +229,7 @@ internal fun ContinuousBookReader(
hyphenation = hyphenation, hyphenation = hyphenation,
highlightedRange = highlightedRange, highlightedRange = highlightedRange,
depth = element.depth, depth = element.depth,
onLinkOpen = onNoteOpen,
) )
is ReaderElement.Cite -> ReaderCite( is ReaderElement.Cite -> ReaderCite(
cite = element.cite, cite = element.cite,
@ -228,6 +237,7 @@ internal fun ContinuousBookReader(
hyphenation = hyphenation, hyphenation = hyphenation,
highlightedRange = highlightedRange, highlightedRange = highlightedRange,
depth = element.depth, depth = element.depth,
onLinkOpen = onNoteOpen,
) )
is ReaderElement.Poem -> ReaderPoem( is ReaderElement.Poem -> ReaderPoem(
poem = element.poem, poem = element.poem,
@ -235,6 +245,7 @@ internal fun ContinuousBookReader(
hyphenation = hyphenation, hyphenation = hyphenation,
highlightedRange = highlightedRange, highlightedRange = highlightedRange,
depth = element.depth, depth = element.depth,
onLinkOpen = onNoteOpen,
) )
} }
} }
@ -246,7 +257,8 @@ private fun Modifier.pageTurnOnTouchTap(
onPageUp: () -> Unit, onPageUp: () -> Unit,
): Modifier = pointerInput(onPageDown, onPageUp) { ): Modifier = pointerInput(onPageDown, onPageUp) {
awaitEachGesture { awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Final)
if (down.isConsumed) return@awaitEachGesture
if (down.type != PointerType.Touch) { if (down.type != PointerType.Touch) {
waitForUpOrCancellation(pass = PointerEventPass.Final) waitForUpOrCancellation(pass = PointerEventPass.Final)
return@awaitEachGesture return@awaitEachGesture
@ -258,7 +270,7 @@ private fun Modifier.pageTurnOnTouchTap(
var cancelled = false var cancelled = false
while (upPosition == null && !cancelled) { while (upPosition == null && !cancelled) {
val event = awaitPointerEvent(pass = PointerEventPass.Initial) val event = awaitPointerEvent(pass = PointerEventPass.Final)
val change = event.changes.firstOrNull { it.id == down.id } val change = event.changes.firstOrNull { it.id == down.id }
if (change == null) { if (change == null) {
cancelled = true cancelled = true
@ -345,6 +357,91 @@ private fun TextLayoutResult.toTextLineMetrics(): TextLineMetrics =
lineBottoms = List(lineCount) { line -> getLineBottom(line).roundToInt() }, lineBottoms = List(lineCount) { line -> getLineBottom(line).roundToInt() },
) )
@Composable
internal fun Fb2NoteReferenceDialog(
book: Fb2Book,
noteId: String,
onDismiss: () -> Unit,
onImageOpen: (ViewedBookImage) -> Unit,
onNoteOpen: (String) -> Unit,
) {
val note = book.notes[noteId]
val hyphenation = remember { HyphenationRegistry() }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(note?.title?.ifBlank { null } ?: strings.notes) },
text = {
if (note == null) {
Text(strings.noNotes, style = MaterialTheme.typography.bodyMedium)
} else {
LazyColumn(
modifier = Modifier.fillMaxWidth().heightIn(max = 520.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
items(note.readableBlocks()) { block ->
when (block) {
Fb2Block.EmptyLine -> Spacer(Modifier.height(12.dp))
is Fb2Block.Image -> BookImage(
book = book,
image = block.image,
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
contentScale = ContentScale.Fit,
onOpen = onImageOpen,
)
is Fb2Block.Paragraph -> ReaderText(
text = block.content,
language = book.language,
hyphenation = hyphenation,
style = MaterialTheme.typography.bodyMedium.copy(lineHeight = 22.sp),
textAlign = TextAlign.Start,
onLinkOpen = onNoteOpen,
)
is Fb2Block.Subtitle -> ReaderText(
text = block.content,
language = book.language,
hyphenation = hyphenation,
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold),
textAlign = TextAlign.Start,
onLinkOpen = onNoteOpen,
)
is Fb2Block.Epigraph -> ReaderEpigraph(
epigraph = block.epigraph,
language = book.language,
hyphenation = hyphenation,
highlightedRange = null,
depth = 0,
onLinkOpen = onNoteOpen,
)
is Fb2Block.Cite -> ReaderCite(
cite = block.cite,
language = book.language,
hyphenation = hyphenation,
highlightedRange = null,
depth = 0,
onLinkOpen = onNoteOpen,
)
is Fb2Block.Poem -> ReaderPoem(
poem = block.poem,
language = book.language,
hyphenation = hyphenation,
highlightedRange = null,
depth = 0,
onLinkOpen = onNoteOpen,
)
}
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(strings.done)
}
},
)
}
@Composable @Composable
private fun DetailsPane( private fun DetailsPane(
book: Fb2Book, book: Fb2Book,
@ -499,6 +596,7 @@ private fun ReaderPane(book: Fb2Book, section: Fb2Section?, modifier: Modifier =
hyphenation = hyphenation, hyphenation = hyphenation,
highlightedRange = null, highlightedRange = null,
depth = 0, depth = 0,
onLinkOpen = {},
) )
is Fb2Block.Subtitle -> ReaderText( is Fb2Block.Subtitle -> ReaderText(
text = block.content, text = block.content,
@ -514,6 +612,7 @@ private fun ReaderPane(book: Fb2Book, section: Fb2Section?, modifier: Modifier =
hyphenation = hyphenation, hyphenation = hyphenation,
highlightedRange = null, highlightedRange = null,
depth = 0, depth = 0,
onLinkOpen = {},
) )
is Fb2Block.Cite -> ReaderCite( is Fb2Block.Cite -> ReaderCite(
cite = block.cite, cite = block.cite,
@ -521,6 +620,7 @@ private fun ReaderPane(book: Fb2Book, section: Fb2Section?, modifier: Modifier =
hyphenation = hyphenation, hyphenation = hyphenation,
highlightedRange = null, highlightedRange = null,
depth = 0, depth = 0,
onLinkOpen = {},
) )
} }
} }
@ -538,15 +638,30 @@ private fun ReaderText(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
highlightedRange: ReaderSentenceRange? = null, highlightedRange: ReaderSentenceRange? = null,
onTextLayout: (TextLayoutResult) -> Unit = {}, onTextLayout: (TextLayoutResult) -> Unit = {},
onLinkOpen: (String) -> Unit = {},
) { ) {
val highlightColor = MaterialTheme.colorScheme.secondaryContainer val highlightColor = MaterialTheme.colorScheme.secondaryContainer
val annotatedText = text.toAnnotatedString(language, hyphenation, highlightedRange, highlightColor) val annotatedText = text.toAnnotatedString(language, hyphenation, highlightedRange, highlightColor)
val hasLinks = annotatedText.hasReaderLinks()
val needsSoftHyphenPaintWorkaround = isDesktopPlatform val needsSoftHyphenPaintWorkaround = isDesktopPlatform
var textLayout by remember(annotatedText) { mutableStateOf<TextLayoutResult?>(null) } var textLayout by remember(annotatedText) { mutableStateOf<TextLayoutResult?>(null) }
val desktopHyphenColor = MaterialTheme.colorScheme.onSurface val desktopHyphenColor = MaterialTheme.colorScheme.onSurface
val desktopHyphenGutter = 8.dp val desktopHyphenGutter = 8.dp
val textModifier = modifier val textModifier = modifier
.fillMaxWidth() .fillMaxWidth()
.then(
if (hasLinks) {
Modifier
.pointerHoverIcon(PointerIcon.Hand)
.readerLinkTapHandler(
annotatedText = annotatedText,
textLayout = textLayout,
onLinkOpen = onLinkOpen,
)
} else {
Modifier
},
)
.then( .then(
if (needsSoftHyphenPaintWorkaround) { if (needsSoftHyphenPaintWorkaround) {
Modifier.drawWithContent { Modifier.drawWithContent {
@ -598,6 +713,7 @@ private fun ReaderPoem(
highlightedRange: ReaderSentenceRange?, highlightedRange: ReaderSentenceRange?,
depth: Int, depth: Int,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onLinkOpen: (String) -> Unit = {},
) { ) {
val segments = remember(poem) { poem.readerSegments() } val segments = remember(poem) { poem.readerSegments() }
Column( Column(
@ -613,6 +729,7 @@ private fun ReaderPoem(
highlightedRange = highlightedRange, highlightedRange = highlightedRange,
depth = 0, depth = 0,
modifier = Modifier.padding(top = if (index == 0) 0.dp else 8.dp, bottom = 8.dp), modifier = Modifier.padding(top = if (index == 0) 0.dp else 8.dp, bottom = 8.dp),
onLinkOpen = onLinkOpen,
) )
} }
segments.forEach { segment -> segments.forEach { segment ->
@ -636,6 +753,7 @@ private fun ReaderPoem(
ReaderPoemSegmentKind.Verse -> Modifier.padding(start = 22.dp) ReaderPoemSegmentKind.Verse -> Modifier.padding(start = 22.dp)
else -> Modifier else -> Modifier
}, },
onLinkOpen = onLinkOpen,
) )
} }
} }
@ -649,6 +767,7 @@ private fun ReaderEpigraph(
highlightedRange: ReaderSentenceRange?, highlightedRange: ReaderSentenceRange?,
depth: Int, depth: Int,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onLinkOpen: (String) -> Unit = {},
) { ) {
Column( Column(
modifier = modifier modifier = modifier
@ -663,6 +782,7 @@ private fun ReaderEpigraph(
hyphenation = hyphenation, hyphenation = hyphenation,
highlightedRange = null, highlightedRange = null,
depth = 0, depth = 0,
onLinkOpen = onLinkOpen,
) )
} }
epigraph.textAuthors.forEach { author -> epigraph.textAuthors.forEach { author ->
@ -674,6 +794,7 @@ private fun ReaderEpigraph(
highlightedRange = null, highlightedRange = null,
textAlign = TextAlign.End, textAlign = TextAlign.End,
modifier = Modifier.fillMaxWidth().padding(top = 2.dp), modifier = Modifier.fillMaxWidth().padding(top = 2.dp),
onLinkOpen = onLinkOpen,
) )
} }
} }
@ -687,6 +808,7 @@ private fun ReaderCite(
highlightedRange: ReaderSentenceRange?, highlightedRange: ReaderSentenceRange?,
depth: Int, depth: Int,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onLinkOpen: (String) -> Unit = {},
) { ) {
Column( Column(
modifier = modifier modifier = modifier
@ -701,6 +823,7 @@ private fun ReaderCite(
hyphenation = hyphenation, hyphenation = hyphenation,
highlightedRange = null, highlightedRange = null,
depth = 0, depth = 0,
onLinkOpen = onLinkOpen,
) )
} }
cite.textAuthors.forEach { author -> cite.textAuthors.forEach { author ->
@ -712,6 +835,7 @@ private fun ReaderCite(
highlightedRange = null, highlightedRange = null,
textAlign = TextAlign.End, textAlign = TextAlign.End,
modifier = Modifier.fillMaxWidth().padding(top = 2.dp), modifier = Modifier.fillMaxWidth().padding(top = 2.dp),
onLinkOpen = onLinkOpen,
) )
} }
} }
@ -724,6 +848,7 @@ private fun ReaderEpigraphBlock(
hyphenation: HyphenationRegistry, hyphenation: HyphenationRegistry,
highlightedRange: ReaderSentenceRange?, highlightedRange: ReaderSentenceRange?,
depth: Int, depth: Int,
onLinkOpen: (String) -> Unit,
) { ) {
when (block) { when (block) {
Fb2EpigraphBlock.EmptyLine -> Spacer(Modifier.height(12.dp)) Fb2EpigraphBlock.EmptyLine -> Spacer(Modifier.height(12.dp))
@ -734,6 +859,7 @@ private fun ReaderEpigraphBlock(
style = epigraphTextStyle(language), style = epigraphTextStyle(language),
highlightedRange = highlightedRange, highlightedRange = highlightedRange,
textAlign = TextAlign.Start, textAlign = TextAlign.Start,
onLinkOpen = onLinkOpen,
) )
is Fb2EpigraphBlock.Subtitle -> ReaderText( is Fb2EpigraphBlock.Subtitle -> ReaderText(
text = block.content, text = block.content,
@ -742,6 +868,7 @@ private fun ReaderEpigraphBlock(
style = epigraphTextStyle(language).copy(fontWeight = FontWeight.SemiBold), style = epigraphTextStyle(language).copy(fontWeight = FontWeight.SemiBold),
highlightedRange = highlightedRange, highlightedRange = highlightedRange,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
onLinkOpen = onLinkOpen,
) )
is Fb2EpigraphBlock.Poem -> ReaderPoem( is Fb2EpigraphBlock.Poem -> ReaderPoem(
poem = block.poem, poem = block.poem,
@ -749,6 +876,7 @@ private fun ReaderEpigraphBlock(
hyphenation = hyphenation, hyphenation = hyphenation,
highlightedRange = highlightedRange, highlightedRange = highlightedRange,
depth = depth, depth = depth,
onLinkOpen = onLinkOpen,
) )
is Fb2EpigraphBlock.Cite -> ReaderCite( is Fb2EpigraphBlock.Cite -> ReaderCite(
cite = block.cite, cite = block.cite,
@ -756,6 +884,7 @@ private fun ReaderEpigraphBlock(
hyphenation = hyphenation, hyphenation = hyphenation,
highlightedRange = highlightedRange, highlightedRange = highlightedRange,
depth = depth, depth = depth,
onLinkOpen = onLinkOpen,
) )
} }
} }
@ -945,6 +1074,38 @@ private fun Modifier.softImageEdgeFade(): Modifier =
) )
} }
private fun Modifier.readerLinkTapHandler(
annotatedText: AnnotatedString,
textLayout: TextLayoutResult?,
onLinkOpen: (String) -> Unit,
): Modifier = pointerInput(annotatedText, textLayout, onLinkOpen) {
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Main)
val link = textLayout?.readerLinkAt(annotatedText, down.position)
if (link == null) {
waitForUpOrCancellation(pass = PointerEventPass.Main)
return@awaitEachGesture
}
down.consume()
val up = waitForUpOrCancellation(pass = PointerEventPass.Main)
if (up != null) {
up.consume()
onLinkOpen(link)
}
}
}
private fun TextLayoutResult.readerLinkAt(text: AnnotatedString, position: Offset): String? {
val offset = getOffsetForPosition(position)
return text.getStringAnnotations(ReaderLinkAnnotationTag, offset, offset)
.firstOrNull()
?.item
}
private fun AnnotatedString.hasReaderLinks(): Boolean =
getStringAnnotations(ReaderLinkAnnotationTag, 0, length).isNotEmpty()
private fun Fb2Text.toAnnotatedString( private fun Fb2Text.toAnnotatedString(
language: String?, language: String?,
hyphenation: HyphenationRegistry, hyphenation: HyphenationRegistry,
@ -954,14 +1115,18 @@ private fun Fb2Text.toAnnotatedString(
buildAnnotatedString { buildAnnotatedString {
var plainOffset = 0 var plainOffset = 0
spans.forEach { span -> spans.forEach { span ->
val isLink = span.href.isNullOrBlank().not()
val spanStyle = SpanStyle( val spanStyle = SpanStyle(
fontStyle = if (Fb2TextStyle.Emphasis in span.styles) FontStyle.Italic else null, fontStyle = if (Fb2TextStyle.Emphasis in span.styles) FontStyle.Italic else null,
fontWeight = if (Fb2TextStyle.Strong in span.styles) FontWeight.Bold else null, fontWeight = if (Fb2TextStyle.Strong in span.styles) FontWeight.Bold else null,
fontFamily = if (Fb2TextStyle.Code in span.styles) FontFamily.Monospace else null, fontFamily = if (Fb2TextStyle.Code in span.styles) FontFamily.Monospace else null,
textDecoration = if (Fb2TextStyle.Strikethrough in span.styles) { color = if (isLink) LinkTextColor else Color.Unspecified,
TextDecoration.LineThrough textDecoration = when {
} else { isLink && Fb2TextStyle.Strikethrough in span.styles ->
null TextDecoration.combine(listOf(TextDecoration.Underline, TextDecoration.LineThrough))
isLink -> TextDecoration.Underline
Fb2TextStyle.Strikethrough in span.styles -> TextDecoration.LineThrough
else -> null
}, },
baselineShift = when { baselineShift = when {
Fb2TextStyle.Superscript in span.styles -> BaselineShift.Superscript Fb2TextStyle.Superscript in span.styles -> BaselineShift.Superscript
@ -969,9 +1134,13 @@ private fun Fb2Text.toAnnotatedString(
else -> null else -> null
}, },
) )
span.href?.takeIf { it.isNotBlank() }?.let {
pushStringAnnotation(ReaderLinkAnnotationTag, it)
}
withStyle(spanStyle) { withStyle(spanStyle) {
appendWithHighlight(span.text, plainOffset, highlightedRange, highlightColor, language, hyphenation) appendWithHighlight(span.text, plainOffset, highlightedRange, highlightColor, language, hyphenation)
} }
if (isLink) pop()
plainOffset += span.text.length plainOffset += span.text.length
} }
} }
@ -1350,6 +1519,8 @@ private const val StarBreakPauseMillis = 1_200L
private const val EllipsisPauseAfterMillis = 350L private const val EllipsisPauseAfterMillis = 350L
private const val CombiningAcuteAccent = '\u0301' private const val CombiningAcuteAccent = '\u0301'
private const val ReadAloudStressableLetters = "аеёиоуыэюяАЕЁИОУЫЭЮЯaeiouyAEIOUY" private const val ReadAloudStressableLetters = "аеёиоуыэюяАЕЁИОУЫЭЮЯaeiouyAEIOUY"
private const val ReaderLinkAnnotationTag = "fb2-link"
private val LinkTextColor = Color(0xFF0B57D0)
private val ReadAloudHardcodedTextReplacements = listOf( private val ReadAloudHardcodedTextReplacements = listOf(
ReadAloudTextReplacement(from = "Господа,", to = "Господ/а,", caseSensitive = true), ReadAloudTextReplacement(from = "Господа,", to = "Господ/а,", caseSensitive = true),

View File

@ -87,6 +87,7 @@ internal fun BookView(
var readAloudSettingsVisible by remember(fileId) { mutableStateOf(false) } var readAloudSettingsVisible by remember(fileId) { mutableStateOf(false) }
var readAloudResumeSentenceIndex by remember(fileId) { mutableStateOf<Int?>(null) } var readAloudResumeSentenceIndex by remember(fileId) { mutableStateOf<Int?>(null) }
var userScrollGeneration by remember(fileId) { mutableStateOf(0) } var userScrollGeneration by remember(fileId) { mutableStateOf(0) }
var selectedNoteId by remember(fileId) { mutableStateOf<String?>(null) }
val readAloudState by ReadAloudPlatform.state.collectAsState() val readAloudState by ReadAloudPlatform.state.collectAsState()
val readAloudSettings by ReadAloudPlatform.settingsState.collectAsState() val readAloudSettings by ReadAloudPlatform.settingsState.collectAsState()
val platformName = getPlatform().name val platformName = getPlatform().name
@ -306,6 +307,7 @@ internal fun BookView(
highlightedSentence = highlightedSentence, highlightedSentence = highlightedSentence,
onUserScroll = { userScrollGeneration += 1 }, onUserScroll = { userScrollGeneration += 1 },
onImageOpen = onImageOpen, onImageOpen = onImageOpen,
onNoteOpen = { href -> selectedNoteId = href.removePrefix("#") },
) )
if (readAloudPanelVisible && readAloudState.active) { if (readAloudPanelVisible && readAloudState.active) {
ReadAloudPanel( ReadAloudPanel(
@ -338,6 +340,16 @@ internal fun BookView(
onVoiceSelected = { ReadAloudPlatform.selectVoice(it) }, onVoiceSelected = { ReadAloudPlatform.selectVoice(it) },
) )
} }
selectedNoteId?.let { noteId ->
Fb2NoteReferenceDialog(
book = book,
noteId = noteId,
onDismiss = { selectedNoteId = null },
onImageOpen = onImageOpen,
onNoteOpen = { href -> selectedNoteId = href.removePrefix("#") },
)
}
} }
@Composable @Composable

View File

@ -17,6 +17,7 @@ data class Fb2Book(
val bodyImages: List<Fb2ImageRef> = emptyList(), val bodyImages: List<Fb2ImageRef> = emptyList(),
val bodyEpigraphs: List<Fb2Epigraph> = emptyList(), val bodyEpigraphs: List<Fb2Epigraph> = emptyList(),
val sections: List<Fb2Section> = emptyList(), val sections: List<Fb2Section> = emptyList(),
val notes: Map<String, Fb2Section> = emptyMap(),
val binaries: List<Fb2Binary> = emptyList(), val binaries: List<Fb2Binary> = emptyList(),
) { ) {
fun binaryFor(image: Fb2ImageRef): Fb2Binary? = fun binaryFor(image: Fb2ImageRef): Fb2Binary? =
@ -47,6 +48,7 @@ data class Fb2DocumentInfo(
) )
data class Fb2Section( data class Fb2Section(
val id: String? = null,
val title: String? = null, val title: String? = null,
val paragraphs: List<String> = emptyList(), val paragraphs: List<String> = emptyList(),
val images: List<Fb2ImageRef> = emptyList(), val images: List<Fb2ImageRef> = emptyList(),
@ -124,6 +126,8 @@ data class Fb2Text(
data class Fb2TextSpan( data class Fb2TextSpan(
val text: String, val text: String,
val styles: Set<Fb2TextStyle> = emptySet(), val styles: Set<Fb2TextStyle> = emptySet(),
val href: String? = null,
val linkType: String? = null,
) )
enum class Fb2TextStyle { enum class Fb2TextStyle {

View File

@ -13,8 +13,15 @@ internal object Fb2XmlMapper {
?: throw Fb2ParseException("FB2 document is missing description/title-info") ?: throw Fb2ParseException("FB2 document is missing description/title-info")
val documentInfo = description.first("document-info") val documentInfo = description.first("document-info")
val body = root.children("body").firstOrNull { it.attributes["name"] != "notes" } val bodies = root.children("body")
val body = bodies.firstOrNull { it.attributes["name"] != "notes" }
?: root.first("body") ?: root.first("body")
val notes = bodies
.filter { it.attributes["name"] == "notes" }
.flatMap { it.children("section") }
.map(::sectionFrom)
.mapNotNull { section -> section.id?.let { it to section } }
.toMap()
return Fb2Book( return Fb2Book(
title = titleInfo.firstText("book-title") title = titleInfo.firstText("book-title")
@ -34,6 +41,7 @@ internal object Fb2XmlMapper {
bodyImages = body?.children("image")?.mapNotNull(::imageRefFrom).orEmpty(), bodyImages = body?.children("image")?.mapNotNull(::imageRefFrom).orEmpty(),
bodyEpigraphs = body?.children("epigraph")?.map(::epigraphFrom).orEmpty(), bodyEpigraphs = body?.children("epigraph")?.map(::epigraphFrom).orEmpty(),
sections = body?.children("section")?.map(::sectionFrom).orEmpty(), sections = body?.children("section")?.map(::sectionFrom).orEmpty(),
notes = notes,
binaries = root.children("binary").mapNotNull(::binaryFrom), binaries = root.children("binary").mapNotNull(::binaryFrom),
) )
} }
@ -135,6 +143,7 @@ internal object Fb2XmlMapper {
} }
return Fb2Section( return Fb2Section(
id = element.attributes["id"]?.takeIf { it.isNotBlank() },
title = element.first("title")?.text()?.ifBlank { null }, title = element.first("title")?.text()?.ifBlank { null },
paragraphs = blocks.filterIsInstance<Fb2Block.Paragraph>() paragraphs = blocks.filterIsInstance<Fb2Block.Paragraph>()
.mapNotNull { it.content.plainText.ifBlank { null } }, .mapNotNull { it.content.plainText.ifBlank { null } },
@ -203,11 +212,23 @@ internal object Fb2XmlMapper {
private fun textFrom(element: XmlElement): Fb2Text = private fun textFrom(element: XmlElement): Fb2Text =
Fb2Text(spansFrom(element.nodes).mergeAdjacent().trimBoundaryWhitespace()) Fb2Text(spansFrom(element.nodes).mergeAdjacent().trimBoundaryWhitespace())
private fun spansFrom(nodes: List<XmlNode>, styles: Set<Fb2TextStyle> = emptySet()): List<Fb2TextSpan> = private fun spansFrom(
nodes: List<XmlNode>,
styles: Set<Fb2TextStyle> = emptySet(),
href: String? = null,
linkType: String? = null,
): List<Fb2TextSpan> =
nodes.flatMap { node -> nodes.flatMap { node ->
when (node) { when (node) {
is XmlNode.TextNode -> listOfNotNull( is XmlNode.TextNode -> listOfNotNull(
node.text.takeIf { it.isNotEmpty() }?.let { Fb2TextSpan(it, styles) } node.text.takeIf { it.isNotEmpty() }?.let {
Fb2TextSpan(
text = it,
styles = styles,
href = href,
linkType = linkType,
)
}
) )
is XmlNode.ElementNode -> { is XmlNode.ElementNode -> {
val element = node.element val element = node.element
@ -220,7 +241,13 @@ internal object Fb2XmlMapper {
"sub" -> styles + Fb2TextStyle.Subscript "sub" -> styles + Fb2TextStyle.Subscript
else -> styles else -> styles
} }
spansFrom(element.nodes, nestedStyles) val nestedHref = element.attributeLocal("href")
?.takeIf { element.localName == "a" && it.isNotBlank() }
?: href
val nestedLinkType = element.attributes["type"]
?.takeIf { element.localName == "a" && it.isNotBlank() }
?: linkType
spansFrom(element.nodes, nestedStyles, nestedHref, nestedLinkType)
} }
} }
} }
@ -230,7 +257,11 @@ internal object Fb2XmlMapper {
val merged = mutableListOf<Fb2TextSpan>() val merged = mutableListOf<Fb2TextSpan>()
forEach { span -> forEach { span ->
val previous = merged.lastOrNull() val previous = merged.lastOrNull()
if (previous != null && previous.styles == span.styles) { if (previous != null &&
previous.styles == span.styles &&
previous.href == span.href &&
previous.linkType == span.linkType
) {
merged[merged.lastIndex] = previous.copy(text = previous.text + span.text) merged[merged.lastIndex] = previous.copy(text = previous.text + span.text)
} else { } else {
merged += span merged += span

View File

@ -116,6 +116,18 @@ class Fb2FormatTest {
assertEquals("Poem quote", (poemEpigraph.blocks.single() as Fb2EpigraphBlock.Paragraph).content.plainText) assertEquals("Poem quote", (poemEpigraph.blocks.single() as Fb2EpigraphBlock.Paragraph).content.plainText)
} }
@Test
fun parsesNoteLinksAndNotesBody() {
val book = Fb2Format.parseXml(notesXml)
val paragraph = book.sections.single().blocks.single() as Fb2Block.Paragraph
val link = paragraph.content.spans.single { it.href != null }
assertEquals("[1]", link.text)
assertEquals("#n_1", link.href)
assertEquals("note", link.linkType)
assertEquals("Note text.", (book.notes.getValue("n_1").blocks.single() as Fb2Block.Paragraph).content.plainText)
}
private val sampleXml = """ private val sampleXml = """
<?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:xlink="http://www.w3.org/1999/xlink"> <FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0" xmlns:xlink="http://www.w3.org/1999/xlink">
@ -261,6 +273,35 @@ class Fb2FormatTest {
</FictionBook> </FictionBook>
""".trimIndent() """.trimIndent()
private val notesXml = """
<?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">
<description>
<title-info>
<author><nickname>A</nickname></author>
<book-title>Notes</book-title>
<lang>en</lang>
</title-info>
<document-info>
<author><nickname>Toread</nickname></author>
<date>2026-05-12</date>
<id>notes</id>
<version>1.0</version>
</document-info>
</description>
<body>
<section>
<p>Main<a l:href="#n_1" type="note">[1]</a>.</p>
</section>
</body>
<body name="notes">
<section id="n_1">
<p>Note text.</p>
</section>
</body>
</FictionBook>
""".trimIndent()
private fun String.encodeWindows1251(): ByteArray = private fun String.encodeWindows1251(): ByteArray =
ByteArray(length) { index -> ByteArray(length) { index ->
val char = this[index] val char = this[index]