Windows (#291)
* 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:
parent
88c7fa7b5c
commit
8366d76dcd
214 changed files with 53372 additions and 4702 deletions
|
|
@ -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
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue