* Centralize library management logic and introduce support for plain text and HTML formats

* Centralize library management logic and introduce support for plain text and HTML formats

* Expand unit test coverage for library state management, UI models, and MainViewModel features.

* Add comprehensive unit tests for PDF reader core logic, preferences, and data persistence

* Add unit tests for EPUB parsing, content loading, search functionality, and reader JavaScript bridges.

* Add unit tests for OPDS parsing and Smart Collection engine, and integrate Kover plugin

* Add comprehensive unit tests

* Centralize library snapshot serialization in the `shared` module and improve filtering and sorting logic.

* Implement text selection, highlighting, and reading state persistence for PDF and EPUB engines in desktop version

* Folder import support for desktop app

* Introduce Smart Shelves with rule-based filtering in desktop version

* Implement shared EPUB annotation serialization and highlight rendering

* Centralize file type capabilities and platform-specific support logic

* Refactor reader state management to use a central reducer

* Implement customizable reader toolbar and advanced formatting settings in shared

* Implement locator-based navigation and customizable highlight palette for desktop app

* Enhance reader customization and expand search functionality in desktop app

* Redesign reader settings and tools into a tabbed control panel in desktop app

* Enhance reader navigation and highlight precision in desktop app

* Implement bidirectional position synchronization and dynamic highlights in the desktop reader

* Implement shared state management and enhanced search for the PDF reader in desktop app

* Add vertical scroll support to the desktop PDF reader

* Implement ink, text, and eraser annotation support in desktop PDF viewer

* Implement PDF bookmarks, Table of Contents, and annotation editing in desktop app

* Implement link handling and navigation for PDF and EPUB readers in desktop app

* Implement PDF jump history for navigation in desktop app

* Enhance PDF ink rendering and annotation capabilities in desktop app

* Implement advanced PDF text annotations with inline editing and rich styling in desktop app

* Add move handle and movement logic for PDF text annotations in desktop app

* Implement local folder synchronization and metadata sidecar support in desktop app

* Implement book metadata extraction and drag-and-drop import for Desktop

* Implement dynamic and custom app theme management for desktop

* Introduce canonical PDF annotation codec and support for multi-segment highlights

* Implement rich text editing and pagination support for the PDF reader in desktop app

* Improve PDF rich text pagination, synchronization, and observability in desktop

* Hide trailing structural page breaks in rich text editor

* Implement a unified JVM book loader and expand supported formats on Desktop

* Add comic archive support for Desktop and enhance MOBI parsing

* Implement shared OPDS catalog support and UI for Android and Desktop

* Improve native WebView lifecycle and surface transition management on Desktop

* Enable Compose Swing interop blending and simplify Desktop WebView management

* Integrate BYOK AI features and Cloud TTS for desktop

* Enhance Desktop TTS with streaming audio and improved secure storage for AI key

* Implement scoped Cloud TTS with synchronized highlighting for EPUB and PDF in desktop app

* Implement custom font management and utility screens in desktop app

* Implement PDFium-based PDF annotation export

* Remove PdfBox dependency and standardize PDF export via Pdfium

* Implement local audio caching and playback controls for Gemini Cloud TTS in desktop app

* Implement reader themes and custom texture support in desktop app

* Redesign non-reader UI with responsive navigation and enhanced library management in desktop app

* Introduce ReaderWorkspaceShell to unify EPUB and PDF reader layouts in desktop app

* Exclude manual-only files from automated sync and import

* Implement customizable Text-to-Speech (TTS) word replacements

* Optimize reader performance with persistent layout caching and decoupled theme rendering

* Improve position restoration during reader reconfiguration in epub pagination

* Use independent thickness for eraser tool and stylus override
This commit is contained in:
Aryan 2026-05-10 10:07:37 +05:30 committed by GitHub
parent 88c7fa7b5c
commit 8366d76dcd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
214 changed files with 53372 additions and 4702 deletions

View file

@ -32,6 +32,38 @@ object NativePdfiumBridge {
@JvmStatic external fun getAnnotSubtypeAtPoint(pagePtr: Long, x: Double, y: Double): Int
@JvmStatic external fun getAnnotRectAtPoint(pagePtr: Long, x: Double, y: Double): FloatArray?
@JvmStatic external fun checkActionSupport(): Boolean
@JvmStatic external fun exportAnnotatedPdf(
sourcePath: String,
destPath: String,
inkPageIndices: IntArray,
inkTypes: IntArray,
inkColors: IntArray,
inkStrokeWidths: FloatArray,
inkPointOffsets: IntArray,
inkPointCounts: IntArray,
inkPoints: FloatArray,
textPageIndices: IntArray,
textBounds: FloatArray,
textColors: IntArray,
textBackgroundColors: IntArray,
textFontSizes: FloatArray,
textFlags: IntArray,
textValues: Array<String>,
textFontPaths: Array<String>,
textFontNames: Array<String>,
rasterPageIndices: IntArray,
rasterBounds: FloatArray,
rasterWidths: IntArray,
rasterHeights: IntArray,
rasterPixelOffsets: IntArray,
rasterPixels: IntArray,
highlightPageIndices: IntArray,
highlightColors: IntArray,
highlightRectOffsets: IntArray,
highlightRectCounts: IntArray,
highlightRects: FloatArray,
highlightContents: Array<String>
): Boolean
const val ANNOT_TEXT = PdfiumAnnotationSubtype.TEXT
const val ANNOT_LINK = PdfiumAnnotationSubtype.LINK

File diff suppressed because it is too large Load diff

View file

@ -33,4 +33,4 @@ internal enum class DisplayMode {
internal fun saveTtsMode(context: Context, mode: TtsPlaybackManager.TtsMode) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
prefs.edit { putString(TTS_MODE_KEY, mode.name) }
}
}

View file

