/* * Episteme Reader - A native Android document reader. * Copyright (C) 2026 Episteme * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . * * mail: epistemereader@gmail.com */ package com.aryan.reader.pdf import android.content.Context import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.SoftwareKeyboardController import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextMeasurer import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.sp import com.aryan.reader.pdf.data.VirtualPage import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.json.JSONArray import org.json.JSONObject import timber.log.Timber import java.io.File const val PAGE_BREAK_CHAR = '\u000C' private const val ZWSP = "\u200B" internal fun String.hasRenderableRichText(): Boolean = any { it != PAGE_BREAK_CHAR && !it.isWhitespace() } object PdfFontCache { private val cache = ConcurrentHashMap() private var assetManager: android.content.res.AssetManager? = null fun init(assets: android.content.res.AssetManager) { this.assetManager = assets } fun getFontFamily(path: String?): FontFamily { if (path.isNullOrBlank()) { return FontFamily.Default } return cache.getOrPut(path) { try { if (path.startsWith("asset:")) { val assetPath = path.removePrefix("asset:") assetManager?.let { Timber.tag("PdfFontDebug").i("Loading font from assets: $assetPath") FontFamily(Font(assetPath, it)) } ?: FontFamily.Default } else { val file = File(path) if (file.exists()) { FontFamily(Font(file)) } else { FontFamily.Default } } } catch (e: Exception) { Timber.tag("PdfFontDebug").e(e, "getFontFamily: Failed to load $path") FontFamily.Default } } } fun getPath(fontFamily: FontFamily?): String? { if (fontFamily == null) return null return cache.entries.find { it.value == fontFamily }?.key } } data class GlobalRichSpan( val start: Int, val end: Int, val color: Int, val backgroundColor: Int, val fontSizeNorm: Float, val isBold: Boolean, val isItalic: Boolean, val isUnderline: Boolean, val isStrikethrough: Boolean, val fontPath: String? = null ) data class GlobalRichDocument(val text: String, val spans: List) data class PageTextLayout( val pageIndex: Int, val visibleText: AnnotatedString, val globalStartIndex: Int, val globalEndIndex: Int, val pageHeightPx: Float ) object RichTextMapper { fun toAnnotatedString( document: GlobalRichDocument, pageHeightPx: Float, rangeStart: Int = 0, rangeEnd: Int = document.text.length ): AnnotatedString { val safeGlobalStart = rangeStart.coerceIn(0, document.text.length) val safeGlobalEnd = rangeEnd.coerceIn(safeGlobalStart, document.text.length) if (safeGlobalStart == safeGlobalEnd) return AnnotatedString("") val textSubstring = document.text.substring(safeGlobalStart, safeGlobalEnd) return buildAnnotatedString { append(textSubstring) var count = 0 for (span in document.spans) { if (span.start >= safeGlobalEnd) break if (span.end <= safeGlobalStart) continue val intersectionStart = maxOf(span.start, safeGlobalStart) val intersectionEnd = minOf(span.end, safeGlobalEnd) val fontFamily = PdfFontCache.getFontFamily(span.fontPath) if (span.fontPath != null) { Timber.tag("PdfFontDebug").v("toAnnotatedString: Applying font path ${span.fontPath} to span [${span.start}..${span.end}]") } if (intersectionStart < intersectionEnd) { val fontSizePx = if (pageHeightPx > 0) span.fontSizeNorm * pageHeightPx else 16f val decorations = mutableListOf() if (span.isUnderline) decorations.add(TextDecoration.Underline) if (span.isStrikethrough) decorations.add(TextDecoration.LineThrough) addStyle( style = SpanStyle( color = Color(span.color), background = Color(span.backgroundColor), fontSize = fontSizePx.sp, fontWeight = if (span.isBold) FontWeight.Bold else FontWeight.Normal, fontStyle = if (span.isItalic) FontStyle.Italic else FontStyle.Normal, textDecoration = if (decorations.isNotEmpty()) TextDecoration.combine(decorations) else TextDecoration.None, fontFamily = PdfFontCache.getFontFamily(span.fontPath) ), start = intersectionStart - safeGlobalStart, end = intersectionEnd - safeGlobalStart ) count++ } } Timber.tag("TextAnnotStyle").v("toAnnotatedString: Final local spans: $count") } } fun fromAnnotatedString(text: AnnotatedString, pageHeightPx: Float): GlobalRichDocument { val pageBreakCount = text.text.count { it == PAGE_BREAK_CHAR } Timber.tag("RichTextFlow").v("fromAnnotatedString: Mapping text. Len=${text.length}, PageBreaks=$pageBreakCount") if (text.isEmpty()) return GlobalRichDocument("", emptyList()) val spans = mutableListOf() val changePoints = sortedSetOf(0, text.length) text.spanStyles.forEach { changePoints.add(it.start) changePoints.add(it.end) } val sortedPoints = changePoints.toList() for (i in 0 until sortedPoints.size - 1) { val start = sortedPoints[i] val end = sortedPoints[i + 1] if (start >= end) continue val activeStyles = text.spanStyles.filter { it.start <= start && it.end >= end } var effective = SpanStyle(color = Color.Black, fontSize = 16.sp) activeStyles.forEach { effective = effective.merge(it.item) } val fsNorm = if (effective.fontSize.isSp) { if (pageHeightPx > 0) (effective.fontSize.value / pageHeightPx) else 0.015f } else 0.015f val currentDec = effective.textDecoration ?: TextDecoration.None val isUnd = currentDec.contains(TextDecoration.Underline) val isStr = currentDec.contains(TextDecoration.LineThrough) val newSpan = GlobalRichSpan( start = start, end = end, color = effective.color.toArgb(), backgroundColor = effective.background.toArgb(), fontSizeNorm = fsNorm, isBold = effective.fontWeight == FontWeight.Bold, isItalic = effective.fontStyle == FontStyle.Italic, isUnderline = isUnd, isStrikethrough = isStr, fontPath = PdfFontCache.getPath(effective.fontFamily) ) if (spans.isNotEmpty()) { val last = spans.last() if (last.end == start && last.color == newSpan.color && last.backgroundColor == newSpan.backgroundColor && last.fontSizeNorm == newSpan.fontSizeNorm && last.isBold == newSpan.isBold && last.isItalic == newSpan.isItalic && last.isUnderline == newSpan.isUnderline && last.isStrikethrough == newSpan.isStrikethrough && last.fontPath == newSpan.fontPath ) { spans[spans.lastIndex] = last.copy(end = end) } else { spans.add(newSpan) } } else { spans.add(newSpan) } } val result = GlobalRichDocument(text.text, spans) Timber.tag("TextAnnotStyle").d("fromAnnotatedString: Created GlobalRichDocument with ${spans.size} merged spans") return result } } class TextPaginationEngine { fun paginate( globalText: AnnotatedString, pageWidthPx: Float, pageHeightPx: Float, textMeasurer: TextMeasurer, density: Density, marginX: Float, marginY: Float, previousLayouts: List = emptyList(), dirtyGlobalIndex: Int = 0 ): List { val totalLen = globalText.length Timber.d( "android.paginate start textLen=$totalLen page=${pageWidthPx.richAndroidLogFloat()}x${pageHeightPx.richAndroidLogFloat()} " + "margin=${marginX.richAndroidLogFloat()},${marginY.richAndroidLogFloat()} prev=${previousLayouts.size} dirty=$dirtyGlobalIndex" ) if (totalLen == 0) { Timber.d("android.paginate empty -> p0:0-0") return listOf( PageTextLayout(0, AnnotatedString(""), 0, 0, pageHeightPx) ) } if (pageWidthPx <= 0 || pageHeightPx <= 0) { Timber.d("android.paginate aborted invalid page size") return emptyList() } val editorWidth = (pageWidthPx - (marginX * 2)).coerceAtLeast(10f) val editorHeight = (pageHeightPx - (marginY * 2)).coerceAtLeast(10f) val newPages = mutableListOf() var currentPageIndex = 0 var segmentStart = 0 val rawText = globalText.text while (segmentStart < totalLen) { val breakIndex = rawText.indexOf(PAGE_BREAK_CHAR, startIndex = segmentStart) val hasExplicitBreak = breakIndex != -1 val contentEnd = if (hasExplicitBreak) breakIndex else totalLen val segmentEnd = if (hasExplicitBreak) breakIndex + 1 else totalLen currentPageIndex = newPages.appendMeasuredAndroidRichTextSegment( globalText = globalText, segmentStart = segmentStart, contentEnd = contentEnd, explicitBreakEnd = if (hasExplicitBreak) segmentEnd else null, pageIndex = currentPageIndex, pageHeightPx = pageHeightPx, editorWidth = editorWidth, editorHeight = editorHeight, textMeasurer = textMeasurer, density = density ) segmentStart = segmentEnd } val resultLayouts = newPages.withTrailingAndroidBlankRichTextPageIfNeeded( globalText = globalText, pageHeightPx = pageHeightPx ) val mapLog = resultLayouts.joinToString("\n") { " Page ${it.pageIndex}: Global[${it.globalStartIndex}..${it.globalEndIndex}]" } Timber.tag("RichTextMigration").i("Pagination Map Generated:\n$mapLog") Timber.d("android.paginate done -> ${resultLayouts.richAndroidLayoutSummary()}") return resultLayouts } } private fun MutableList.appendMeasuredAndroidRichTextSegment( globalText: AnnotatedString, segmentStart: Int, contentEnd: Int, explicitBreakEnd: Int?, pageIndex: Int, pageHeightPx: Float, editorWidth: Float, editorHeight: Float, textMeasurer: TextMeasurer, density: Density ): Int { var nextPageIndex = pageIndex if (segmentStart >= contentEnd) { val breakEnd = explicitBreakEnd ?: return nextPageIndex add( PageTextLayout( pageIndex = nextPageIndex, visibleText = globalText.subSequence(segmentStart, breakEnd), globalStartIndex = segmentStart, globalEndIndex = breakEnd, pageHeightPx = pageHeightPx ) ) Timber.d( "android.paginate pageBreakOnly page=$nextPageIndex global=$segmentStart..$breakEnd" ) return nextPageIndex + 1 } val contentLength = contentEnd - segmentStart var relativeStart = 0 while (relativeStart < contentLength) { val globalStart = segmentStart + relativeStart val remainingText = globalText.subSequence(globalStart, contentEnd) val measureResult = textMeasurer.measure( text = remainingText, style = TextStyle(fontSize = 16.sp, color = Color.Black), constraints = Constraints(maxWidth = editorWidth.toInt(), maxHeight = Constraints.Infinity), density = density ) val fitsOnPage = measureResult.size.height.toFloat() <= editorHeight || measureResult.lineCount <= 1 var overflowLineIndex: Int? = null val relativeEnd = if (fitsOnPage) { contentLength } else { val lineIndex = measureResult.richAndroidLastFittingLineIndex(editorHeight) overflowLineIndex = lineIndex val localEnd = measureResult.getLineEnd(lineIndex) .coerceIn(0, remainingText.length) .coerceAtLeast(1) (relativeStart + localEnd) .coerceAtLeast(relativeStart + 1) .coerceAtMost(contentLength) } val isLastContentPage = relativeEnd >= contentLength val globalEnd = if (isLastContentPage && explicitBreakEnd != null) { explicitBreakEnd } else { segmentStart + relativeEnd } add( PageTextLayout( pageIndex = nextPageIndex, visibleText = globalText.subSequence(globalStart, globalEnd), globalStartIndex = globalStart, globalEndIndex = globalEnd, pageHeightPx = pageHeightPx ) ) if (isLastContentPage && explicitBreakEnd != null) { Timber.d( "android.paginate pageBreak page=$nextPageIndex global=$globalStart..$globalEnd" ) } else if (!fitsOnPage) { Timber.d( "android.paginate overflow page=$nextPageIndex global=$globalStart..$globalEnd line=$overflowLineIndex" ) } nextPageIndex++ relativeStart = relativeEnd } return nextPageIndex } private fun TextLayoutResult.richAndroidLastFittingLineIndex(editorHeight: Float): Int { var lastFitting = 0 for (lineIndex in 0 until lineCount) { if (lineIndex == 0 || getLineBottom(lineIndex) <= editorHeight) { lastFitting = lineIndex } else { break } } return lastFitting.coerceIn(0, (lineCount - 1).coerceAtLeast(0)) } private fun List.withTrailingAndroidBlankRichTextPageIfNeeded( globalText: AnnotatedString, pageHeightPx: Float ): List { if (globalText.text.lastOrNull() != PAGE_BREAK_CHAR) return this val lastLayout = lastOrNull() val trailingStart = globalText.length if (lastLayout != null && lastLayout.globalStartIndex == trailingStart && lastLayout.globalEndIndex == trailingStart ) { return this } return this + PageTextLayout( pageIndex = (lastLayout?.pageIndex ?: -1) + 1, visibleText = AnnotatedString(""), globalStartIndex = trailingStart, globalEndIndex = trailingStart, pageHeightPx = pageHeightPx ) } private fun AnnotatedString.withoutTrailingAndroidPageBreak(): AnnotatedString { return if (text.lastOrNull() == PAGE_BREAK_CHAR) { subSequence(0, length - 1) } else { this } } private fun AnnotatedString.withRestoredTrailingAndroidPageBreak(shouldRestore: Boolean): AnnotatedString { if (!shouldRestore) return this if (text.lastOrNull() == PAGE_BREAK_CHAR) return this return this + AnnotatedString(PAGE_BREAK_CHAR.toString()) } internal fun androidRichTextInsertionIndexForPage( insertPageIndex: Int, pageLayouts: List, textLength: Int ): Int { val rawIndex = if (insertPageIndex <= 0) { 0 } else { pageLayouts.find { it.pageIndex == insertPageIndex - 1 }?.globalEndIndex ?: textLength } return rawIndex.coerceIn(0, textLength) } internal fun androidRichTextBlankInsertBreakCount(text: String, insertionCharIndex: Int): Int { val safeIndex = insertionCharIndex.coerceIn(0, text.length) if (safeIndex == 0 || safeIndex == text.length) return 1 val hasBoundaryBreakBefore = text.getOrNull(safeIndex - 1) == PAGE_BREAK_CHAR val hasBoundaryBreakAfter = text.getOrNull(safeIndex) == PAGE_BREAK_CHAR return if (hasBoundaryBreakBefore || hasBoundaryBreakAfter) 1 else 2 } internal fun remapAndroidRichTextForLayoutChange( currentLayout: List, updatedLayout: List, pageLayouts: List ): AnnotatedString { if (pageLayouts.isEmpty()) return AnnotatedString("") val mapping = buildPdfPageIndexMapping( currentLayout = currentLayout, updatedLayout = updatedLayout, sourcePageIndices = pageLayouts.map { it.pageIndex } ) if (mapping.isEmpty()) return AnnotatedString("") val contentByTargetPage = linkedMapOf() pageLayouts.sortedBy { it.pageIndex }.forEach { layout -> val targetPageIndex = mapping[layout.pageIndex] ?: return@forEach val pageContent = layout.visibleText.withoutTrailingAndroidPageBreak() contentByTargetPage[targetPageIndex] = pageContent } val lastPageWithContent = contentByTargetPage .filterValues { it.text.isNotEmpty() } .keys .maxOrNull() ?: return AnnotatedString("") val builder = AnnotatedString.Builder() for (pageIndex in 0..lastPageWithContent) { contentByTargetPage[pageIndex]?.let { builder.append(it) } if (pageIndex < lastPageWithContent) { builder.append(PAGE_BREAK_CHAR.toString()) } } return builder.toAnnotatedString() } class PdfRichTextRepository(private val context: Context) { private val _document = MutableStateFlow(null) val document = _document.asStateFlow() private fun getFile(bookId: String): File { val safeId = bookId.replace("[^a-zA-Z0-9._-]".toRegex(), "_") return File(context.filesDir, "rich_doc_${safeId}.json") } fun getFileForSync(bookId: String): File = getFile(bookId) suspend fun load(bookId: String) { withContext(Dispatchers.IO) { val file = getFile(bookId) Timber.d( "android.repository.load start book=$bookId exists=${file.exists()} path=${file.absolutePath}" ) if (!file.exists()) { _document.value = GlobalRichDocument("", emptyList()) Timber.d("android.repository.load missing -> empty book=$bookId") return@withContext } try { val jsonString = file.readText() val json = JSONObject(jsonString) val text = json.getString("text") val spansArray = json.getJSONArray("spans") val spans = mutableListOf() for (i in 0 until spansArray.length()) { val sObj = spansArray.getJSONObject(i) spans.add( GlobalRichSpan( start = sObj.getInt("s"), end = sObj.getInt("e"), color = sObj.getInt("c"), backgroundColor = sObj.optInt("bg", android.graphics.Color.TRANSPARENT), fontSizeNorm = sObj.getDouble("sz").toFloat(), isBold = sObj.optBoolean("b"), isItalic = sObj.optBoolean("i"), isUnderline = sObj.optBoolean("u"), isStrikethrough = sObj.optBoolean("st"), fontPath = sObj.optString("fp"), ) ) } _document.value = GlobalRichDocument(text, spans) Timber.d( "android.repository.load decoded book=$bookId rawLen=${jsonString.length} textLen=${text.length} spans=${spans.size}" ) } catch (e: Exception) { Timber.e(e, "android.repository.load failed book=$bookId") Timber.e(e, "Failed to load rich text doc") _document.value = GlobalRichDocument("", emptyList()) } } } suspend fun save(bookId: String, document: GlobalRichDocument) { _document.value = document withContext(Dispatchers.IO) { try { Timber.d( "android.repository.save start book=$bookId textLen=${document.text.length} spans=${document.spans.size}" ) val obj = JSONObject().apply { put("text", document.text) val spansArray = JSONArray() document.spans.forEach { span -> spansArray.put( JSONObject().apply { put("s", span.start) put("e", span.end) put("c", span.color) put("bg", span.backgroundColor) put("sz", span.fontSizeNorm.toDouble()) put("b", span.isBold) put("i", span.isItalic) put("u", span.isUnderline) put("st", span.isStrikethrough) put("fp", span.fontPath) }) } put("spans", spansArray) } val file = getFile(bookId) file.writeText(obj.toString()) Timber.d( "android.repository.save done book=$bookId bytes=${file.length()} path=${file.absolutePath}" ) } catch (e: Exception) { Timber.e(e, "android.repository.save failed book=$bookId") Timber.e(e, "Failed to save rich text doc") } } } } private fun Float.richAndroidLogFloat(): String { return if (isFinite()) { val rounded = kotlin.math.round(this * 10f) / 10f rounded.toString() } else { toString() } } private fun List.richAndroidLayoutSummary(): String { if (isEmpty()) return "[]" return joinToString(prefix = "[", postfix = "]", limit = 8, truncated = "...") { layout -> "p${layout.pageIndex}:${layout.globalStartIndex}-${layout.globalEndIndex}/len${layout.visibleText.length}" } } @Stable class RichTextController( private val repository: PdfRichTextRepository, private val scope: CoroutineScope, private val bookId: String ) { var globalTextFieldValue by mutableStateOf(TextFieldValue("")) private set var localTextFieldValue by mutableStateOf(TextFieldValue("")) private set val editingValue: TextFieldValue get() = if (activePageIndex != -1) localTextFieldValue else globalTextFieldValue var activePageIndex by mutableIntStateOf(-1) private set var pageLayouts by mutableStateOf(emptyList()) private set val hasRenderableText: Boolean get() = globalTextFieldValue.text.hasRenderableRichText() var currentStyle: SpanStyle by mutableStateOf(SpanStyle(color = Color.Black, fontSize = 16.sp)) private set var currentFontPath: String? by mutableStateOf(null) private set var currentFontName: String? by mutableStateOf(null) private set var cursorPageIndex by mutableIntStateOf(-1) private set var cursorRectInPage by mutableStateOf(null) private set var isCursorVisible by mutableStateOf(false) private set var showCursorOverride by mutableStateOf(true) private var lastPageWidth: Float = 1000f private var lastPageHeight: Float = 1414f private var lastDensity: Density? = null private var lastTextMeasurer: TextMeasurer? = null private val engine = TextPaginationEngine() private var saveJob: Job? = null private var syncJob: Job? = null private var isSaving = false var focusRequester = androidx.compose.ui.focus.FocusRequester() private var keyboardController: SoftwareKeyboardController? = null init { scope.launch { repository.document.collect { doc -> if (doc != null && globalTextFieldValue.text.isEmpty()) { val annotated = withContext(Dispatchers.Default) { RichTextMapper.toAnnotatedString(doc, lastPageHeight) } globalTextFieldValue = TextFieldValue(annotated) repaginate(dirtyStartIndex = 0, caller = "ControllerInit") } } } } fun setKeyboardController(controller: SoftwareKeyboardController?) { this.keyboardController = controller } private fun requestFocusAndShowKeyboard() { try { focusRequester.requestFocus() keyboardController?.show() } catch (e: Exception) { Timber.e(e, "Failed to request focus/keyboard") } } fun clearSelection() { isCursorVisible = false val pageToSync = activePageIndex if (pageToSync != -1) { scope.launch { performSync(pageToSync) if (activePageIndex == pageToSync) activePageIndex = -1 } } if (globalTextFieldValue.text.isNotEmpty()) { globalTextFieldValue = globalTextFieldValue.copy( selection = TextRange(globalTextFieldValue.text.length) ) } cursorPageIndex = -1 cursorRectInPage = null } fun updateLayoutConfig(width: Float, height: Float, density: Density, measurer: TextMeasurer) { if (lastPageWidth != width || lastPageHeight != height || lastDensity != density) { lastPageWidth = width lastPageHeight = height lastDensity = density lastTextMeasurer = measurer repaginate(dirtyStartIndex = 0, caller = "LayoutConfigChange") } } private data class MutableSpan(var start: Int, var end: Int, val item: SpanStyle) private fun compactSpans(spans: List): List { if (spans.isEmpty()) return emptyList() val result = mutableListOf() val groupedByStyle = spans.groupBy { it.item } for ((_, styleSpans) in groupedByStyle) { val sorted = styleSpans.sortedBy { it.start } if (sorted.isEmpty()) continue var current = sorted[0] for (i in 1 until sorted.size) { val next = sorted[i] if (next.start <= current.end) { current.end = maxOf(current.end, next.end) } else { result.add(current) current = next } } result.add(current) } return result } fun onValueChanged(newValue: TextFieldValue) { if (isSaving) return if (activePageIndex != -1 && !newValue.text.startsWith(ZWSP)) { val handled = handleBackspaceAtStart() if (!handled) { localTextFieldValue = localTextFieldValue.copy(selection = TextRange(1)) } return } val oldValue = if (activePageIndex != -1) localTextFieldValue else globalTextFieldValue val newText = newValue.text val oldText = oldValue.text if (newText == oldText) { if (activePageIndex != -1) { if (localTextFieldValue.selection != newValue.selection || localTextFieldValue.composition != newValue.composition) { localTextFieldValue = newValue.copy(annotatedString = localTextFieldValue.annotatedString) isCursorVisible = true updateLocalCursor() if (kotlin.math.abs(newValue.selection.start - oldValue.selection.start) > 1) { Timber.tag("CursorNavTrace").d("Local cursor moved significantly.") } } } else { if (globalTextFieldValue.selection != newValue.selection || globalTextFieldValue.composition != newValue.composition) { globalTextFieldValue = newValue.copy(annotatedString = globalTextFieldValue.annotatedString) updateGlobalCursor() } } return } val oldAnnotated = oldValue.annotatedString val mutableSpans = mutableListOf() val diff = newText.length - oldText.length val cursor = newValue.selection.end val changeStart = if (diff > 0) cursor - diff else cursor val changeEndOld = if (diff > 0) changeStart else changeStart - diff oldAnnotated.spanStyles.forEach { span -> val s = span.start val e = span.end val item = span.item if (diff > 0) { if (e <= changeStart) { mutableSpans.add(MutableSpan(s, e, item)) } else if (s >= changeStart) { mutableSpans.add(MutableSpan(s + diff, e + diff, item)) } else { mutableSpans.add(MutableSpan(s, e + diff, item)) } } else { if (e <= changeStart) { mutableSpans.add(MutableSpan(s, e, item)) } else if (s >= changeEndOld) { mutableSpans.add(MutableSpan(s + diff, e + diff, item)) } else { val newS = if (s < changeStart) s else changeStart val newE = (e + diff).coerceAtLeast(changeStart) if (newS < newE) { mutableSpans.add(MutableSpan(newS, newE, item)) } } } } if (diff > 0) { val start = (cursor - diff).coerceAtLeast(0) mutableSpans.add(MutableSpan(start, cursor, currentStyle)) } val mergedSpans = compactSpans(mutableSpans) val builder = AnnotatedString.Builder(newText) mergedSpans.forEach { span -> builder.addStyle(span.item, span.start, span.end) } val finalAnnotated = builder.toAnnotatedString() val finalValue = newValue.copy(annotatedString = finalAnnotated) if (activePageIndex != -1) { localTextFieldValue = finalValue isCursorVisible = true updateLocalCursor() syncJob?.cancel() syncJob = scope.launch { delay(300) performSync(activePageIndex, checkCursorMove = true) } } else { globalTextFieldValue = finalValue debouncedSave(globalTextFieldValue) repaginate(dirtyStartIndex = 0, caller = "GlobalValueChanged") } } private suspend fun syncLocalToGlobal() { if (activePageIndex == -1) return val layout = pageLayouts.find { it.pageIndex == activePageIndex } ?: return val globalStart = layout.globalStartIndex val globalEnd = layout.globalEndIndex val currentGlobal = globalTextFieldValue.annotatedString // FIX: Strip ZWSP (index 0) from local text val localEditableText = if (localTextFieldValue.annotatedString.isNotEmpty()) { localTextFieldValue.annotatedString.subSequence(1, localTextFieldValue.annotatedString.length) } else AnnotatedString("") val shouldPreservePageBreak = layout.visibleText.text.lastOrNull() == PAGE_BREAK_CHAR val localText = localEditableText.withRestoredTrailingAndroidPageBreak(shouldPreservePageBreak) Timber.tag("RichTextFlow").d("Sync: Page $activePageIndex, GlobalRange [$globalStart..$globalEnd], LocalLen ${localText.length}") val builder = AnnotatedString.Builder() builder.append(currentGlobal.subSequence(0, globalStart)) builder.append(localText) if (globalEnd < currentGlobal.length) { builder.append(currentGlobal.subSequence(globalEnd, currentGlobal.length)) } val newGlobalAnnotated = builder.toAnnotatedString() // FIX: Adjust selection -1 because local selection included ZWSP val localSelectionStart = (localTextFieldValue.selection.start - 1).coerceAtLeast(0) val newGlobalCursorPos = globalStart + localSelectionStart val totalBreaks = newGlobalAnnotated.text.count { it == PAGE_BREAK_CHAR } Timber.tag("RichTextFlow").d("Sync Complete: TotalLen ${newGlobalAnnotated.length}, TotalBreaks $totalBreaks") val newGlobalValue = TextFieldValue(newGlobalAnnotated, TextRange(newGlobalCursorPos)) globalTextFieldValue = newGlobalValue Timber.tag("RichTextFlow").d("Syncing Global: Total Len ${newGlobalAnnotated.length}") debouncedSave(newGlobalValue) val measurer = lastTextMeasurer ?: return val density = lastDensity ?: return val marginX = lastPageWidth * 0.1f val marginY = lastPageHeight * 0.08f val newLayouts = withContext(Dispatchers.Default) { engine.paginate( globalText = newGlobalAnnotated, pageWidthPx = lastPageWidth, pageHeightPx = lastPageHeight, textMeasurer = measurer, density = density, marginX = marginX, marginY = marginY, previousLayouts = pageLayouts, dirtyGlobalIndex = globalStart ) } pageLayouts = newLayouts val newActiveLayout = newLayouts.find { newGlobalCursorPos >= it.globalStartIndex && newGlobalCursorPos <= it.globalEndIndex } if (newActiveLayout != null) { activePageIndex = newActiveLayout.pageIndex val reExtractedText = newGlobalAnnotated.subSequence( newActiveLayout.globalStartIndex, newActiveLayout.globalEndIndex ).withoutTrailingAndroidPageBreak() val textWithZwsp = AnnotatedString(ZWSP) + reExtractedText val newLocalCursor = (newGlobalCursorPos - newActiveLayout.globalStartIndex + 1) .coerceIn(0, textWithZwsp.length) localTextFieldValue = TextFieldValue(textWithZwsp, TextRange(newLocalCursor)) updateLocalCursor() } } fun updateCurrentStyle(style: SpanStyle, fontPath: String? = currentFontPath, fontName: String? = currentFontName) { Timber.tag("PdfFontDebug").d("Controller: updateCurrentStyle called. Name: $fontName, Path: $fontPath") val effectiveFontFamily = PdfFontCache.getFontFamily(fontPath) val styleWithFont = style.copy(fontFamily = effectiveFontFamily) currentStyle = styleWithFont currentFontPath = fontPath currentFontName = fontName isCursorVisible = true if (activePageIndex != -1) { if (!localTextFieldValue.selection.collapsed) { val builder = AnnotatedString.Builder(localTextFieldValue.annotatedString) builder.addStyle(styleWithFont, localTextFieldValue.selection.start, localTextFieldValue.selection.end) localTextFieldValue = localTextFieldValue.copy(annotatedString = builder.toAnnotatedString()) Timber.tag("TextAnnotStyle").d("Controller: Applied style to Local Selection (${localTextFieldValue.selection})") syncJob?.cancel() syncJob = scope.launch { delay(500) syncLocalToGlobal() } } } else { if (!globalTextFieldValue.selection.collapsed) { val builder = AnnotatedString.Builder(globalTextFieldValue.annotatedString) builder.addStyle(styleWithFont, globalTextFieldValue.selection.start, globalTextFieldValue.selection.end) globalTextFieldValue = globalTextFieldValue.copy(annotatedString = builder.toAnnotatedString()) debouncedSave(globalTextFieldValue) repaginate(dirtyStartIndex = globalTextFieldValue.selection.min, caller = "GlobalValueChanged") } } requestFocusAndShowKeyboard() } fun handleTapOnPage(pageIndex: Int, localTapOffset: Offset) { if (globalTextFieldValue.text.length > 500_000 && activePageIndex == -1) { Timber.tag("RichTextFlow").w("Document too large for instant interactive tap.") } Timber.tag("RichTextFlow").i("Tap on Page $pageIndex at $localTapOffset. Current Active: $activePageIndex") if (activePageIndex != -1 && activePageIndex != pageIndex) { scope.launch { performSync(activePageIndex) } } val measurer = lastTextMeasurer ?: return val density = lastDensity ?: return var layout = pageLayouts.find { it.pageIndex == pageIndex } if (layout == null) { val lastLayout = pageLayouts.lastOrNull() val currentLastPage = lastLayout?.pageIndex ?: 0 Timber.tag("RichTextFlow").w("Tapped page $pageIndex beyond current text stream (Last page: $currentLastPage). Bridging gap.") if (pageIndex > currentLastPage) { val breaksNeeded = pageIndex - currentLastPage val builder = AnnotatedString.Builder(globalTextFieldValue.annotatedString) repeat(breaksNeeded) { builder.append(PAGE_BREAK_CHAR.toString()) } globalTextFieldValue = TextFieldValue(builder.toAnnotatedString()) repaginateSync(0) layout = pageLayouts.find { it.pageIndex == pageIndex } } } val currentLayout = layout ?: return activePageIndex = pageIndex val margin = lastPageWidth * 0.1f val editorWidth = (lastPageWidth - (margin * 2)).coerceAtLeast(10f) val vText = currentLayout.visibleText val editableText = vText.withoutTrailingAndroidPageBreak() // FIX: Prepend ZWSP to the visible text val textWithZwsp = AnnotatedString(ZWSP) + editableText val safeLen = editableText.length // FIX: Adjust initial selection by +1 because of ZWSP localTextFieldValue = TextFieldValue(textWithZwsp, TextRange(safeLen + 1)) val measureResult = measurer.measure( text = editableText, // We measure editable text, not the hidden page-break sentinel style = TextStyle(fontSize = 16.sp, color = Color.Black), constraints = Constraints(maxWidth = editorWidth.toInt()), density = density ) val textHeight = if (editableText.isEmpty()) 0f else measureResult.size.height.toFloat() if (editableText.isNotEmpty() && localTapOffset.y <= textHeight) { var localIndex = measureResult.getOffsetForPosition(localTapOffset) localIndex = localIndex.coerceIn(0, editableText.length) localTextFieldValue = localTextFieldValue.copy(selection = TextRange(localIndex + 1)) } else { val gap = localTapOffset.y - textHeight injectNewlinesLocal(gap) } isCursorVisible = true updateLocalCursor() requestFocusAndShowKeyboard() } private fun injectNewlinesLocal(gapPixels: Float) { val fontSizeSp = currentStyle.fontSize.value val densityVal = lastDensity?.density ?: 1f val lineHeightPx = (if (fontSizeSp.isNaN()) 16f else fontSizeSp) * densityVal * 1.3f val linesNeeded = (gapPixels / lineHeightPx).toInt().coerceAtLeast(1) val sb = StringBuilder() repeat(linesNeeded) { sb.append('\n') } val original = localTextFieldValue.annotatedString val text = original.text val builder = AnnotatedString.Builder() val endsWithBreak = text.isNotEmpty() && text.last() == PAGE_BREAK_CHAR if (endsWithBreak) { builder.append(original.subSequence(0, text.length - 1)) builder.pushStyle(currentStyle) builder.append(sb.toString()) builder.pop() builder.append(PAGE_BREAK_CHAR.toString()) } else { builder.append(original) builder.pushStyle(currentStyle) builder.append(sb.toString()) builder.pop() } val newVal = builder.toAnnotatedString() val newCursor = if (endsWithBreak) newVal.length - 1 else newVal.length localTextFieldValue = TextFieldValue(newVal, TextRange(newCursor)) onValueChanged(localTextFieldValue) } private fun repaginateSync(@Suppress("SameParameterValue") dirtyStartIndex: Int) { val measurer = lastTextMeasurer ?: return val density = lastDensity ?: return val marginX = lastPageWidth * 0.1f val marginY = lastPageHeight * 0.08f pageLayouts = engine.paginate( globalText = globalTextFieldValue.annotatedString, pageWidthPx = lastPageWidth, pageHeightPx = lastPageHeight, textMeasurer = measurer, density = density, marginX = marginX, marginY = marginY, previousLayouts = pageLayouts, dirtyGlobalIndex = dirtyStartIndex ) } private fun repaginate(dirtyStartIndex: Int, caller: String) { val measurer = lastTextMeasurer ?: return val density = lastDensity ?: return val marginX = lastPageWidth * 0.1f val marginY = lastPageHeight * 0.08f val currentText = globalTextFieldValue.annotatedString val currentLayouts = pageLayouts scope.launch { Timber.tag("RichTextMigration").v("repaginate triggered by [$caller] from dirty index $dirtyStartIndex. Global text len: ${currentText.length}") val newLayouts = withContext(Dispatchers.Default) { engine.paginate( globalText = currentText, pageWidthPx = lastPageWidth, pageHeightPx = lastPageHeight, textMeasurer = measurer, density = density, marginX = marginX, marginY = marginY, previousLayouts = currentLayouts, dirtyGlobalIndex = dirtyStartIndex ) } pageLayouts = newLayouts Timber.tag("RichTextMigration").d("repaginate [$caller] completed. Pages: ${newLayouts.size}") } } private fun updateLocalCursor() { if (lastTextMeasurer == null || lastDensity == null) return val selection = localTextFieldValue.selection if (selection.collapsed) { val margin = lastPageWidth * 0.1f val editorWidth = (lastPageWidth - (margin * 2)).coerceAtLeast(10f) val measureResult = lastTextMeasurer!!.measure( text = localTextFieldValue.annotatedString, style = TextStyle(fontSize = 16.sp), constraints = Constraints(maxWidth = editorWidth.toInt()), density = lastDensity!! ) val safeOffset = selection.start.coerceIn(0, localTextFieldValue.text.length) val rect = measureResult.getCursorRect(safeOffset) val marginY = lastPageHeight * 0.08f cursorPageIndex = activePageIndex cursorRectInPage = rect.translate(margin, marginY) } } private fun updateGlobalCursor() { val selection = globalTextFieldValue.selection if (isCursorVisible && showCursorOverride && selection.collapsed) { val cursorIndex = selection.start val layout = pageLayouts.find { cursorIndex >= it.globalStartIndex && cursorIndex <= it.globalEndIndex } if (layout != null && lastTextMeasurer != null && lastDensity != null) { cursorPageIndex = layout.pageIndex val margin = lastPageWidth * 0.1f val editorWidth = (lastPageWidth - (margin * 2)).coerceAtLeast(10f) val measureResult = lastTextMeasurer!!.measure( text = layout.visibleText, style = TextStyle(fontSize = 16.sp), constraints = Constraints(maxWidth = editorWidth.toInt()), density = lastDensity!! ) val localIndex = (cursorIndex - layout.globalStartIndex).coerceIn(0, layout.visibleText.length) val rect = measureResult.getCursorRect(localIndex) val marginY = lastPageHeight * 0.08f cursorRectInPage = rect.translate(margin, marginY) } } else { cursorPageIndex = -1 cursorRectInPage = null } } // --- Misc Operations --- private suspend fun forceSyncAndClear() { if (activePageIndex != -1) { Timber.tag("RichTextFlow").i("ForceSync: Committing local edits before structural change.") performSync(activePageIndex) activePageIndex = -1 cursorPageIndex = -1 cursorRectInPage = null localTextFieldValue = TextFieldValue("") } } fun insertPageBreakAt(insertPageIndex: Int, count: Int = 1) { scope.launch { forceSyncAndClear() val original = globalTextFieldValue.annotatedString Timber.tag("RichTextMigration").d("insertPageBreakAt: Target Page Index: $insertPageIndex, Count: $count") val safeIndex = androidRichTextInsertionIndexForPage( insertPageIndex = insertPageIndex, pageLayouts = pageLayouts, textLength = original.length ) Timber.tag("RichTextMigration").v("insertPageBreakAt: insertion index $safeIndex") insertPageBreaksIntoGlobalText( original = original, safeIndex = safeIndex, count = count, caller = "InsertPageBreakAt" ) } } fun insertBlankPageAt(insertPageIndex: Int) { scope.launch { forceSyncAndClear() val original = globalTextFieldValue.annotatedString val safeIndex = androidRichTextInsertionIndexForPage( insertPageIndex = insertPageIndex, pageLayouts = pageLayouts, textLength = original.length ) val requiredBreaks = androidRichTextBlankInsertBreakCount( text = original.text, insertionCharIndex = safeIndex ) Timber.tag("RichTextMigration").i( "insertBlankPageAt: page=$insertPageIndex index=$safeIndex breaks=$requiredBreaks" ) insertPageBreaksIntoGlobalText( original = original, safeIndex = safeIndex, count = requiredBreaks, caller = "InsertBlankPageAt" ) } } suspend fun remapPagesForLayoutChange( currentLayout: List, updatedLayout: List ) = withContext(NonCancellable) { Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( "rich.remap.start current=${currentLayout.pdfLayoutDebugSummary()} " + "updated=${updatedLayout.pdfLayoutDebugSummary()} pageLayouts=${pageLayouts.size} " + "textLen=${globalTextFieldValue.annotatedString.length}" ) forceSyncAndClear() val original = globalTextFieldValue.annotatedString if (pageLayouts.isEmpty() && original.text.isNotEmpty()) { Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).w( "rich.remap.skipNoLayouts current=${currentLayout.pdfLayoutDebugSummary()} " + "updated=${updatedLayout.pdfLayoutDebugSummary()} textLen=${original.length}" ) Timber.tag("RichTextMigration").w( "remapPagesForLayoutChange skipped: no rich text page layouts for non-empty text" ) return@withContext } val remapped = remapAndroidRichTextForLayoutChange( currentLayout = currentLayout, updatedLayout = updatedLayout, pageLayouts = pageLayouts ) if (remapped == original) { Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( "rich.remap.noChange current=${currentLayout.pdfLayoutDebugSummary()} " + "updated=${updatedLayout.pdfLayoutDebugSummary()} textLen=${original.length}" ) return@withContext } Timber.tag("RichTextMigration").i( "remapPagesForLayoutChange: textLen ${original.length} -> ${remapped.length}, " + "pages ${currentLayout.size} -> ${updatedLayout.size}" ) Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( "rich.remap.apply textLen=${original.length}->${remapped.length} " + "current=${currentLayout.pdfLayoutDebugSummary()} updated=${updatedLayout.pdfLayoutDebugSummary()}" ) globalTextFieldValue = TextFieldValue( remapped, selection = TextRange(remapped.length) ) repaginateSync(0) saveCurrentGlobalTextImmediately() Timber.tag(PDF_BLANK_PAGE_PERSISTENCE_TAG).i( "rich.remap.done textLen=${globalTextFieldValue.annotatedString.length} pageLayouts=${pageLayouts.size}" ) } private fun insertPageBreaksIntoGlobalText( original: AnnotatedString, safeIndex: Int, count: Int, caller: String ) { val safeCount = count.coerceAtLeast(0) if (safeCount == 0) return Timber.tag("RichTextMigration").i("$caller: Inserting $safeCount PAGE_BREAK_CHARs at global index $safeIndex") val builder = AnnotatedString.Builder() builder.append(original.subSequence(0, safeIndex)) repeat(safeCount) { builder.append(PAGE_BREAK_CHAR.toString()) } builder.append(original.subSequence(safeIndex, original.length)) val newCursorPos = safeIndex + safeCount globalTextFieldValue = TextFieldValue(builder.toAnnotatedString(), TextRange(newCursorPos)) debouncedSave(globalTextFieldValue) repaginate(dirtyStartIndex = safeIndex, caller = caller) } private suspend fun saveCurrentGlobalTextImmediately() { saveJob?.cancel() val finalAnnotated = globalTextFieldValue.annotatedString withContext(Dispatchers.Default) { val doc = RichTextMapper.fromAnnotatedString(finalAnnotated, lastPageHeight) repository.save(bookId, doc) } } fun deleteTextOnPage(pageIndex: Int) { scope.launch { forceSyncAndClear() val layout = pageLayouts.find { it.pageIndex == pageIndex } ?: return@launch val start = layout.globalStartIndex val end = layout.globalEndIndex if (start >= end && start >= globalTextFieldValue.text.length) return@launch Timber.tag("RichTextFlow").i("Deleting text on Page $pageIndex (Global: $start..$end)") val original = globalTextFieldValue.annotatedString val builder = AnnotatedString.Builder() builder.append(original.subSequence(0, start)) if (end < original.length) { builder.append(original.subSequence(end, original.length)) } globalTextFieldValue = TextFieldValue(builder.toAnnotatedString(), TextRange(start)) debouncedSave(globalTextFieldValue) repaginate(dirtyStartIndex = start, caller = "DeleteTextOnPage") } } private fun debouncedSave(tfv: TextFieldValue) { saveJob?.cancel() saveJob = scope.launch(Dispatchers.Default) { delay(1000) val baseDoc = RichTextMapper.fromAnnotatedString(tfv.annotatedString, lastPageHeight) repository.save(bookId, baseDoc) } } private suspend fun performSync(pageIdx: Int, checkCursorMove: Boolean = false) { if (pageIdx == -1) { Timber.tag("RichTextMigration").w("performSync aborted: pageIdx is -1") return } val layout = pageLayouts.find { it.pageIndex == pageIdx } ?: run { Timber.tag("RichTextMigration").e("performSync failed: No layout found for pageIdx $pageIdx. Current layout count: ${pageLayouts.size}") return } val globalStart = layout.globalStartIndex val globalEnd = layout.globalEndIndex Timber.tag("RichTextMigration").i("performSync EXECUTE: Page=$pageIdx, GlobalRange=[$globalStart..$globalEnd], LocalTextLen=${localTextFieldValue.text.length}") val currentGlobal = globalTextFieldValue.annotatedString val localAnnotatedRaw = localTextFieldValue.annotatedString val localEditableAnnotated = if (localAnnotatedRaw.isNotEmpty()) localAnnotatedRaw.subSequence(1, localAnnotatedRaw.length) else AnnotatedString("") val shouldPreservePageBreak = layout.visibleText.text.lastOrNull() == PAGE_BREAK_CHAR val localAnnotated = localEditableAnnotated.withRestoredTrailingAndroidPageBreak(shouldPreservePageBreak) val charBeforeSync = if (globalStart > 0) currentGlobal.text[globalStart - 1] else "START" val charAfterSync = if (globalEnd < currentGlobal.length) currentGlobal.text[globalEnd] else "END" Timber.tag("RichTextFlow").v("Sync START Page $pageIdx. Boundary: [$charBeforeSync]...[G:$globalStart..$globalEnd]...[$charAfterSync]") val builder = AnnotatedString.Builder() builder.append(currentGlobal.subSequence(0, globalStart)) builder.append(localAnnotated) if (globalEnd < currentGlobal.length) { builder.append(currentGlobal.subSequence(globalEnd, currentGlobal.length)) } val newGlobalAnnotated = builder.toAnnotatedString() Timber.tag("RichTextMigration").d("performSync: Merged text. Global length: ${currentGlobal.length} -> ${newGlobalAnnotated.length}") val localSelectionStart = (localTextFieldValue.selection.start - 1).coerceAtLeast(0) val newGlobalCursorPos = (globalStart + localSelectionStart).coerceIn(0, newGlobalAnnotated.length) if (checkCursorMove) { Timber.tag("CursorNavTrace").d("performSync: Checking move. GlobalStart: $globalStart, LocalSelStart: $localSelectionStart, TargetGlobalPos: $newGlobalCursorPos") } globalTextFieldValue = TextFieldValue(newGlobalAnnotated, TextRange(newGlobalCursorPos)) debouncedSave(globalTextFieldValue) val measurer = lastTextMeasurer ?: return val density = lastDensity ?: return val marginX = lastPageWidth * 0.1f val marginY = lastPageHeight * 0.08f // Repaginate from the start of the current page val newLayouts = withContext(Dispatchers.Default) { engine.paginate( globalText = newGlobalAnnotated, pageWidthPx = lastPageWidth, pageHeightPx = lastPageHeight, textMeasurer = measurer, density = density, marginX = marginX, marginY = marginY, previousLayouts = pageLayouts, dirtyGlobalIndex = globalStart ) } val oldPage1Start = pageLayouts.find { it.pageIndex == pageIdx + 1 }?.globalStartIndex val newPage1Start = newLayouts.find { it.pageIndex == pageIdx + 1 }?.globalStartIndex Timber.tag("RichTextFlow").d("Sync Repaginated. Page ${pageIdx+1} start shifted: $oldPage1Start -> $newPage1Start") pageLayouts = newLayouts if (checkCursorMove) { val newActiveLayout = newLayouts.find { newGlobalCursorPos >= it.globalStartIndex && newGlobalCursorPos < it.globalEndIndex } ?: newLayouts.find { newGlobalCursorPos == it.globalEndIndex } if (newActiveLayout != null) { if (newActiveLayout.pageIndex != activePageIndex) { Timber.tag("CursorNavTrace").i("performSync: MIGRATING from Page $activePageIndex to ${newActiveLayout.pageIndex}") activePageIndex = newActiveLayout.pageIndex } val reExtracted = newGlobalAnnotated.subSequence( newActiveLayout.globalStartIndex, newActiveLayout.globalEndIndex ).withoutTrailingAndroidPageBreak() val textWithZwsp = AnnotatedString(ZWSP) + reExtracted val newLocalCursor = (newGlobalCursorPos - newActiveLayout.globalStartIndex + 1).coerceIn(0, textWithZwsp.length) Timber.tag("CursorNavTrace").d("performSync: Final local cursor set to $newLocalCursor on Page ${newActiveLayout.pageIndex}") localTextFieldValue = TextFieldValue(textWithZwsp, TextRange(newLocalCursor)) updateLocalCursor() if (newActiveLayout.pageIndex != pageIdx) { requestFocusAndShowKeyboard() } } else { Timber.tag("CursorNavTrace").w("performSync: No layout found for GlobalPos $newGlobalCursorPos") } } } fun handleBackspaceAtStart(): Boolean { if (localTextFieldValue.selection.start != 0 && localTextFieldValue.selection.start != 1) return false val originalActivePage = activePageIndex if (originalActivePage <= 0) return false Timber.tag("CursorNavTrace").i("handleBackspaceAtStart: Triggered at top of Page $originalActivePage") scope.launch { syncJob?.cancel() performSync(originalActivePage) val currentLayout = pageLayouts.find { it.pageIndex == originalActivePage } ?: return@launch val globalText = globalTextFieldValue.annotatedString val currentGlobalStart = currentLayout.globalStartIndex if (currentGlobalStart > 0) { val charBefore = globalText.text[currentGlobalStart - 1] if (charBefore == PAGE_BREAK_CHAR) { Timber.tag("CursorNavTrace").i("handleBackspaceAtStart: Found PAGE_BREAK at index ${currentGlobalStart - 1}. Removing it.") val targetPageIdx = originalActivePage - 1 val builder = AnnotatedString.Builder() builder.append(globalText.subSequence(0, currentGlobalStart - 1)) val contentAfterBreak = globalText.subSequence(currentGlobalStart, globalText.length) builder.append(contentAfterBreak) var intermediateGlobal = builder.toAnnotatedString() var newCursorPos = (currentGlobalStart - 1).coerceAtLeast(0) val measurer = lastTextMeasurer ?: return@launch val density = lastDensity ?: return@launch val marginX = lastPageWidth * 0.1f val marginY = lastPageHeight * 0.08f val editorHeight = (lastPageHeight - (marginY * 2)).coerceAtLeast(10f) val editorWidth = (lastPageWidth - (marginX * 2)).coerceAtLeast(10f) val targetLayout = pageLayouts.find { it.pageIndex == targetPageIdx } val targetStart = targetLayout?.globalStartIndex ?: 0 val safeTargetStart = targetStart.coerceIn(0, newCursorPos) val pageTextToMeasure = intermediateGlobal.subSequence(safeTargetStart, newCursorPos) val measureResult = measurer.measure( text = pageTextToMeasure, style = TextStyle(fontSize = 16.sp), constraints = Constraints(maxWidth = editorWidth.toInt()), density = density ) val currentTextHeight = measureResult.size.height.toFloat() val gap = editorHeight - currentTextHeight if (gap > 0) { val fontSizeSp = currentStyle.fontSize.value val densityVal = density.density val lineHeightPx = (if (fontSizeSp.isNaN() || fontSizeSp <= 0) 16f else fontSizeSp) * densityVal * 1.3f val linesNeeded = (gap / lineHeightPx).toInt().coerceAtLeast(0) if (linesNeeded > 0) { val padding = "\n".repeat(linesNeeded) val paddedBuilder = AnnotatedString.Builder() paddedBuilder.append(intermediateGlobal.subSequence(0, newCursorPos)) paddedBuilder.pushStyle(currentStyle) paddedBuilder.append(padding) paddedBuilder.pop() paddedBuilder.append(intermediateGlobal.subSequence(newCursorPos, intermediateGlobal.length)) intermediateGlobal = paddedBuilder.toAnnotatedString() newCursorPos += padding.length } } globalTextFieldValue = TextFieldValue(intermediateGlobal, TextRange(newCursorPos)) debouncedSave(globalTextFieldValue) val finalLayouts = withContext(Dispatchers.Default) { engine.paginate(intermediateGlobal, lastPageWidth, lastPageHeight, measurer, density, marginX, marginY) } pageLayouts = finalLayouts val finalActiveLayout = finalLayouts.find { it.pageIndex == targetPageIdx } ?: finalLayouts.findLast { newCursorPos >= it.globalStartIndex && newCursorPos <= it.globalEndIndex } if (finalActiveLayout != null) { activePageIndex = finalActiveLayout.pageIndex val reExtracted = intermediateGlobal.subSequence( finalActiveLayout.globalStartIndex, finalActiveLayout.globalEndIndex ).withoutTrailingAndroidPageBreak() val textWithZwsp = AnnotatedString(ZWSP) + reExtracted val localCursor = (newCursorPos - finalActiveLayout.globalStartIndex + 1).coerceIn(0, textWithZwsp.length) localTextFieldValue = TextFieldValue(textWithZwsp, TextRange(localCursor)) updateLocalCursor() requestFocusAndShowKeyboard() } } else { Timber.tag("CursorNavTrace").i("handleBackspaceAtStart: Contiguous text (Overflow). Deleting char at ${currentGlobalStart - 1}") val builder = AnnotatedString.Builder() builder.append(globalText.subSequence(0, currentGlobalStart - 1)) builder.append(globalText.subSequence(currentGlobalStart, globalText.length)) val newGlobalText = builder.toAnnotatedString() val newCursorPos = (currentGlobalStart - 1).coerceAtLeast(0) globalTextFieldValue = TextFieldValue(newGlobalText, TextRange(newCursorPos)) debouncedSave(globalTextFieldValue) val measurer = lastTextMeasurer ?: return@launch val density = lastDensity ?: return@launch val marginX = lastPageWidth * 0.1f val marginY = lastPageHeight * 0.08f val finalLayouts = withContext(Dispatchers.Default) { engine.paginate( newGlobalText, lastPageWidth, lastPageHeight, measurer, density, marginX, marginY ) } pageLayouts = finalLayouts val finalActiveLayout = finalLayouts.find { newCursorPos >= it.globalStartIndex && newCursorPos < it.globalEndIndex } ?: finalLayouts.find { newCursorPos == it.globalEndIndex } if (finalActiveLayout != null) { @Suppress("UnusedVariable", "Unused") val previousPage = activePageIndex activePageIndex = finalActiveLayout.pageIndex val reExtracted = newGlobalText.subSequence( finalActiveLayout.globalStartIndex, finalActiveLayout.globalEndIndex ).withoutTrailingAndroidPageBreak() val textWithZwsp = AnnotatedString(ZWSP) + reExtracted val localCursor = (newCursorPos - finalActiveLayout.globalStartIndex + 1).coerceIn(0, textWithZwsp.length) localTextFieldValue = TextFieldValue(textWithZwsp, TextRange(localCursor)) updateLocalCursor() requestFocusAndShowKeyboard() Timber.tag("CursorNavTrace").i("handleBackspaceAtStart: Overflow backspace moved cursor to Page ${finalActiveLayout.pageIndex}, Index $localCursor") } else { Timber.tag("CursorNavTrace").e("handleBackspaceAtStart: Could not find layout for Overflow Backspace pos $newCursorPos") } } } else { Timber.tag("CursorNavTrace").w("handleBackspaceAtStart: Start index is 0, cannot backspace.") } } return true } suspend fun saveImmediate() { if (isSaving) return isSaving = true try { Timber.tag("RichTextMigration").i("saveImmediate: Starting. Current activePageIndex: $activePageIndex") saveJob?.cancel() syncJob?.cancel() val pageToSync = activePageIndex if (pageToSync != -1) { performSync(pageToSync) delay(50) activePageIndex = -1 cursorPageIndex = -1 cursorRectInPage = null Timber.tag("RichTextMigration").d("saveImmediate: Sync finished and state cleared for page $pageToSync") } val finalAnnotated = globalTextFieldValue.annotatedString withContext(Dispatchers.Default) { val doc = RichTextMapper.fromAnnotatedString(finalAnnotated, lastPageHeight) repository.save(bookId, doc) } } finally { delay(100) isSaving = false } } }