diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt index 8df7117..2917118 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width 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.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue 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.graphicsLayer 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.pointerInput +import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.input.nestedscroll.NestedScrollConnection @@ -107,6 +112,7 @@ internal fun ContinuousBookReader( highlightedSentence: ReadAloudSentence? = null, onUserScroll: () -> Unit = {}, onImageOpen: (ViewedBookImage) -> Unit = {}, + onNoteOpen: (String) -> Unit = {}, ) { val hyphenation = remember { HyphenationRegistry() } val scope = rememberCoroutineScope() @@ -204,6 +210,7 @@ internal fun ContinuousBookReader( textAlign = TextAlign.Justify, modifier = Modifier.padding(start = (element.depth * 8).dp, end = 0.dp), onTextLayout = { textLineMetricsByItem[itemIndex] = it.toTextLineMetrics() }, + onLinkOpen = onNoteOpen, ) is ReaderElement.Subtitle -> ReaderText( text = element.text, @@ -214,6 +221,7 @@ internal fun ContinuousBookReader( textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().padding(top = 18.dp, bottom = 8.dp), onTextLayout = { textLineMetricsByItem[itemIndex] = it.toTextLineMetrics() }, + onLinkOpen = onNoteOpen, ) is ReaderElement.Epigraph -> ReaderEpigraph( epigraph = element.epigraph, @@ -221,6 +229,7 @@ internal fun ContinuousBookReader( hyphenation = hyphenation, highlightedRange = highlightedRange, depth = element.depth, + onLinkOpen = onNoteOpen, ) is ReaderElement.Cite -> ReaderCite( cite = element.cite, @@ -228,6 +237,7 @@ internal fun ContinuousBookReader( hyphenation = hyphenation, highlightedRange = highlightedRange, depth = element.depth, + onLinkOpen = onNoteOpen, ) is ReaderElement.Poem -> ReaderPoem( poem = element.poem, @@ -235,6 +245,7 @@ internal fun ContinuousBookReader( hyphenation = hyphenation, highlightedRange = highlightedRange, depth = element.depth, + onLinkOpen = onNoteOpen, ) } } @@ -246,7 +257,8 @@ private fun Modifier.pageTurnOnTouchTap( onPageUp: () -> Unit, ): Modifier = pointerInput(onPageDown, onPageUp) { 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) { waitForUpOrCancellation(pass = PointerEventPass.Final) return@awaitEachGesture @@ -258,7 +270,7 @@ private fun Modifier.pageTurnOnTouchTap( var cancelled = false 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 } if (change == null) { cancelled = true @@ -345,6 +357,91 @@ private fun TextLayoutResult.toTextLineMetrics(): TextLineMetrics = 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 private fun DetailsPane( book: Fb2Book, @@ -499,6 +596,7 @@ private fun ReaderPane(book: Fb2Book, section: Fb2Section?, modifier: Modifier = hyphenation = hyphenation, highlightedRange = null, depth = 0, + onLinkOpen = {}, ) is Fb2Block.Subtitle -> ReaderText( text = block.content, @@ -514,6 +612,7 @@ private fun ReaderPane(book: Fb2Book, section: Fb2Section?, modifier: Modifier = hyphenation = hyphenation, highlightedRange = null, depth = 0, + onLinkOpen = {}, ) is Fb2Block.Cite -> ReaderCite( cite = block.cite, @@ -521,6 +620,7 @@ private fun ReaderPane(book: Fb2Book, section: Fb2Section?, modifier: Modifier = hyphenation = hyphenation, highlightedRange = null, depth = 0, + onLinkOpen = {}, ) } } @@ -538,15 +638,30 @@ private fun ReaderText( modifier: Modifier = Modifier, highlightedRange: ReaderSentenceRange? = null, onTextLayout: (TextLayoutResult) -> Unit = {}, + onLinkOpen: (String) -> Unit = {}, ) { val highlightColor = MaterialTheme.colorScheme.secondaryContainer val annotatedText = text.toAnnotatedString(language, hyphenation, highlightedRange, highlightColor) + val hasLinks = annotatedText.hasReaderLinks() val needsSoftHyphenPaintWorkaround = isDesktopPlatform var textLayout by remember(annotatedText) { mutableStateOf(null) } val desktopHyphenColor = MaterialTheme.colorScheme.onSurface val desktopHyphenGutter = 8.dp val textModifier = modifier .fillMaxWidth() + .then( + if (hasLinks) { + Modifier + .pointerHoverIcon(PointerIcon.Hand) + .readerLinkTapHandler( + annotatedText = annotatedText, + textLayout = textLayout, + onLinkOpen = onLinkOpen, + ) + } else { + Modifier + }, + ) .then( if (needsSoftHyphenPaintWorkaround) { Modifier.drawWithContent { @@ -598,6 +713,7 @@ private fun ReaderPoem( highlightedRange: ReaderSentenceRange?, depth: Int, modifier: Modifier = Modifier, + onLinkOpen: (String) -> Unit = {}, ) { val segments = remember(poem) { poem.readerSegments() } Column( @@ -613,6 +729,7 @@ private fun ReaderPoem( highlightedRange = highlightedRange, depth = 0, modifier = Modifier.padding(top = if (index == 0) 0.dp else 8.dp, bottom = 8.dp), + onLinkOpen = onLinkOpen, ) } segments.forEach { segment -> @@ -636,6 +753,7 @@ private fun ReaderPoem( ReaderPoemSegmentKind.Verse -> Modifier.padding(start = 22.dp) else -> Modifier }, + onLinkOpen = onLinkOpen, ) } } @@ -649,6 +767,7 @@ private fun ReaderEpigraph( highlightedRange: ReaderSentenceRange?, depth: Int, modifier: Modifier = Modifier, + onLinkOpen: (String) -> Unit = {}, ) { Column( modifier = modifier @@ -663,6 +782,7 @@ private fun ReaderEpigraph( hyphenation = hyphenation, highlightedRange = null, depth = 0, + onLinkOpen = onLinkOpen, ) } epigraph.textAuthors.forEach { author -> @@ -674,6 +794,7 @@ private fun ReaderEpigraph( highlightedRange = null, textAlign = TextAlign.End, modifier = Modifier.fillMaxWidth().padding(top = 2.dp), + onLinkOpen = onLinkOpen, ) } } @@ -687,6 +808,7 @@ private fun ReaderCite( highlightedRange: ReaderSentenceRange?, depth: Int, modifier: Modifier = Modifier, + onLinkOpen: (String) -> Unit = {}, ) { Column( modifier = modifier @@ -701,6 +823,7 @@ private fun ReaderCite( hyphenation = hyphenation, highlightedRange = null, depth = 0, + onLinkOpen = onLinkOpen, ) } cite.textAuthors.forEach { author -> @@ -712,6 +835,7 @@ private fun ReaderCite( highlightedRange = null, textAlign = TextAlign.End, modifier = Modifier.fillMaxWidth().padding(top = 2.dp), + onLinkOpen = onLinkOpen, ) } } @@ -724,6 +848,7 @@ private fun ReaderEpigraphBlock( hyphenation: HyphenationRegistry, highlightedRange: ReaderSentenceRange?, depth: Int, + onLinkOpen: (String) -> Unit, ) { when (block) { Fb2EpigraphBlock.EmptyLine -> Spacer(Modifier.height(12.dp)) @@ -734,6 +859,7 @@ private fun ReaderEpigraphBlock( style = epigraphTextStyle(language), highlightedRange = highlightedRange, textAlign = TextAlign.Start, + onLinkOpen = onLinkOpen, ) is Fb2EpigraphBlock.Subtitle -> ReaderText( text = block.content, @@ -742,6 +868,7 @@ private fun ReaderEpigraphBlock( style = epigraphTextStyle(language).copy(fontWeight = FontWeight.SemiBold), highlightedRange = highlightedRange, textAlign = TextAlign.Center, + onLinkOpen = onLinkOpen, ) is Fb2EpigraphBlock.Poem -> ReaderPoem( poem = block.poem, @@ -749,6 +876,7 @@ private fun ReaderEpigraphBlock( hyphenation = hyphenation, highlightedRange = highlightedRange, depth = depth, + onLinkOpen = onLinkOpen, ) is Fb2EpigraphBlock.Cite -> ReaderCite( cite = block.cite, @@ -756,6 +884,7 @@ private fun ReaderEpigraphBlock( hyphenation = hyphenation, highlightedRange = highlightedRange, 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( language: String?, hyphenation: HyphenationRegistry, @@ -954,14 +1115,18 @@ private fun Fb2Text.toAnnotatedString( buildAnnotatedString { var plainOffset = 0 spans.forEach { span -> + val isLink = span.href.isNullOrBlank().not() val spanStyle = SpanStyle( fontStyle = if (Fb2TextStyle.Emphasis in span.styles) FontStyle.Italic else null, fontWeight = if (Fb2TextStyle.Strong in span.styles) FontWeight.Bold else null, fontFamily = if (Fb2TextStyle.Code in span.styles) FontFamily.Monospace else null, - textDecoration = if (Fb2TextStyle.Strikethrough in span.styles) { - TextDecoration.LineThrough - } else { - null + color = if (isLink) LinkTextColor else Color.Unspecified, + textDecoration = when { + isLink && Fb2TextStyle.Strikethrough in span.styles -> + TextDecoration.combine(listOf(TextDecoration.Underline, TextDecoration.LineThrough)) + isLink -> TextDecoration.Underline + Fb2TextStyle.Strikethrough in span.styles -> TextDecoration.LineThrough + else -> null }, baselineShift = when { Fb2TextStyle.Superscript in span.styles -> BaselineShift.Superscript @@ -969,9 +1134,13 @@ private fun Fb2Text.toAnnotatedString( else -> null }, ) + span.href?.takeIf { it.isNotBlank() }?.let { + pushStringAnnotation(ReaderLinkAnnotationTag, it) + } withStyle(spanStyle) { appendWithHighlight(span.text, plainOffset, highlightedRange, highlightColor, language, hyphenation) } + if (isLink) pop() plainOffset += span.text.length } } @@ -1350,6 +1519,8 @@ private const val StarBreakPauseMillis = 1_200L private const val EllipsisPauseAfterMillis = 350L private const val CombiningAcuteAccent = '\u0301' private const val ReadAloudStressableLetters = "аеёиоуыэюяАЕЁИОУЫЭЮЯaeiouyAEIOUY" +private const val ReaderLinkAnnotationTag = "fb2-link" +private val LinkTextColor = Color(0xFF0B57D0) private val ReadAloudHardcodedTextReplacements = listOf( ReadAloudTextReplacement(from = "Господа,", to = "Господ/а,", caseSensitive = true), diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt index f30cfb4..ada3f1b 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt @@ -87,6 +87,7 @@ internal fun BookView( var readAloudSettingsVisible by remember(fileId) { mutableStateOf(false) } var readAloudResumeSentenceIndex by remember(fileId) { mutableStateOf(null) } var userScrollGeneration by remember(fileId) { mutableStateOf(0) } + var selectedNoteId by remember(fileId) { mutableStateOf(null) } val readAloudState by ReadAloudPlatform.state.collectAsState() val readAloudSettings by ReadAloudPlatform.settingsState.collectAsState() val platformName = getPlatform().name @@ -306,6 +307,7 @@ internal fun BookView( highlightedSentence = highlightedSentence, onUserScroll = { userScrollGeneration += 1 }, onImageOpen = onImageOpen, + onNoteOpen = { href -> selectedNoteId = href.removePrefix("#") }, ) if (readAloudPanelVisible && readAloudState.active) { ReadAloudPanel( @@ -338,6 +340,16 @@ internal fun BookView( onVoiceSelected = { ReadAloudPlatform.selectVoice(it) }, ) } + + selectedNoteId?.let { noteId -> + Fb2NoteReferenceDialog( + book = book, + noteId = noteId, + onDismiss = { selectedNoteId = null }, + onImageOpen = onImageOpen, + onNoteOpen = { href -> selectedNoteId = href.removePrefix("#") }, + ) + } } @Composable diff --git a/shared/src/commonMain/kotlin/net/sergeych/toread/fb2/Fb2Book.kt b/shared/src/commonMain/kotlin/net/sergeych/toread/fb2/Fb2Book.kt index 90d537a..5e482a7 100644 --- a/shared/src/commonMain/kotlin/net/sergeych/toread/fb2/Fb2Book.kt +++ b/shared/src/commonMain/kotlin/net/sergeych/toread/fb2/Fb2Book.kt @@ -17,6 +17,7 @@ data class Fb2Book( val bodyImages: List = emptyList(), val bodyEpigraphs: List = emptyList(), val sections: List = emptyList(), + val notes: Map = emptyMap(), val binaries: List = emptyList(), ) { fun binaryFor(image: Fb2ImageRef): Fb2Binary? = @@ -47,6 +48,7 @@ data class Fb2DocumentInfo( ) data class Fb2Section( + val id: String? = null, val title: String? = null, val paragraphs: List = emptyList(), val images: List = emptyList(), @@ -124,6 +126,8 @@ data class Fb2Text( data class Fb2TextSpan( val text: String, val styles: Set = emptySet(), + val href: String? = null, + val linkType: String? = null, ) enum class Fb2TextStyle { diff --git a/shared/src/commonMain/kotlin/net/sergeych/toread/fb2/Fb2XmlMapper.kt b/shared/src/commonMain/kotlin/net/sergeych/toread/fb2/Fb2XmlMapper.kt index 97c9297..1cadcfb 100644 --- a/shared/src/commonMain/kotlin/net/sergeych/toread/fb2/Fb2XmlMapper.kt +++ b/shared/src/commonMain/kotlin/net/sergeych/toread/fb2/Fb2XmlMapper.kt @@ -13,8 +13,15 @@ internal object Fb2XmlMapper { ?: throw Fb2ParseException("FB2 document is missing description/title-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") + 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( title = titleInfo.firstText("book-title") @@ -34,6 +41,7 @@ internal object Fb2XmlMapper { bodyImages = body?.children("image")?.mapNotNull(::imageRefFrom).orEmpty(), bodyEpigraphs = body?.children("epigraph")?.map(::epigraphFrom).orEmpty(), sections = body?.children("section")?.map(::sectionFrom).orEmpty(), + notes = notes, binaries = root.children("binary").mapNotNull(::binaryFrom), ) } @@ -135,6 +143,7 @@ internal object Fb2XmlMapper { } return Fb2Section( + id = element.attributes["id"]?.takeIf { it.isNotBlank() }, title = element.first("title")?.text()?.ifBlank { null }, paragraphs = blocks.filterIsInstance() .mapNotNull { it.content.plainText.ifBlank { null } }, @@ -203,11 +212,23 @@ internal object Fb2XmlMapper { private fun textFrom(element: XmlElement): Fb2Text = Fb2Text(spansFrom(element.nodes).mergeAdjacent().trimBoundaryWhitespace()) - private fun spansFrom(nodes: List, styles: Set = emptySet()): List = + private fun spansFrom( + nodes: List, + styles: Set = emptySet(), + href: String? = null, + linkType: String? = null, + ): List = nodes.flatMap { node -> when (node) { 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 -> { val element = node.element @@ -220,7 +241,13 @@ internal object Fb2XmlMapper { "sub" -> styles + Fb2TextStyle.Subscript 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() forEach { span -> 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) } else { merged += span diff --git a/shared/src/commonTest/kotlin/net/sergeych/toread/fb2/Fb2FormatTest.kt b/shared/src/commonTest/kotlin/net/sergeych/toread/fb2/Fb2FormatTest.kt index a23245b..bcb0fde 100644 --- a/shared/src/commonTest/kotlin/net/sergeych/toread/fb2/Fb2FormatTest.kt +++ b/shared/src/commonTest/kotlin/net/sergeych/toread/fb2/Fb2FormatTest.kt @@ -116,6 +116,18 @@ class Fb2FormatTest { 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 = """ @@ -261,6 +273,35 @@ class Fb2FormatTest { """.trimIndent() + private val notesXml = """ + + + + + A + Notes + en + + + Toread + 2026-05-12 + notes + 1.0 + + + +
+

Main[1].

+
+ + +
+

Note text.

+
+ +
+ """.trimIndent() + private fun String.encodeWindows1251(): ByteArray = ByteArray(length) { index -> val char = this[index]