@ -549,6 +549,7 @@ internal fun PdfPageComposable(
onNoteRequested: (String?) -> Unit = {},
onTts: (Int, Int) -> Unit = { _, _ -> },
activeToolThickness: Float = 0f,
eraserToolThickness: Float = 0f,
customHighlightColors: Map<PdfHighlightColor, Color> = emptyMap(),
onPaletteClick: (() -> Unit)? = null,
lockedState: Triple<Float, Float, Float>? = null,
@ -4270,6 +4271,7 @@ internal fun PdfPageComposable(
eraserPosition = eraserPosition,
isStylusEraserOverride = isStylusEraserOverride,
activeToolThickness = activeToolThickness,
eraserToolThickness = eraserToolThickness,
richTextController = richTextController,
textBoxes = textBoxes,
selectedTextBoxId = selectedTextBoxId,
@ -5106,6 +5108,7 @@ private fun PdfPageRenderer(
onHighlightDelete: (String) -> Unit,
onTts: (Int, Int) -> Unit,
activeToolThickness: Float,
eraserToolThickness: Float,
onNote: (String?) -> Unit,
isBubbleZoomModeActive: Boolean = false,
isActivePage: Boolean = true,
@ -5173,7 +5176,7 @@ private fun PdfPageRenderer(
val isEditable = isEditMode && selectedTool == InkType.TEXT
val hasContent = richTextController.pageLayouts.any {
it.pageIndex == selectionData.pageIndex
}
} || richTextController.hasRenderableText
if (isEditable || hasContent) {
PdfRichTextLayer(
@ -5323,8 +5326,13 @@ private fun PdfPageRenderer(
if (isEditMode && (selectedTool == InkType.ERASER || isStylusEraserOverride) && eraserPosition != null) {
Canvas(modifier = Modifier.fillMaxSize()) {
val radiusPx = if (activeToolThickness > 0f && staticData.targetWidth > 0) {
activeToolThickness * staticData.targetWidth * scale // Calculate dynamic size based on tool settings scale
val eraserStrokeWidth = resolveEraserStrokeWidth(
isStylusEraserOverride,
activeToolThickness,
eraserToolThickness
)
val radiusPx = if (eraserStrokeWidth > 0f && staticData.targetWidth > 0) {
eraserStrokeWidth * staticData.targetWidth * scale
} else {
8.dp.toPx()
}
@ -5798,7 +5806,7 @@ fun PdfRichTextLayer(
val textToRender = if (controller.activePageIndex == pageIndex) {
controller.localTextFieldValue.annotatedString
} else {
pageLayout?.visibleText
pageLayout?.visibleText?.withoutTrailingPdfPageBreakForRender()
}
if (textToRender != null) {
@ -5871,6 +5879,14 @@ fun PdfRichTextLayer(
}
}
private fun AnnotatedString.withoutTrailingPdfPageBreakForRender(): AnnotatedString {
return if (text.lastOrNull() == PAGE_BREAK_CHAR) {
subSequence(0, length - 1)
} else {
this
}
}
private fun getNativePointer(obj: Any): Long {
val priorityFields = listOf("pagePtr", "mNativePage", "page")

View file

@ -39,10 +39,10 @@ private const val PREF_EXTERNAL_TRANSLATE_PKG = "external_translate_package"
private const val PREF_EXTERNAL_SEARCH_PKG = "external_search_package"
private const val PDF_THEME_KEY = "pdf_reader_theme"
private const val PDF_KEEP_SCREEN_ON_KEY = "pdf_keep_screen_on_enabled"
private const val PDF_HIDDEN_TOOLS_KEY = "pdf_hidden_tools"
private const val PDF_TOOL_ORDER_KEY = "pdf_tool_order"
private const val PDF_BOTTOM_TOOLS_KEY = "pdf_bottom_tools"
private const val PDF_SYSTEM_UI_MODE_KEY = "pdf_system_ui_mode"
internal const val PDF_HIDDEN_TOOLS_KEY = "pdf_hidden_tools"
internal const val PDF_TOOL_ORDER_KEY = "pdf_tool_order"
internal const val PDF_BOTTOM_TOOLS_KEY = "pdf_bottom_tools"
internal const val PDF_SYSTEM_UI_MODE_KEY = "pdf_system_ui_mode"
internal const val PDF_LAYOUT_DEBUG_TAG = "PdfLayoutDebug"
enum class PdfReaderTool(val title: String, val category: String) {
@ -64,6 +64,7 @@ enum class PdfReaderTool(val title: String, val category: String) {
KEEP_SCREEN_ON("Keep Screen On", "Overflow Menu"),
AUTO_SCROLL("Auto Scroll", "Overflow Menu"),
TTS_SETTINGS("TTS Voice Settings", "Overflow Menu"),
TTS_REPLACEMENTS("TTS Word Replacements", "Overflow Menu"),
BOOKMARK("Bookmark", "Overflow Menu"),
PAGE_MANAGEMENT("Page Management", "Overflow Menu"),
REFLOW("Text View (Reflow)", "Overflow Menu"),

View file

@ -117,6 +117,7 @@ internal fun PdfTopBar(
onToggleKeepScreenOn: () -> Unit,
onStartAutoScroll: () -> Unit,
onShowTtsSettings: () -> Unit,
onShowTtsReplacements: () -> Unit,
onToggleBookmark: () -> Unit,
onInsertPage: () -> Unit,
onDeletePage: () -> Unit,
@ -425,6 +426,15 @@ internal fun PdfTopBar(
onClick = { showMoreMenu = false; onShowTtsSettings() },
leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) }
)
HorizontalDivider()
}
if (!hiddenTools.contains(PdfReaderTool.TTS_REPLACEMENTS.name)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_tts_word_replacements)) },
onClick = { showMoreMenu = false; onShowTtsReplacements() },
leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) }
)
}
if (!hiddenTools.contains(PdfReaderTool.BOOKMARK.name)) {

View file

@ -251,6 +251,7 @@ internal fun PdfVerticalReader(
onNoteRequested: (String?) -> Unit = {},
onTts: (Int, Int) -> Unit = { _, _ -> },
activeToolThickness: Float = 0f,
eraserToolThickness: Float = 0f,
customHighlightColors: Map<PdfHighlightColor, Color> = emptyMap(),
onPaletteClick: () -> Unit = {},
lockedState: Triple<Float, Float, Float>? = null,
@ -1744,6 +1745,7 @@ internal fun PdfVerticalReader(
onNoteRequested = onNoteRequested,
onTts = onTts,
activeToolThickness = activeToolThickness,
eraserToolThickness = eraserToolThickness,
customHighlightColors = customHighlightColors,
onPaletteClick = onPaletteClick,
onTextBoxDragStart = { box, localTopLeft, touchOffset ->
@ -2151,8 +2153,13 @@ internal fun PdfVerticalReader(
if (isEditMode && (selectedTool == InkType.ERASER || isStylusEraserOverride) && globalEraserPosition != null) {
Canvas(modifier = Modifier.fillMaxSize()) {
val pos = globalEraserPosition!!
val radiusPx = if (activeToolThickness > 0f) {
activeToolThickness * screenWidth * zoomAnimatable.value
val eraserStrokeWidth = resolveEraserStrokeWidth(
isStylusEraserOverride,
activeToolThickness,
eraserToolThickness
)
val radiusPx = if (eraserStrokeWidth > 0f) {
eraserStrokeWidth * screenWidth * zoomAnimatable.value
} else {
8.dp.toPx()
}

View file

@ -210,6 +210,7 @@ import com.aryan.reader.SearchResult
import com.aryan.reader.SummarizationResult
import com.aryan.reader.SummaryCacheManager
import com.aryan.reader.TtsSettingsSheet
import com.aryan.reader.TtsWordReplacementsSheet
import com.aryan.reader.ml.SpeechBubble
import com.aryan.reader.epubreader.AutoScrollControls
import com.aryan.reader.epubreader.DictionarySettingsDialog
@ -224,6 +225,7 @@ import com.aryan.reader.callByokGeminiInlineAi
import com.aryan.reader.isByokCloudTtsAvailable
import com.aryan.reader.loadCustomThemes
import com.aryan.reader.loadGlobalTextureTransparency
import com.aryan.reader.loadTtsReplacementPreferences
import com.aryan.reader.paginatedreader.TtsChunk
import com.aryan.reader.pdf.data.AnnotationSettingsRepository
import com.aryan.reader.pdf.data.PdfAnnotation
@ -238,11 +240,14 @@ import com.aryan.reader.pdf.data.VirtualPage
import com.aryan.reader.rememberSearchState
import com.aryan.reader.saveCustomThemes
import com.aryan.reader.saveGlobalTextureTransparency
import com.aryan.reader.saveTtsReplacementPreferences
import com.aryan.reader.shared.ReaderTtsReplacementPreferences
import com.aryan.reader.summarizationUrl
import com.aryan.reader.tts.SpeakerSamplePlayer
import com.aryan.reader.tts.TtsPlaybackManager
import com.aryan.reader.tts.rememberTtsController
import com.aryan.reader.tts.splitTextIntoChunks
import com.aryan.reader.withTtsReplacements
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
@ -277,6 +282,12 @@ import androidx.compose.ui.input.pointer.isTertiaryPressed
import androidx.compose.ui.input.pointer.isBackPressed
import androidx.compose.ui.input.pointer.isForwardPressed
internal fun resolveEraserStrokeWidth(
isEraserOverride: Boolean,
activeToolThickness: Float,
eraserToolThickness: Float
): Float = if (isEraserOverride) eraserToolThickness else activeToolThickness
@Suppress("KotlinConstantConditions")
@SuppressLint("UnusedBoxWithConstraintsScope", "ObsoleteSdkInt", "LocalContextGetResourceValueCall")
@ExperimentalMaterial3Api
@ -441,6 +452,12 @@ fun PdfViewerScreen(
)
}
var showTtsSettingsSheet by remember { mutableStateOf(false) }
var showTtsReplacementsSheet by remember { mutableStateOf(false) }
var ttsReplacementPreferences by remember { mutableStateOf(loadTtsReplacementPreferences(context)) }
val updateTtsReplacementPreferences: (ReaderTtsReplacementPreferences) -> Unit = { next ->
ttsReplacementPreferences = next
saveTtsReplacementPreferences(context, next)
}
DisposableEffect(isKeepScreenOn) {
view.keepScreenOn = isKeepScreenOn
@ -805,6 +822,7 @@ fun PdfViewerScreen(
val activeToolColor = toolSettings.getToolColor(selectedTool)
val activeToolThickness = toolSettings.getToolThickness(selectedTool)
val eraserToolThickness = toolSettings.getToolThickness(InkType.ERASER)
val fountainPenColor = toolSettings.getToolColor(InkType.FOUNTAIN_PEN)
val markerColor = toolSettings.getToolColor(InkType.PEN)
@ -824,6 +842,7 @@ fun PdfViewerScreen(
val currentStrokeColor by remember(activeToolColor) { derivedStateOf { activeToolColor } }
val currentStrokeWidth by remember(activeToolThickness) { derivedStateOf { activeToolThickness } }
val currentEraserStrokeWidth by remember(eraserToolThickness) { derivedStateOf { eraserToolThickness } }
val pdfTextRepository = remember(context) { PdfTextRepository(context) }
val annotationRepository = remember(context) { PdfAnnotationRepository(context) }
@ -1342,6 +1361,30 @@ fun PdfViewerScreen(
Timber.d("Derived currentPage recomposed. New value: $currentPage (Mode: $displayMode)")
suspend fun rebuildMissingHighlightBounds(
document: ReaderDocument,
highlights: List<PdfUserHighlight>
): List<PdfUserHighlight> = withContext(Dispatchers.IO) {
highlights.map { highlight ->
if (highlight.bounds.isNotEmpty()) return@map highlight
val start = highlight.range.first
val end = highlight.range.second
if (highlight.pageIndex < 0 || end <= start) return@map highlight
runCatching {
document.openPage(highlight.pageIndex)?.use { page ->
page.openTextPage().use { textPage ->
val rects = textPage.textPageGetRectsForRanges(intArrayOf(start, end - start))
?.map { it.rect }
.orEmpty()
val merged = mergePdfRectsIntoLines(rects)
if (merged.isEmpty()) highlight else highlight.copy(bounds = merged)
}
} ?: highlight
}.getOrDefault(highlight)
}
}
val onHighlightAdd = remember(pdfDocument, currentBookId) {
{ pageIndex: Int, range: Pair<Int, Int>, text: String, color: PdfHighlightColor ->
Timber.tag("PdfExportDebug").i("onHighlightAdd: Adding persistent highlight. Page: $pageIndex, Text: ${text.take(20)}...")
@ -2042,6 +2085,25 @@ fun PdfViewerScreen(
}
}
var isRebuildingSyncedHighlightBounds by remember(currentBookId) { mutableStateOf(false) }
LaunchedEffect(pdfDocument, currentBookId, userHighlights.toList()) {
val document = pdfDocument ?: return@LaunchedEffect
if (currentBookId == null || isRebuildingSyncedHighlightBounds) return@LaunchedEffect
val snapshot = userHighlights.toList()
if (snapshot.none { it.bounds.isEmpty() && it.range.second > it.range.first }) return@LaunchedEffect
isRebuildingSyncedHighlightBounds = true
try {
val rebuilt = rebuildMissingHighlightBounds(document, snapshot)
if (rebuilt != snapshot) {
userHighlights.clear()
userHighlights.addAll(rebuilt)
}
} finally {
isRebuildingSyncedHighlightBounds = false
}
}
var pendingSaveMode by remember { mutableStateOf<SaveMode?>(null) }
val saveLauncher = rememberLauncherForActivityResult(
@ -2076,7 +2138,7 @@ fun PdfViewerScreen(
viewModel.saveOriginalPdf(effectivePdfUri, uri)
}
else -> {}
null -> Unit
}
}
pendingSaveMode = null
@ -2618,7 +2680,7 @@ fun PdfViewerScreen(
val ttsChunks = chunks.mapIndexed { index, text -> TtsChunk(text, "", index) }
ttsController.start(
chunks = ttsChunks,
chunks = ttsChunks.withTtsReplacements(ttsReplacementPreferences, bookId),
bookTitle = bookTitle,
chapterTitle = pageTitle,
coverImageUri = null,
@ -3434,6 +3496,7 @@ fun PdfViewerScreen(
}
showTtsSettingsSheet -> showTtsSettingsSheet = false
showTtsReplacementsSheet -> showTtsReplacementsSheet = false
showThemePanel -> showThemePanel = false
else -> {
@ -3712,6 +3775,9 @@ fun PdfViewerScreen(
val currentStrokeWidthState by rememberUpdatedState(
currentStrokeWidth
)
val currentEraserStrokeWidthState by rememberUpdatedState(
currentEraserStrokeWidth
)
@Suppress("ControlFlowWithEmptyBody") val onDrawPagination =
remember(pageIndex) {
@ -3719,10 +3785,15 @@ fun PdfViewerScreen(
val effectiveTool = if (isEraserOverride) InkType.ERASER else currentSelectedTool
if (effectiveTool == InkType.TEXT) {
} else if (effectiveTool == InkType.ERASER) {
val eraserStrokeWidth = resolveEraserStrokeWidth(
isEraserOverride,
currentStrokeWidthState,
currentEraserStrokeWidthState
)
val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f }
val existing = allAnnotations[pageIndex] ?: emptyList()
val toRemove = existing.filter {
isAnnotationHit(it, point, lastEraserPoint, aspectRatio, currentStrokeWidthState)
isAnnotationHit(it, point, lastEraserPoint, aspectRatio, eraserStrokeWidth)
}
lastEraserPoint = point
if (toRemove.isNotEmpty()) {
@ -3762,10 +3833,15 @@ fun PdfViewerScreen(
} else if (effectiveTool == InkType.ERASER) {
lastEraserPoint = point
erasedAnnotationsFromStroke.clear()
val eraserStrokeWidth = resolveEraserStrokeWidth(
isEraserOverride,
currentStrokeWidthState,
currentEraserStrokeWidthState
)
val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f }
val existing = allAnnotations[pageIndex] ?: emptyList()
val toRemove = existing.filter {
isAnnotationHit(it, point, lastEraserPoint, aspectRatio, currentStrokeWidthState)
isAnnotationHit(it, point, lastEraserPoint, aspectRatio, eraserStrokeWidth)
}
if (toRemove.isNotEmpty()) {
val batch =
@ -3892,6 +3968,7 @@ fun PdfViewerScreen(
onNoteRequested = onNoteRequested,
onTts = { pageIdx, charIdx -> startTtsWithPermissionCheck(pageIdx, charIdx) },
activeToolThickness = currentStrokeWidthState,
eraserToolThickness = currentEraserStrokeWidthState,
lockedState = lockedState,
onZoomAndPanChanged = { newScale, newOffset ->
if (pagerState.currentPage == pageIndex) {
@ -4152,6 +4229,9 @@ fun PdfViewerScreen(
val currentStrokeWidthState by rememberUpdatedState(
currentStrokeWidth
)
val currentEraserStrokeWidthState by rememberUpdatedState(
currentEraserStrokeWidth
)
@Suppress("ControlFlowWithEmptyBody") val onDrawStartStable =
remember {
@ -4164,11 +4244,16 @@ fun PdfViewerScreen(
} else if (effectiveTool == InkType.ERASER) {
lastEraserPoint = point
erasedAnnotationsFromStroke.clear()
val eraserStrokeWidth = resolveEraserStrokeWidth(
isEraserOverride,
currentStrokeWidthState,
currentEraserStrokeWidthState
)
val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f }
val existing = allAnnotations[pageIndex] ?: emptyList()
val toRemove = existing.filter {
isAnnotationHit(it, point, lastEraserPoint, aspectRatio, currentStrokeWidthState)
isAnnotationHit(it, point, lastEraserPoint, aspectRatio, eraserStrokeWidth)
}
if (toRemove.isNotEmpty()) {
val batch =
@ -4204,10 +4289,15 @@ fun PdfViewerScreen(
{ pageIndex: Int, point: PdfPoint, isEraserOverride: Boolean ->
val effectiveTool = if (isEraserOverride) InkType.ERASER else currentSelectedTool
if (effectiveTool == InkType.ERASER) {
val eraserStrokeWidth = resolveEraserStrokeWidth(
isEraserOverride,
currentStrokeWidthState,
currentEraserStrokeWidthState
)
val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f }
val existing = allAnnotations[pageIndex] ?: emptyList()
val toRemove = existing.filter {
isAnnotationHit(it, point, lastEraserPoint, aspectRatio, currentStrokeWidthState)
isAnnotationHit(it, point, lastEraserPoint, aspectRatio, eraserStrokeWidth)
}
lastEraserPoint = point
if (toRemove.isNotEmpty()) {
@ -4281,6 +4371,7 @@ fun PdfViewerScreen(
onNoteRequested = onNoteRequested,
onTts = { pageIdx, charIdx -> startTtsWithPermissionCheck(pageIdx, charIdx) },
activeToolThickness = currentStrokeWidthState,
eraserToolThickness = currentEraserStrokeWidthState,
onLinkClicked = onLinkClickedStable,
onInternalLinkClicked = onInternalLinkNavStable,
bookmarks = bookmarksHolder,
@ -5019,6 +5110,7 @@ fun PdfViewerScreen(
showBars = !isMusicianMode
},
onShowTtsSettings = { showTtsSettingsSheet = true },
onShowTtsReplacements = { showTtsReplacementsSheet = true },
onToggleBookmark = onBookmarkClick,
onInsertPage = onInsertPage,
onDeletePage = onDeletePage,
@ -6511,6 +6603,15 @@ fun PdfViewerScreen(
)
}
TtsWordReplacementsSheet(
isVisible = showTtsReplacementsSheet,
bookId = bookId,
bookTitle = documentMetadataTitle ?: originalFileName,
preferences = ttsReplacementPreferences,
onPreferencesChange = updateTtsReplacementPreferences,
onDismiss = { showTtsReplacementsSheet = false },
)
if (showDictionarySettingsSheet) {
DictionarySettingsDialog(
isVisible = true,
@ -6684,15 +6785,18 @@ fun PdfViewerScreen(
title = { Text(stringResource(R.string.title_save_to_device)) },
text = { Text(stringResource(R.string.desc_choose_format_save)) },
confirmButton = {
TextButton(
onClick = {
showSaveDialog = false
pendingSaveMode = SaveMode.ANNOTATED
val suggestedName = getSuggestedFilename(
originalFileName, isAnnotated = true
)
saveLauncher.launch(suggestedName)
}) { Text(stringResource(R.string.action_with_annotations)) }
Column(horizontalAlignment = Alignment.End) {
TextButton(
onClick = {
showSaveDialog = false
pendingSaveMode = SaveMode.ANNOTATED
val suggestedName = getSuggestedFilename(
originalFileName, isAnnotated = true
)
saveLauncher.launch(suggestedName)
}) { Text(stringResource(R.string.action_with_annotations)) }
}
},
dismissButton = {
Row {
@ -6723,31 +6827,34 @@ fun PdfViewerScreen(
title = { Text(stringResource(R.string.share_chooser_title)) },
text = { Text(stringResource(R.string.desc_choose_format_share)) },
confirmButton = {
TextButton(
onClick = {
showShareDialog = false
isShareLoading = true
Timber.tag("PdfExportDebug").i("SHARE TRIGGERED: userHighlights count: ${userHighlights.size}")
val filename = getSuggestedFilename(
originalFileName, isAnnotated = true
)
coroutineScope.launch {
val currentRichTextLayouts = richTextController?.pageLayouts
viewModel.sharePdf(
activityContext = context,
sourceUri = effectivePdfUri,
annotations = allAnnotations,
richTextPageLayouts = currentRichTextLayouts,
textBoxes = textBoxes.toList(),
highlights = userHighlights.toList(),
includeAnnotations = true,
filename = filename,
bookId = currentBookId
Column(horizontalAlignment = Alignment.End) {
TextButton(
onClick = {
showShareDialog = false
isShareLoading = true
Timber.tag("PdfExportDebug").i("SHARE TRIGGERED: userHighlights count: ${userHighlights.size}")
val filename = getSuggestedFilename(
originalFileName, isAnnotated = true
)
isShareLoading = false
}
}) { Text(stringResource(R.string.action_with_annotations)) }
coroutineScope.launch {
val currentRichTextLayouts = richTextController?.pageLayouts
viewModel.sharePdf(
activityContext = context,
sourceUri = effectivePdfUri,
annotations = allAnnotations,
richTextPageLayouts = currentRichTextLayouts,
textBoxes = textBoxes.toList(),
highlights = userHighlights.toList(),
includeAnnotations = true,
filename = filename,
bookId = currentBookId
)
isShareLoading = false
}
}) { Text(stringResource(R.string.action_with_annotations)) }
}
},
dismissButton = {
Row {

View file

@ -0,0 +1,768 @@
package com.aryan.reader.pdf
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Typeface
import android.graphics.pdf.PdfRenderer
import android.net.Uri
import android.os.ParcelFileDescriptor
import android.text.Layout
import android.text.SpannableString
import android.text.Spanned
import android.text.StaticLayout
import android.text.TextPaint
import android.text.style.AbsoluteSizeSpan
import android.text.style.BackgroundColorSpan
import android.text.style.ForegroundColorSpan
import android.text.style.MetricAffectingSpan
import android.text.style.StrikethroughSpan
import android.text.style.StyleSpan
import android.text.style.UnderlineSpan
import android.util.TypedValue
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.isSpecified
import com.aryan.reader.pdf.data.PdfAnnotation
import com.aryan.reader.pdf.data.PdfTextBox
import com.aryan.reader.pdf.data.VirtualPage
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
import java.io.OutputStream
import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import timber.log.Timber
import kotlin.math.ceil
import kotlin.math.roundToInt
internal object PdfiumAnnotationExporter {
internal const val TEXT_FLAG_BOLD = 1
internal const val TEXT_FLAG_ITALIC = 1 shl 1
internal const val TEXT_FLAG_UNDERLINE = 1 shl 2
internal const val TEXT_FLAG_STRIKE_THROUGH = 1 shl 3
internal const val TEXT_FLAG_ABSOLUTE_LINE = 1 shl 4
private const val TEXT_BOX_PADDING_DP = 8f
private const val TEXT_RASTER_PDF_POINT_SCALE = 3f
private const val TEXT_RASTER_MIN_PAGE_HEIGHT_PX = 1200f
private const val TEXT_RASTER_MAX_PAGE_HEIGHT_PX = 3600f
private const val RICH_TEXT_MARGIN_X = 0.1f
private const val RICH_TEXT_MARGIN_Y = 0.08f
suspend fun exportAnnotatedPdf(
context: Context,
sourceUri: Uri,
destStream: OutputStream,
virtualPages: List<VirtualPage>?,
inkAnnotations: Map<Int, List<PdfAnnotation>>,
richTextPageLayouts: List<PageTextLayout>? = null,
textBoxes: List<PdfTextBox>? = null,
highlights: List<PdfUserHighlight>? = null
) {
withContext(Dispatchers.IO) {
if (!supportsOriginalPageOrder(virtualPages)) {
destStream.close()
throw UnsupportedOperationException(
"PDFium annotation export currently supports only the original PDF page order."
)
}
val exportDir = File(context.cacheDir, "pdfium_annotation_export")
if (!exportDir.exists() && !exportDir.mkdirs()) {
destStream.close()
throw IOException("Unable to create PDFium export cache directory.")
}
val sourceFile: File
val destFile: File
try {
sourceFile = File.createTempFile("source_", ".pdf", exportDir)
destFile = File.createTempFile("annotated_", ".pdf", exportDir)
} catch (e: IOException) {
destStream.close()
throw e
}
try {
context.contentResolver.openInputStream(sourceUri)?.use { input ->
FileOutputStream(sourceFile).use { output -> input.copyTo(output) }
} ?: throw IOException("Unable to open source PDF for PDFium export.")
val pageSizes = runCatching { readPdfPageSizes(sourceFile) }
.onFailure { Timber.tag("PdfExportDebug").w(it, "Unable to read page sizes for text raster export.") }
.getOrDefault(emptyList())
val rasterOverlays = buildTextRasterOverlays(
context = context,
textBoxes = textBoxes.orEmpty(),
richTextPageLayouts = richTextPageLayouts.orEmpty(),
pageSizes = pageSizes
)
val payload = buildPayload(
inkAnnotations = inkAnnotations,
textBoxes = emptyList(),
highlights = highlights.orEmpty(),
richTextPageLayouts = emptyList(),
rasterOverlays = rasterOverlays
)
if (!payload.hasAnnotations()) {
FileInputStream(sourceFile).use { input -> input.copyTo(destStream) }
return@withContext
}
val exported = NativePdfiumBridge.exportAnnotatedPdf(
sourcePath = sourceFile.absolutePath,
destPath = destFile.absolutePath,
inkPageIndices = payload.inkPageIndices,
inkTypes = payload.inkTypes,
inkColors = payload.inkColors,
inkStrokeWidths = payload.inkStrokeWidths,
inkPointOffsets = payload.inkPointOffsets,
inkPointCounts = payload.inkPointCounts,
inkPoints = payload.inkPoints,
textPageIndices = payload.textPageIndices,
textBounds = payload.textBounds,
textColors = payload.textColors,
textBackgroundColors = payload.textBackgroundColors,
textFontSizes = payload.textFontSizes,
textFlags = payload.textFlags,
textValues = payload.textValues,
textFontPaths = payload.textFontPaths,
textFontNames = payload.textFontNames,
rasterPageIndices = payload.rasterPageIndices,
rasterBounds = payload.rasterBounds,
rasterWidths = payload.rasterWidths,
rasterHeights = payload.rasterHeights,
rasterPixelOffsets = payload.rasterPixelOffsets,
rasterPixels = payload.rasterPixels,
highlightPageIndices = payload.highlightPageIndices,
highlightColors = payload.highlightColors,
highlightRectOffsets = payload.highlightRectOffsets,
highlightRectCounts = payload.highlightRectCounts,
highlightRects = payload.highlightRects,
highlightContents = payload.highlightContents
)
if (!exported) {
throw IOException("PDFium failed to write annotated PDF.")
}
FileInputStream(destFile).use { input -> input.copyTo(destStream) }
Timber.tag("PdfExportDebug").i(
"PDFium export saved ${payload.inkPageIndices.size} ink, " +
"${payload.highlightPageIndices.size} highlight, " +
"${payload.rasterPageIndices.size} raster text overlays."
)
} finally {
destStream.close()
sourceFile.delete()
destFile.delete()
}
}
}
internal fun supportsOriginalPageOrder(virtualPages: List<VirtualPage>?): Boolean {
return virtualPages == null || virtualPages.withIndex().all { (index, page) ->
page is VirtualPage.PdfPage && page.pdfIndex == index
}
}
@Suppress("UNUSED_PARAMETER")
internal fun buildPayload(
inkAnnotations: Map<Int, List<PdfAnnotation>>,
textBoxes: List<PdfTextBox>,
highlights: List<PdfUserHighlight>,
richTextPageLayouts: List<PageTextLayout> = emptyList(),
fontPathResolver: (String?) -> String? = { it },
rasterOverlays: List<PdfiumRasterOverlay> = emptyList()
): PdfiumAnnotationExportPayload {
val inkItems = inkAnnotations.entries
.flatMap { (pageIndex, annotations) -> annotations.map { pageIndex to it } }
.filter { (_, annotation) ->
annotation.points.size >= 2 &&
annotation.inkType != InkType.ERASER &&
annotation.inkType != InkType.TEXT
}
val inkPageIndices = IntArray(inkItems.size)
val inkTypes = IntArray(inkItems.size)
val inkColors = IntArray(inkItems.size)
val inkStrokeWidths = FloatArray(inkItems.size)
val inkPointOffsets = IntArray(inkItems.size)
val inkPointCounts = IntArray(inkItems.size)
val inkPoints = FloatArray(inkItems.sumOf { it.second.points.size } * 2)
var inkPointCursor = 0
inkItems.forEachIndexed { index, (pageIndex, annotation) ->
inkPageIndices[index] = pageIndex
inkTypes[index] = annotation.inkType.ordinal
inkColors[index] = annotation.color.toArgb()
inkStrokeWidths[index] = annotation.strokeWidth
inkPointOffsets[index] = inkPointCursor / 2
inkPointCounts[index] = annotation.points.size
annotation.points.forEach { point ->
inkPoints[inkPointCursor++] = point.x
inkPoints[inkPointCursor++] = point.y
}
}
val textPageIndices = IntArray(0)
val textBounds = FloatArray(0)
val textColors = IntArray(0)
val textBackgroundColors = IntArray(0)
val textFontSizes = FloatArray(0)
val textFlags = IntArray(0)
val textValues = emptyArray<String>()
val textFontPaths = emptyArray<String>()
val textFontNames = emptyArray<String>()
val rasterPageIndices = IntArray(rasterOverlays.size)
val rasterBounds = FloatArray(rasterOverlays.size * 4)
val rasterWidths = IntArray(rasterOverlays.size)
val rasterHeights = IntArray(rasterOverlays.size)
val rasterPixelOffsets = IntArray(rasterOverlays.size)
val rasterPixels = IntArray(rasterOverlays.sumOf { it.pixels.size })
var rasterPixelCursor = 0
rasterOverlays.forEachIndexed { index, overlay ->
rasterPageIndices[index] = overlay.pageIndex
rasterBounds[index * 4] = overlay.left
rasterBounds[index * 4 + 1] = overlay.top
rasterBounds[index * 4 + 2] = overlay.right
rasterBounds[index * 4 + 3] = overlay.bottom
rasterWidths[index] = overlay.width
rasterHeights[index] = overlay.height
rasterPixelOffsets[index] = rasterPixelCursor
overlay.pixels.copyInto(rasterPixels, rasterPixelCursor)
rasterPixelCursor += overlay.pixels.size
}
val boundedHighlights = highlights.filter { it.bounds.isNotEmpty() }
val highlightPageIndices = IntArray(boundedHighlights.size)
val highlightColors = IntArray(boundedHighlights.size)
val highlightRectOffsets = IntArray(boundedHighlights.size)
val highlightRectCounts = IntArray(boundedHighlights.size)
val highlightRects = FloatArray(boundedHighlights.sumOf { it.bounds.size } * 4)
val highlightContents = Array(boundedHighlights.size) { "" }
var highlightRectCursor = 0
boundedHighlights.forEachIndexed { index, highlight ->
highlightPageIndices[index] = highlight.pageIndex
highlightColors[index] = highlight.color.color.toArgb()
highlightRectOffsets[index] = highlightRectCursor / 4
highlightRectCounts[index] = highlight.bounds.size
highlightContents[index] = highlight.note?.takeIf { it.isNotBlank() } ?: highlight.text
highlight.bounds.forEach { rect ->
highlightRects[highlightRectCursor++] = rect.left
highlightRects[highlightRectCursor++] = rect.top
highlightRects[highlightRectCursor++] = rect.right
highlightRects[highlightRectCursor++] = rect.bottom
}
}
return PdfiumAnnotationExportPayload(
inkPageIndices = inkPageIndices,
inkTypes = inkTypes,
inkColors = inkColors,
inkStrokeWidths = inkStrokeWidths,
inkPointOffsets = inkPointOffsets,
inkPointCounts = inkPointCounts,
inkPoints = inkPoints,
textPageIndices = textPageIndices,
textBounds = textBounds,
textColors = textColors,
textBackgroundColors = textBackgroundColors,
textFontSizes = textFontSizes,
textFlags = textFlags,
textValues = textValues,
textFontPaths = textFontPaths,
textFontNames = textFontNames,
rasterPageIndices = rasterPageIndices,
rasterBounds = rasterBounds,
rasterWidths = rasterWidths,
rasterHeights = rasterHeights,
rasterPixelOffsets = rasterPixelOffsets,
rasterPixels = rasterPixels,
highlightPageIndices = highlightPageIndices,
highlightColors = highlightColors,
highlightRectOffsets = highlightRectOffsets,
highlightRectCounts = highlightRectCounts,
highlightRects = highlightRects,
highlightContents = highlightContents
)
}
private fun buildTextRasterOverlays(
context: Context,
textBoxes: List<PdfTextBox>,
richTextPageLayouts: List<PageTextLayout>,
pageSizes: List<PdfiumPageSize>
): List<PdfiumRasterOverlay> {
val overlays = mutableListOf<PdfiumRasterOverlay>()
textBoxes.mapNotNullTo(overlays) { box ->
renderTextBoxOverlay(context, box, pageSizeFor(pageSizes, box.pageIndex))
}
richTextPageLayouts.mapNotNullTo(overlays) { layout ->
renderRichTextOverlay(context, layout, pageSizeFor(pageSizes, layout.pageIndex))
}
return overlays
}
private fun renderTextBoxOverlay(
context: Context,
box: PdfTextBox,
pageSize: PdfiumPageSize
): PdfiumRasterOverlay? {
val text = box.text.sanitizeRasterText()
if (box.pageIndex < 0 || text.isBlank()) return null
val bounds = box.relativeBounds
val left = bounds.left.coerceIn(0f, 1f)
val top = bounds.top.coerceIn(0f, 1f)
val right = bounds.right.coerceIn(left, 1f)
val bottom = bounds.bottom.coerceIn(top, 1f)
if (right - left <= 0f || bottom - top <= 0f) return null
val pageHeightPx = pageSize.exportHeightPx()
val pageWidthPx = pageHeightPx * pageSize.aspect
val bitmapWidth = ceil((right - left) * pageWidthPx).toInt().coerceAtLeast(1)
val bitmapHeight = ceil((bottom - top) * pageHeightPx).toInt().coerceAtLeast(1)
val paddingPx = dpToPx(context, TEXT_BOX_PADDING_DP)
.coerceAtMost((minOf(bitmapWidth, bitmapHeight) / 2f).coerceAtLeast(0f))
val contentWidth = (bitmapWidth - paddingPx * 2f).roundToInt().coerceAtLeast(1)
val fontSizePx = (box.fontSize * pageHeightPx).coerceAtLeast(1f)
val typeface = resolveTypeface(context, box.fontPath, box.fontName, box.isBold, box.isItalic)
val bitmap = Bitmap.createBitmap(bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888)
return try {
val paint = textPaint(
colorArgb = box.color.toArgb(),
textSizePx = fontSizePx,
typeface = typeface
)
val spannable = SpannableString(text)
applyTextBoxSpans(
text = spannable,
colorArgb = box.color.toArgb(),
backgroundArgb = box.backgroundColor.toArgb(),
fontSizePx = fontSizePx,
isBold = box.isBold,
isItalic = box.isItalic,
isUnderline = box.isUnderline,
isStrikeThrough = box.isStrikeThrough,
typeface = typeface
)
drawStaticLayout(
bitmap = bitmap,
text = spannable,
paint = paint,
width = contentWidth,
translateX = paddingPx,
translateY = paddingPx
)
bitmap.toRasterOverlay(box.pageIndex, left, top, right, bottom)
} finally {
bitmap.recycle()
}
}
private fun renderRichTextOverlay(
context: Context,
layout: PageTextLayout,
pageSize: PdfiumPageSize
): PdfiumRasterOverlay? {
val visibleText = layout.visibleText.withoutTrailingPdfiumPageBreak()
if (layout.pageIndex < 0 || visibleText.text.isBlank()) return null
val pageHeightPx = layout.pageHeightPx.takeIf { it > 0f } ?: pageSize.exportHeightPx()
val pageWidthPx = pageHeightPx * pageSize.aspect
val left = RICH_TEXT_MARGIN_X
val top = RICH_TEXT_MARGIN_Y
val right = 1f - RICH_TEXT_MARGIN_X
val bottom = 1f - RICH_TEXT_MARGIN_Y
val bitmapWidth = ceil((right - left) * pageWidthPx).toInt().coerceAtLeast(1)
val bitmapHeight = ceil((bottom - top) * pageHeightPx).toInt().coerceAtLeast(1)
val bitmap = Bitmap.createBitmap(bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888)
return try {
val paint = textPaint(
colorArgb = Color.Black.toArgb(),
textSizePx = spToPx(context, 16f),
typeface = Typeface.DEFAULT
)
val spannable = visibleText.toAndroidSpannable(context)
drawStaticLayout(
bitmap = bitmap,
text = spannable,
paint = paint,
width = bitmapWidth,
translateX = 0f,
translateY = 0f
)
bitmap.toRasterOverlay(layout.pageIndex, left, top, right, bottom)
} finally {
bitmap.recycle()
}
}
private fun applyTextBoxSpans(
text: SpannableString,
colorArgb: Int,
backgroundArgb: Int,
fontSizePx: Float,
isBold: Boolean,
isItalic: Boolean,
isUnderline: Boolean,
isStrikeThrough: Boolean,
typeface: Typeface
) {
if (text.isEmpty()) return
val end = text.length
text.setSpan(ForegroundColorSpan(colorArgb), 0, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
if ((backgroundArgb ushr 24) != 0) {
text.setSpan(BackgroundColorSpan(backgroundArgb), 0, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
text.setSpan(AbsoluteSizeSpan(fontSizePx.roundToInt().coerceAtLeast(1), false), 0, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
text.setSpan(TypefaceSpanCompat(typeface), 0, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
if (!hasStyle(typeface, isBold, isItalic)) {
text.setSpan(StyleSpan(typefaceStyle(isBold, isItalic)), 0, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
if (isUnderline) {
text.setSpan(UnderlineSpan(), 0, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
if (isStrikeThrough) {
text.setSpan(StrikethroughSpan(), 0, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
private fun AnnotatedString.toAndroidSpannable(context: Context): SpannableString {
val spannable = SpannableString(text.sanitizeRasterTextPreservingLength())
spanStyles.forEach { range ->
applySpanStyle(context, spannable, range.item, range.start, range.end)
}
return spannable
}
private fun applySpanStyle(
context: Context,
spannable: SpannableString,
style: SpanStyle,
rawStart: Int,
rawEnd: Int
) {
val start = rawStart.coerceIn(0, spannable.length)
val end = rawEnd.coerceIn(start, spannable.length)
if (start >= end) return
val color = style.color
if (color != Color.Unspecified) {
spannable.setSpan(ForegroundColorSpan(color.toArgb()), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
val background = style.background
if (background != Color.Unspecified && background.alpha > 0f) {
spannable.setSpan(BackgroundColorSpan(background.toArgb()), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
if (style.fontSize.isSpecified) {
val textSizePx = spToPx(context, style.fontSize.value)
spannable.setSpan(
AbsoluteSizeSpan(textSizePx.roundToInt().coerceAtLeast(1), false),
start,
end,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
val isBold = isBold(style.fontWeight)
val isItalic = style.fontStyle == FontStyle.Italic
val fontPath = PdfFontCache.getPath(style.fontFamily)
val fontName = standardFontName(style.fontFamily)
val typeface = resolveTypeface(context, fontPath, fontName, isBold, isItalic)
if (fontPath != null || fontName != null) {
spannable.setSpan(TypefaceSpanCompat(typeface), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
} else if (isBold || isItalic) {
spannable.setSpan(StyleSpan(typefaceStyle(isBold, isItalic)), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
val decoration = style.textDecoration ?: TextDecoration.None
if (decoration.contains(TextDecoration.Underline)) {
spannable.setSpan(UnderlineSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
if (decoration.contains(TextDecoration.LineThrough)) {
spannable.setSpan(StrikethroughSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
private fun drawStaticLayout(
bitmap: Bitmap,
text: CharSequence,
paint: TextPaint,
width: Int,
translateX: Float,
translateY: Float
) {
val canvas = Canvas(bitmap)
canvas.save()
canvas.clipRect(0, 0, bitmap.width, bitmap.height)
canvas.translate(translateX, translateY)
StaticLayout.Builder.obtain(text, 0, text.length, paint, width)
.setAlignment(Layout.Alignment.ALIGN_NORMAL)
.setIncludePad(false)
.setLineSpacing(0f, 1f)
.build()
.draw(canvas)
canvas.restore()
}
private fun textPaint(
colorArgb: Int,
textSizePx: Float,
typeface: Typeface
): TextPaint =
TextPaint(Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG).apply {
color = colorArgb
textSize = textSizePx
this.typeface = typeface
}
private fun Bitmap.toRasterOverlay(
pageIndex: Int,
boundsLeft: Float,
boundsTop: Float,
boundsRight: Float,
boundsBottom: Float
): PdfiumRasterOverlay? {
val allPixels = IntArray(width * height)
getPixels(allPixels, 0, width, 0, 0, width, height)
var minX = width
var minY = height
var maxX = -1
var maxY = -1
for (y in 0 until height) {
val rowOffset = y * width
for (x in 0 until width) {
if ((allPixels[rowOffset + x] ushr 24) != 0) {
if (x < minX) minX = x
if (x > maxX) maxX = x
if (y < minY) minY = y
if (y > maxY) maxY = y
}
}
}
if (maxX < minX || maxY < minY) return null
val cropWidth = maxX - minX + 1
val cropHeight = maxY - minY + 1
val cropped = IntArray(cropWidth * cropHeight)
for (row in 0 until cropHeight) {
System.arraycopy(
allPixels,
(minY + row) * width + minX,
cropped,
row * cropWidth,
cropWidth
)
}
val boundsWidth = boundsRight - boundsLeft
val boundsHeight = boundsBottom - boundsTop
return PdfiumRasterOverlay(
pageIndex = pageIndex,
left = boundsLeft + boundsWidth * (minX.toFloat() / width),
top = boundsTop + boundsHeight * (minY.toFloat() / height),
right = boundsLeft + boundsWidth * ((maxX + 1).toFloat() / width),
bottom = boundsTop + boundsHeight * ((maxY + 1).toFloat() / height),
width = cropWidth,
height = cropHeight,
pixels = cropped
)
}
private fun readPdfPageSizes(sourceFile: File): List<PdfiumPageSize> {
return ParcelFileDescriptor.open(sourceFile, ParcelFileDescriptor.MODE_READ_ONLY).use { descriptor ->
PdfRenderer(descriptor).use { renderer ->
List(renderer.pageCount) { index ->
val page = renderer.openPage(index)
try {
PdfiumPageSize(page.width, page.height)
} finally {
page.close()
}
}
}
}
}
private fun pageSizeFor(pageSizes: List<PdfiumPageSize>, pageIndex: Int): PdfiumPageSize =
pageSizes.getOrNull(pageIndex) ?: PdfiumPageSize.Default
private fun PdfiumPageSize.exportHeightPx(): Float =
(height * TEXT_RASTER_PDF_POINT_SCALE)
.coerceIn(TEXT_RASTER_MIN_PAGE_HEIGHT_PX, TEXT_RASTER_MAX_PAGE_HEIGHT_PX)
private fun resolveTypeface(
context: Context,
fontPath: String?,
fontName: String?,
isBold: Boolean,
isItalic: Boolean
): Typeface {
val base = try {
when {
!fontPath.isNullOrBlank() && fontPath.startsWith("asset:") ->
Typeface.createFromAsset(context.assets, fontPath.removePrefix("asset:"))
!fontPath.isNullOrBlank() ->
Typeface.createFromFile(fontPath)
else -> when (fontName?.lowercase(Locale.US)) {
"serif" -> Typeface.SERIF
"monospace" -> Typeface.MONOSPACE
"cursive" -> Typeface.create("casual", Typeface.NORMAL)
"sans", "sansserif", "sans-serif" -> Typeface.SANS_SERIF
else -> Typeface.DEFAULT
}
}
} catch (e: Exception) {
Timber.tag("PdfFontDebug").w(e, "Falling back while rasterizing fontPath=$fontPath fontName=$fontName")
Typeface.DEFAULT
}
return Typeface.create(base, typefaceStyle(isBold, isItalic))
}
private fun typefaceStyle(isBold: Boolean, isItalic: Boolean): Int =
when {
isBold && isItalic -> Typeface.BOLD_ITALIC
isBold -> Typeface.BOLD
isItalic -> Typeface.ITALIC
else -> Typeface.NORMAL
}
private fun hasStyle(typeface: Typeface, isBold: Boolean, isItalic: Boolean): Boolean {
val style = typeface.style
return (!isBold || style and Typeface.BOLD != 0) &&
(!isItalic || style and Typeface.ITALIC != 0)
}
private fun isBold(weight: FontWeight?): Boolean =
(weight?.weight ?: FontWeight.Normal.weight) >= FontWeight.SemiBold.weight
private fun standardFontName(fontFamily: FontFamily?): String? =
when (fontFamily) {
FontFamily.Serif -> "Serif"
FontFamily.Monospace -> "Monospace"
FontFamily.SansSerif -> "Sans"
FontFamily.Cursive -> "Cursive"
else -> null
}
private fun dpToPx(context: Context, value: Float): Float =
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, value, context.resources.displayMetrics)
private fun spToPx(context: Context, value: Float): Float =
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, value, context.resources.displayMetrics)
private fun AnnotatedString.withoutTrailingPdfiumPageBreak(): AnnotatedString =
if (text.lastOrNull() == PAGE_BREAK_CHAR) subSequence(0, length - 1) else this
private fun String.sanitizeRasterText(): String =
replace(PAGE_BREAK_CHAR, '\n')
.replace("\u200B", "")
.replace('\r', ' ')
private fun String.sanitizeRasterTextPreservingLength(): String =
replace(PAGE_BREAK_CHAR, '\n')
.replace('\r', ' ')
}
internal data class PdfiumRasterOverlay(
val pageIndex: Int,
val left: Float,
val top: Float,
val right: Float,
val bottom: Float,
val width: Int,
val height: Int,
val pixels: IntArray
)
private data class PdfiumPageSize(
val width: Int,
val height: Int
) {
val aspect: Float
get() = if (width > 0 && height > 0) width.toFloat() / height.toFloat() else Default.aspect
companion object {
val Default = PdfiumPageSize(612, 792)
}
}
private class TypefaceSpanCompat(
private val typeface: Typeface
) : MetricAffectingSpan() {
override fun updateDrawState(tp: TextPaint) {
apply(tp)
}
override fun updateMeasureState(tp: TextPaint) {
apply(tp)
}
private fun apply(paint: Paint) {
val oldStyle = paint.typeface?.style ?: Typeface.NORMAL
val missingStyles = oldStyle and typeface.style.inv()
if (missingStyles and Typeface.BOLD != 0) {
paint.isFakeBoldText = true
}
if (missingStyles and Typeface.ITALIC != 0) {
paint.textSkewX = -0.25f
}
paint.typeface = typeface
}
}
internal data class PdfiumAnnotationExportPayload(
val inkPageIndices: IntArray,
val inkTypes: IntArray,
val inkColors: IntArray,
val inkStrokeWidths: FloatArray,
val inkPointOffsets: IntArray,
val inkPointCounts: IntArray,
val inkPoints: FloatArray,
val textPageIndices: IntArray,
val textBounds: FloatArray,
val textColors: IntArray,
val textBackgroundColors: IntArray,
val textFontSizes: FloatArray,
val textFlags: IntArray,
val textValues: Array<String>,
val textFontPaths: Array<String>,
val textFontNames: Array<String>,
val rasterPageIndices: IntArray,
val rasterBounds: FloatArray,
val rasterWidths: IntArray,
val rasterHeights: IntArray,
val rasterPixelOffsets: IntArray,
val rasterPixels: IntArray,
val highlightPageIndices: IntArray,
val highlightColors: IntArray,
val highlightRectOffsets: IntArray,
val highlightRectCounts: IntArray,
val highlightRects: FloatArray,
val highlightContents: Array<String>
) {
fun hasAnnotations(): Boolean =
inkPageIndices.isNotEmpty() ||
textPageIndices.isNotEmpty() ||
rasterPageIndices.isNotEmpty() ||
highlightPageIndices.isNotEmpty()
}

View file

@ -32,6 +32,7 @@ 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
@ -56,12 +57,16 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import com.aryan.reader.shared.pdf.SHARED_PDF_RICH_TEXT_LOG_TAG
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<String, FontFamily>()
private var assetManager: android.content.res.AssetManager? = null
@ -262,126 +267,200 @@ class TextPaginationEngine {
dirtyGlobalIndex: Int = 0
): List<PageTextLayout> {
val totalLen = globalText.length
if (totalLen == 0) return listOf(
PageTextLayout(0, AnnotatedString(""), 0, 0, pageHeightPx)
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
"android.paginate start textLen=$totalLen page=${pageWidthPx.richAndroidLogFloat()}x${pageHeightPx.richAndroidLogFloat()} " +
"margin=${marginX.richAndroidLogFloat()},${marginY.richAndroidLogFloat()} prev=${previousLayouts.size} dirty=$dirtyGlobalIndex"
)
if (pageWidthPx <= 0 || pageHeightPx <= 0) return emptyList()
val validPages = if (dirtyGlobalIndex > 0 && previousLayouts.isNotEmpty()) {
previousLayouts.takeWhile { it.globalEndIndex < dirtyGlobalIndex }
} else {
emptyList()
if (totalLen == 0) {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d("android.paginate empty -> p0:0-0")
return listOf(
PageTextLayout(0, AnnotatedString(""), 0, 0, pageHeightPx)
)
}
if (pageWidthPx <= 0 || pageHeightPx <= 0) {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d("android.paginate aborted invalid page size")
return emptyList()
}
val startPageIndex = validPages.size
val measurementStartIndex = validPages.lastOrNull()?.globalEndIndex ?: 0
if (measurementStartIndex >= totalLen) return validPages
val textToMeasure = globalText.subSequence(measurementStartIndex, totalLen)
val fullString = textToMeasure.text
val editorWidth = (pageWidthPx - (marginX * 2)).coerceAtLeast(10f)
val editorHeight = (pageHeightPx - (marginY * 2)).coerceAtLeast(10f)
val measureResult = textMeasurer.measure(
text = textToMeasure,
style = TextStyle(fontSize = 16.sp, color = Color.Black),
constraints = Constraints(maxWidth = editorWidth.toInt(), maxHeight = Constraints.Infinity),
density = density
)
val newPages = mutableListOf<PageTextLayout>()
var currentPageIndex = startPageIndex
var currentPageStartRel = 0
var currentPageAccumulatedHeight = 0f
var currentPageIndex = 0
var segmentStart = 0
val rawText = globalText.text
var currentLineIndex = 0
val totalLines = measureResult.lineCount
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
Timber.tag("RichTextFlow").d("Pagination: Measuring ${fullString.length} chars from Global $measurementStartIndex. Lines: $totalLines")
while (currentLineIndex < totalLines) {
val lineTop = measureResult.getLineTop(currentLineIndex)
val lineBottom = measureResult.getLineBottom(currentLineIndex)
val lineHeight = lineBottom - lineTop
val lineStartRel = measureResult.getLineStart(currentLineIndex)
val lineEndRel = measureResult.getLineEnd(currentLineIndex)
val localStartOffset = (currentPageStartRel - lineStartRel).coerceAtLeast(0)
if (lineStartRel + localStartOffset >= lineEndRel && currentLineIndex < totalLines - 1) {
currentLineIndex++
continue
}
val safeEndRel = lineEndRel.coerceAtMost(fullString.length)
val lineContent = fullString.substring(lineStartRel, safeEndRel)
val breakIndexInLine = lineContent.indexOf(PAGE_BREAK_CHAR, localStartOffset)
val hasPageBreak = breakIndexInLine != -1
val isStartOfPage = (currentPageAccumulatedHeight == 0f)
val willOverflow = !isStartOfPage && (currentPageAccumulatedHeight + lineHeight > editorHeight)
if (hasPageBreak) {
val splitRelIndex = lineStartRel + breakIndexInLine + 1
val globalStart = measurementStartIndex + currentPageStartRel
val globalEnd = measurementStartIndex + splitRelIndex
Timber.tag("RichTextMigration").v("PaginationEngine: Found PAGE_BREAK_CHAR at relative ${breakIndexInLine}. Breaking Page $currentPageIndex at Global Index $globalEnd")
if (globalEnd > globalStart) {
val visibleText = globalText.subSequence(globalStart, globalEnd)
newPages.add(PageTextLayout(currentPageIndex, visibleText, globalStart, globalEnd, pageHeightPx))
currentPageIndex++
}
currentPageStartRel = splitRelIndex
currentPageAccumulatedHeight = 0f
continue
}
else if (willOverflow) {
val globalStart = measurementStartIndex + currentPageStartRel
val globalEnd = measurementStartIndex + lineStartRel
if (globalEnd > globalStart) {
val visibleText = globalText.subSequence(globalStart, globalEnd)
newPages.add(PageTextLayout(currentPageIndex, visibleText, globalStart, globalEnd, pageHeightPx))
Timber.tag("RichTextFlow").v("Page $currentPageIndex Created (Overflow): $globalStart -> $globalEnd")
currentPageIndex++
}
currentPageStartRel = lineStartRel
currentPageAccumulatedHeight = 0f
continue
}
currentPageAccumulatedHeight += lineHeight
currentLineIndex++
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
}
if (currentPageStartRel < fullString.length) {
val globalStart = measurementStartIndex + currentPageStartRel
val globalEnd = measurementStartIndex + fullString.length
val visibleText = globalText.subSequence(globalStart, globalEnd)
newPages.add(PageTextLayout(currentPageIndex, visibleText, globalStart, globalEnd, pageHeightPx))
}
val resultLayouts = validPages + newPages
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.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d("android.paginate done -> ${resultLayouts.richAndroidLayoutSummary()}")
return resultLayouts
}
}
private fun MutableList<PageTextLayout>.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.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).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.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
"android.paginate pageBreak page=$nextPageIndex global=$globalStart..$globalEnd"
)
} else if (!fitsOnPage) {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).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<PageTextLayout>.withTrailingAndroidBlankRichTextPageIfNeeded(
globalText: AnnotatedString,
pageHeightPx: Float
): List<PageTextLayout> {
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())
}
class PdfRichTextRepository(private val context: Context) {
private val _document = MutableStateFlow<GlobalRichDocument?>(null)
val document = _document.asStateFlow()
@ -396,8 +475,12 @@ class PdfRichTextRepository(private val context: Context) {
suspend fun load(bookId: String) {
withContext(Dispatchers.IO) {
val file = getFile(bookId)
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
"android.repository.load start book=$bookId exists=${file.exists()} path=${file.absolutePath}"
)
if (!file.exists()) {
_document.value = GlobalRichDocument("", emptyList())
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d("android.repository.load missing -> empty book=$bookId")
return@withContext
}
try {
@ -425,7 +508,11 @@ class PdfRichTextRepository(private val context: Context) {
)
}
_document.value = GlobalRichDocument(text, spans)
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
"android.repository.load decoded book=$bookId rawLen=${jsonString.length} textLen=${text.length} spans=${spans.size}"
)
} catch (e: Exception) {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).e(e, "android.repository.load failed book=$bookId")
Timber.e(e, "Failed to load rich text doc")
_document.value = GlobalRichDocument("", emptyList())
}
@ -436,6 +523,9 @@ class PdfRichTextRepository(private val context: Context) {
_document.value = document
withContext(Dispatchers.IO) {
try {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).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()
@ -456,14 +546,35 @@ class PdfRichTextRepository(private val context: Context) {
}
put("spans", spansArray)
}
getFile(bookId).writeText(obj.toString())
val file = getFile(bookId)
file.writeText(obj.toString())
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
"android.repository.save done book=$bookId bytes=${file.length()} path=${file.absolutePath}"
)
} catch (e: Exception) {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).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<PageTextLayout>.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,
@ -485,6 +596,9 @@ class RichTextController(
var pageLayouts by mutableStateOf(emptyList<PageTextLayout>())
private set
val hasRenderableText: Boolean
get() = globalTextFieldValue.text.hasRenderableRichText()
var currentStyle: SpanStyle by mutableStateOf(SpanStyle(color = Color.Black, fontSize = 16.sp))
private set
@ -720,9 +834,11 @@ class RichTextController(
val currentGlobal = globalTextFieldValue.annotatedString
// FIX: Strip ZWSP (index 0) from local text
val localText = if (localTextFieldValue.annotatedString.isNotEmpty()) {
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}")
@ -776,7 +892,7 @@ class RichTextController(
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)
@ -865,28 +981,26 @@ class RichTextController(
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) + vText
val safeLen = if (vText.isNotEmpty() && vText.last() == PAGE_BREAK_CHAR) vText.length - 1 else vText.length
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 = currentLayout.visibleText, // We measure the original for layout tap calc
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 = measureResult.size.height.toFloat()
val textHeight = if (editableText.isEmpty()) 0f else measureResult.size.height.toFloat()
if (localTapOffset.y <= textHeight) {
if (editableText.isNotEmpty() && localTapOffset.y <= textHeight) {
var localIndex = measureResult.getOffsetForPosition(localTapOffset)
if (vText.isNotEmpty() && vText.last() == PAGE_BREAK_CHAR && localIndex >= vText.length) {
localIndex = vText.length - 1
}
localIndex = localIndex.coerceIn(0, editableText.length)
localTextFieldValue = localTextFieldValue.copy(selection = TextRange(localIndex + 1))
} else {
val gap = localTapOffset.y - textHeight
@ -1132,7 +1246,9 @@ class RichTextController(
val currentGlobal = globalTextFieldValue.annotatedString
val localAnnotatedRaw = localTextFieldValue.annotatedString
val localAnnotated = if (localAnnotatedRaw.isNotEmpty()) localAnnotatedRaw.subSequence(1, localAnnotatedRaw.length) else 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"
@ -1197,7 +1313,7 @@ class RichTextController(
val reExtracted = newGlobalAnnotated.subSequence(
newActiveLayout.globalStartIndex, newActiveLayout.globalEndIndex
)
).withoutTrailingAndroidPageBreak()
val textWithZwsp = AnnotatedString(ZWSP) + reExtracted
val newLocalCursor = (newGlobalCursorPos - newActiveLayout.globalStartIndex + 1).coerceIn(0, textWithZwsp.length)
@ -1301,7 +1417,7 @@ class RichTextController(
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)
@ -1351,7 +1467,7 @@ class RichTextController(
val reExtracted = newGlobalText.subSequence(
finalActiveLayout.globalStartIndex, finalActiveLayout.globalEndIndex
)
).withoutTrailingAndroidPageBreak()
val textWithZwsp = AnnotatedString(ZWSP) + reExtracted
val localCursor = (newCursorPos - finalActiveLayout.globalStartIndex + 1).coerceIn(0, textWithZwsp.length)
@ -1400,4 +1516,4 @@ class RichTextController(
isSaving = false
}
}
}
}

View file

@ -545,8 +545,11 @@ class OpdsStreamDocumentWrapper(
private val client = com.aryan.reader.opds.OpdsRepository.sharedHttpClient.newBuilder()
.apply {
if (!catalog?.username.isNullOrBlank() && !catalog.password.isNullOrBlank()) {
authenticator(com.aryan.reader.opds.OpdsRepository.OpdsAuthenticator(catalog.username, catalog.password))
val streamCatalog = catalog
val username = streamCatalog?.username
val password = streamCatalog?.password
if (!username.isNullOrBlank() && !password.isNullOrBlank()) {
authenticator(com.aryan.reader.opds.OpdsRepository.OpdsAuthenticator(username, password))
}
}
.build()
@ -580,10 +583,11 @@ class OpdsStreamDocumentWrapper(
}
}
val finalUrlTemplate = if (catalog != null && urlTemplate.startsWith("http")) {
val streamCatalog = catalog
val finalUrlTemplate = if (streamCatalog != null && urlTemplate.startsWith("http")) {
try {
val oldUrl = java.net.URL(urlTemplate)
val newUrl = java.net.URL(catalog.url)
val newUrl = java.net.URL(streamCatalog.url)
val oldBase = "${oldUrl.protocol}://${oldUrl.authority}"
val newBase = "${newUrl.protocol}://${newUrl.authority}"
urlTemplate.replace(oldBase, newBase)