Pdf reflow rework (#51)

* perf(pdf): native Pdfium-based reflow engine with auto-open

Migrate PDF-to-Markdown reflow generation from PDFBox to a custom
native implementation using Pdfium. This transition improves
processing speed by ~10x and significantly reduces memory overhead.

- Native JNI Bridge: Implemented `pdfium_bridge.cpp` using `dlopen`
  to hook into the existing `libpdfium.so` memory space.
- Optimized Extraction: Replaced character-by-character JNI calls
  with bulk array retrieval (`getPageFontSizes`, `getPageFontWeights`),
  drastically reducing JNI boundary overhead.
- Enhanced Accuracy: Improved Markdown formatting logic by using
  native font weight (bold) and relative font size variance (headers).
- Thread Safety: Refactored generator to process pages sequentially
  while synchronized with the global `PdfiumCore.lock` to ensure
  stability across concurrent UI operations.
- Seamless UX: Implemented a reactive auto-open system in the
  PDF viewer that tracks user intent and navigates to the reflow
  view immediately upon background task completion.

* Improved PDF to Markdown conversion and added cache cleanup.

- Implemented automated cleanup of imported file caches when deleting books.
- Enhanced `PdfToMarkdownGenerator` with support for font flags (italics), improved kerning, and smarter paragraph wrapping.
- Updated `NativePdfiumBridge` and C++ JNI code to extract font information flags from PDFium.

* Implemented seamless file switching and enhanced reflow transition logic.

Key changes include:
- Added `switchToFileSeamlessly` to `MainViewModel` to handle state transitions and navigation when switching between PDF and reflowed text views.
- Updated `generateAndImportReflowFile` to support automatic opening of the generated file at a specific page/chapter.
- Integrated `NavigationEvent` and `CompletableDeferred` to manage asynchronous navigation and state updates during file switches.
- Modified `EpubReaderScreen` and `PdfViewerScreen` to pass the current position when toggling between PDF and text modes.
- Updated `AppNavigation` to move navigation logic out of the `NavHost` and added loading overlays to viewers to improve UI feedback during transitions.
This commit is contained in:
Aryan 2026-03-10 10:59:44 +05:30 committed by GitHub
parent acf282d4c7
commit c61a264a65
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 940 additions and 419 deletions

View file

@ -0,0 +1,14 @@
package com.aryan.reader.pdf
object NativePdfiumBridge {
init {
System.loadLibrary("native-lib")
}
@JvmStatic external fun getFontSize(textPagePtr: Long, index: Int): Double
@JvmStatic external fun getFontWeight(textPagePtr: Long, index: Int): Int
@JvmStatic external fun getPageFontSizes(textPagePtr: Long, count: Int): FloatArray?
@JvmStatic external fun getPageFontWeights(textPagePtr: Long, count: Int): IntArray?
@JvmStatic external fun getPageFontFlags(textPagePtr: Long, count: Int): IntArray?
}

View file

@ -3,11 +3,9 @@ package com.aryan.reader.pdf
import android.content.Context
import android.net.Uri
import com.tom_roush.pdfbox.io.MemoryUsageSetting
import com.tom_roush.pdfbox.pdmodel.PDDocument
import com.tom_roush.pdfbox.pdmodel.PDPage
import com.tom_roush.pdfbox.text.PDFTextStripper
import com.tom_roush.pdfbox.text.TextPosition
import io.legere.pdfiumandroid.PdfiumCore
import io.legere.pdfiumandroid.suspend.PdfiumCoreKt
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import timber.log.Timber
@ -15,8 +13,6 @@ import java.io.File
import kotlin.math.roundToInt
object PdfToMarkdownGenerator {
// Unique delimiter to split pages reliably
const val PAGE_DELIMITER = "\n\n[[PAGE_BREAK]]\n\n"
suspend fun generateMarkdownFile(
@ -26,109 +22,252 @@ object PdfToMarkdownGenerator {
startPage: Int = 1,
onProgress: (Float) -> Unit
): Boolean = withContext(Dispatchers.IO) {
val methodStartTime = System.currentTimeMillis()
Timber.tag("PdfToMdPerf").d("generateMarkdownFile NATIVE START | uri=$pdfUri | startPage=$startPage")
val pdfiumCore = PdfiumCoreKt(Dispatchers.Default)
val pfd = context.contentResolver.openFileDescriptor(pdfUri, "r")
if (pfd == null) {
Timber.tag("PdfToMdPerf").e("Failed to open ParcelFileDescriptor")
return@withContext false
}
try {
context.contentResolver.openInputStream(pdfUri)?.use { inputStream ->
// Setup mixed memory usage to handle larger files without OOM
PDDocument.load(inputStream, MemoryUsageSetting.setupMixed(50 * 1024 * 1024)).use { doc ->
val totalPages = doc.numberOfPages
val doc = pdfiumCore.newDocument(pfd)
val totalPages = doc.getPageCount()
Timber.tag("PdfToMdPerf").d("Document loaded natively. Total Pages: $totalPages")
// Configure stripper for linear processing
val stripper = MarkdownStripper(totalPages, onProgress)
stripper.startPage = startPage
stripper.endPage = totalPages
destFile.bufferedWriter().use { writer ->
for (pageIdx in (startPage - 1) until totalPages) {
val pageMd = extractPageMarkdown(doc, pageIdx)
writer.write(pageMd)
writer.write(PAGE_DELIMITER)
// Write directly to file stream (O(N) complexity)
destFile.bufferedWriter().use { writer ->
stripper.writeText(doc, writer)
if (pageIdx % 5 == 0 || pageIdx == totalPages - 1) {
onProgress((pageIdx + 1).toFloat() / totalPages.toFloat())
}
}
}
doc.close()
pfd.close()
Timber.tag("PdfToMdPerf").d("generateMarkdownFile NATIVE SUCCESS | totalTime=${System.currentTimeMillis() - methodStartTime}ms")
return@withContext true
} catch (e: Exception) {
Timber.e(e, "Failed to generate Markdown from PDF")
Timber.e(e, "Failed to generate Markdown from PDF natively")
pfd.close()
return@withContext false
}
}
private class MarkdownStripper(
private val totalPages: Int,
private val onProgress: (Float) -> Unit
) : PDFTextStripper() {
private var currentPageBaseFontSize = 0f
private suspend fun extractPageMarkdown(doc: PdfDocumentKt, pageIdx: Int): String {
return try {
doc.openPage(pageIdx).use { page ->
page.openTextPage().use { textPage ->
val charCount = textPage.textPageCountChars()
if (charCount <= 0) return@use ""
init {
sortByPosition = true
suppressDuplicateOverlappingText = true
paragraphStart = ""
paragraphEnd = "\n\n"
}
val text = textPage.textPageGetText(0, charCount) ?: ""
val actualCount = minOf(charCount, text.length)
// Override endPage to update progress and insert delimiter
override fun endPage(page: PDPage?) {
super.endPage(page)
val rawPtr = textPage.page.pagePtr
try {
// Insert our custom delimiter so importer can split chapters
output.write(PAGE_DELIMITER)
val sizes: FloatArray?
val weights: IntArray?
val flags: IntArray?
// Update progress
val current = currentPageNo // inherited from PDFTextStripper
if (totalPages > 0) {
onProgress(current.toFloat() / totalPages.toFloat())
synchronized(PdfiumCore.lock) {
sizes = NativePdfiumBridge.getPageFontSizes(rawPtr, actualCount)
weights = NativePdfiumBridge.getPageFontWeights(rawPtr, actualCount)
flags = NativePdfiumBridge.getPageFontFlags(rawPtr, actualCount)
}
if (sizes == null || weights == null || flags == null) {
return@use text
}
buildMarkdown(text, sizes, weights, flags, actualCount)
}
} catch (e: Exception) {
Timber.e(e, "Error writing page delimiter")
}
}
override fun startPage(page: PDPage?) {
currentPageBaseFontSize = 0f
super.startPage(page)
}
private fun calculateBaseFontSize(textPositions: List<TextPosition>) {
val sizeCounts = mutableMapOf<Float, Int>()
textPositions.forEach { pos ->
val size = pos.fontSizeInPt.roundToInt().toFloat()
sizeCounts[size] = (sizeCounts[size] ?: 0) + 1
}
currentPageBaseFontSize = sizeCounts.maxByOrNull { it.value }?.key ?: 12f
}
override fun writeString(text: String?, textPositions: MutableList<TextPosition>?) {
if (text.isNullOrBlank() || textPositions.isNullOrEmpty()) return
if (currentPageBaseFontSize == 0f) {
calculateBaseFontSize(textPositions)
}
val firstPos = textPositions[0]
val fontSize = firstPos.fontSizeInPt
val fontDescriptor = firstPos.font?.fontDescriptor
val isBold = fontDescriptor?.isForceBold == true ||
(firstPos.font?.name?.contains("Bold", ignoreCase = true) == true)
val isItalic = fontDescriptor?.isItalic == true ||
(firstPos.font?.name?.contains("Italic", ignoreCase = true) == true)
// Header detection logic
val isHeader = fontSize > currentPageBaseFontSize * 1.2
val isBigHeader = fontSize > currentPageBaseFontSize * 1.5
val sb = StringBuilder()
if (isBigHeader) sb.append("## ")
else if (isHeader) sb.append("### ")
if (isBold && !isHeader) sb.append("**")
if (isItalic) sb.append("*")
text.forEach { char -> sb.append(char) }
if (isItalic) sb.append("*")
if (isBold && !isHeader) sb.append("**")
writeString(sb.toString())
} catch (e: Exception) {
Timber.w(e, "Error extracting page $pageIdx")
""
}
}
private data class TextSpan(
val text: String,
val size: Float,
val isBold: Boolean,
val isItalic: Boolean
)
private data class TextLine(
val spans: List<TextSpan>
)
private fun fixKerning(text: String): String {
val pattern = Regex("\\b(?:[A-Za-z0-9] ){2,}[A-Za-z0-9]\\b")
return pattern.replace(text) { matchResult ->
matchResult.value.replace(" ", "")
}
}
private fun buildMarkdown(text: String, sizes: FloatArray, weights: IntArray, flags: IntArray, count: Int): String {
if (count == 0) return ""
val sizeFrequency = HashMap<Int, Int>()
for (i in 0 until count) {
val s = sizes[i].roundToInt()
sizeFrequency[s] = (sizeFrequency[s] ?: 0) + 1
}
val baseSize = sizeFrequency.maxByOrNull { it.value }?.key ?: 12
val lines = mutableListOf<TextLine>()
@Suppress("CanBeVal") var currentSpans = mutableListOf<TextSpan>()
val currentSpanText = StringBuilder()
var currentSize = -1f
var currentBold = false
var currentItalic = false
for (i in 0 until count) {
val c = text[i]
if (c == '\u0000') continue
if (c == '\n' || c == '\r') {
if (c == '\n' && i > 0 && text[i - 1] == '\r') continue
if (currentSpanText.isNotEmpty()) {
currentSpans.add(TextSpan(currentSpanText.toString(), currentSize, currentBold, currentItalic))
currentSpanText.clear()
}
lines.add(TextLine(currentSpans.toList()))
currentSpans.clear()
continue
}
val isSpace = c.isWhitespace()
val size = sizes[i]
val bold = weights[i] > 600
val italic = (flags[i] and 64) != 0
if (currentSpanText.isEmpty()) {
currentSize = size
currentBold = bold
currentItalic = italic
currentSpanText.append(c)
} else {
if (!isSpace && (currentSize != size || currentBold != bold || currentItalic != italic)) {
currentSpans.add(TextSpan(currentSpanText.toString(), currentSize, currentBold, currentItalic))
currentSpanText.clear()
currentSize = size
currentBold = bold
currentItalic = italic
}
currentSpanText.append(c)
}
}
if (currentSpanText.isNotEmpty()) {
currentSpans.add(TextSpan(currentSpanText.toString(), currentSize, currentBold, currentItalic))
}
if (currentSpans.isNotEmpty()) {
lines.add(TextLine(currentSpans))
}
val validLines = lines.filter { it.spans.isNotEmpty() }
val lineLengths = validLines.map { line -> line.spans.sumOf { it.text.length } }.filter { it > 10 }.sorted()
val typicalLineLen = if (lineLengths.isNotEmpty()) {
lineLengths[(lineLengths.size * 0.8).toInt().coerceAtMost(lineLengths.size - 1)]
} else {
80
}
val wrapThreshold = (typicalLineLen * 0.85).toInt()
val sb = StringBuilder()
for (i in lines.indices) {
val line = lines[i]
if (line.spans.isEmpty()) {
sb.append("\n")
continue
}
val maxFontSize = line.spans.filter { it.text.isNotBlank() }.maxOfOrNull { it.size } ?: baseSize.toFloat()
val charBigHeader = maxFontSize > baseSize * 1.5f
val charHeader = maxFontSize > baseSize * 1.2f
var prefix = ""
if (charBigHeader) prefix = "## "
else if (charHeader) prefix = "### "
val rawLineText = line.spans.joinToString("") { it.text }
val trimmedRaw = rawLineText.trim()
val lineLen = trimmedRaw.length
val isList = trimmedRaw.startsWith("") ||
trimmedRaw.startsWith("- ") ||
trimmedRaw.startsWith("") ||
trimmedRaw.matches(Regex("^[0-9]+\\.\\s.*")) ||
trimmedRaw.matches(Regex("^[a-zA-Z]\\)\\s.*"))
if (prefix.isNotEmpty() && !isList) {
sb.append(prefix)
}
for (span in line.spans) {
var spanText = span.text
spanText = fixKerning(spanText)
val leadingSpaces = spanText.takeWhile { it.isWhitespace() }
val trailingSpaces = spanText.takeLastWhile { it.isWhitespace() }
val trimmedText = spanText.trim()
if (trimmedText.isEmpty()) {
sb.append(spanText)
continue
}
sb.append(leadingSpaces)
var tag = ""
if (span.isBold && span.isItalic) tag = "***"
else if (span.isBold) tag = "**"
else if (span.isItalic) tag = "*"
sb.append(tag).append(trimmedText).append(tag)
sb.append(trailingSpaces)
}
var isParagraphBreak = false
if (prefix.isNotEmpty() || isList) {
isParagraphBreak = true
} else if (lineLen < wrapThreshold) {
isParagraphBreak = true
} else if (trimmedRaw.matches(Regex(".*[.!?\"'”’;:*]$"))) {
isParagraphBreak = true
} else {
val nextLine = lines.subList(i + 1, lines.size).firstOrNull { it.spans.isNotEmpty() }
if (nextLine != null) {
val nextRaw = nextLine.spans.joinToString("") { it.text }.trimStart()
if (nextRaw.startsWith("\"") || nextRaw.startsWith("") || nextRaw.startsWith("-")) {
isParagraphBreak = true
}
}
}
if (isParagraphBreak) {
sb.append("\n\n")
} else {
sb.append("\n")
}
}
return sb.toString().replace(Regex("\\n{3,}"), "\n\n").trim()
}
}

View file

@ -27,11 +27,7 @@ import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.pm.PackageManager
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import android.graphics.Bitmap
import kotlin.math.max
import android.graphics.RectF
import android.net.Uri
import android.os.Build
@ -59,8 +55,11 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -137,10 +136,8 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
@ -286,6 +283,7 @@ import java.io.ByteArrayOutputStream
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
import kotlin.random.Random
@ -1845,26 +1843,6 @@ fun PdfViewerScreen(
onToggleBookmark(currentPage)
}
LaunchedEffect(reflowInfo) {
if (reflowInfo?.state == WorkInfo.State.SUCCEEDED &&
reflowInfo?.tags?.contains("book_$bookId") == true) {
val result = snackbarHostState.showSnackbar(
message = "Text View generation complete!",
actionLabel = "OPEN",
duration = SnackbarDuration.Long
)
if (result == SnackbarResult.ActionPerformed) {
snackbarHostState.currentSnackbarData?.dismiss()
val item = uiState.recentFiles.find { it.bookId == reflowBookId }
if (item != null) {
viewModel.onRecentFileClicked(item)
}
}
}
}
LaunchedEffect(pdfUri) { debugPdfLinks(context, pdfUri, pdfiumCore, this) }
LaunchedEffect(currentBookId) {
@ -4979,13 +4957,14 @@ fun PdfViewerScreen(
if (hasReflowFile) {
val item = uiState.recentFiles.find { it.bookId == reflowBookId }
if (item != null) {
viewModel.onRecentFileClicked(item)
viewModel.switchToFileSeamlessly(item, currentPage)
}
} else {
viewModel.generateAndImportReflowFile(
pdfBookId = bookId,
pdfUri = pdfUri,
originalTitle = originalFileName
originalTitle = originalFileName,
autoOpenPage = currentPage
)
}
},

View file

@ -20,29 +20,49 @@ class ReflowWorker(
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
val bookId = inputData.getString(KEY_BOOK_ID) ?: return@withContext Result.failure()
val pdfUriString = inputData.getString(KEY_PDF_URI) ?: return@withContext Result.failure()
val workStartTime = System.currentTimeMillis()
Timber.tag("PdfToMdPerf").d("=== ReflowWorker START ===")
val bookId = inputData.getString(KEY_BOOK_ID) ?: run {
Timber.tag("PdfToMdPerf").e("FAILURE: KEY_BOOK_ID is null")
return@withContext Result.failure()
}
val pdfUriString = inputData.getString(KEY_PDF_URI) ?: run {
Timber.tag("PdfToMdPerf").e("FAILURE: KEY_PDF_URI is null | bookId=$bookId")
return@withContext Result.failure()
}
val originalTitle = inputData.getString(KEY_ORIGINAL_TITLE) ?: "Document"
val reflowBookId = "${bookId}_reflow"
Timber.tag("PdfToMdPerf").d("Input data | bookId=$bookId | reflowBookId=$reflowBookId | pdfUri=$pdfUriString | originalTitle=$originalTitle")
val destFile = File(applicationContext.filesDir, "${bookId}_reflow.md")
val pdfUri = pdfUriString.toUri()
Timber.tag("ReflowWorker").d("Starting background reflow for $originalTitle.")
Timber.tag("PdfToMdPerf").d("Dest file path: ${destFile.absolutePath} | exists=${destFile.exists()}")
Timber.tag("PdfToMdPerf").d("Starting PdfToMarkdownGenerator.generateMarkdownFile...")
val genStartTime = System.currentTimeMillis()
// Delegate entire process to Generator (it now handles the loop and progress)
val success = PdfToMarkdownGenerator.generateMarkdownFile(
applicationContext,
pdfUri,
destFile,
startPage = 1 // Always start from beginning for full regeneration
startPage = 1
) { progress ->
// Report progress
if ((progress * 10).toInt() % 1 == 0) {
Timber.tag("PdfToMdPerf").d("Progress: ${(progress * 100).toInt()}%")
}
setProgressAsync(workDataOf(KEY_PROGRESS to progress))
}
Timber.tag("PdfToMdPerf").d("generateMarkdownFile completed | success=$success | time=${System.currentTimeMillis() - genStartTime}ms")
if (success && destFile.exists()) {
Timber.tag("ReflowWorker").d("Reflow complete. Importing to database.")
val fileSizeKB = destFile.length() / 1024
Timber.tag("PdfToMdPerf").d("Reflow SUCCESS | outputFileSize=${fileSizeKB}KB")
Timber.tag("PdfToMdPerf").d("Starting database import...")
val dbStartTime = System.currentTimeMillis()
val repo = RecentFilesRepository(applicationContext)
@ -63,13 +83,16 @@ class ReflowWorker(
)
repo.addRecentFile(newItem)
Timber.tag("PdfToMdPerf").d("Database import completed in ${System.currentTimeMillis() - dbStartTime}ms")
// 100% Progress
setProgressAsync(workDataOf(KEY_PROGRESS to 1.0f))
val totalTime = System.currentTimeMillis() - workStartTime
Timber.tag("PdfToMdPerf").d("=== ReflowWorker SUCCESS === | totalTime=${totalTime}ms | totalTimeSec=${totalTime / 1000}s")
return@withContext Result.success()
} else {
Timber.e("Reflow failed or was incomplete.")
val totalTime = System.currentTimeMillis() - workStartTime
Timber.tag("PdfToMdPerf").e("=== ReflowWorker FAILURE === | success=$success | fileExists=${destFile.exists()} | totalTime=${totalTime}ms")
return@withContext Result.failure()
}
}