Pdf reflow upgrade (#63)

* Replaced PDF-to-Markdown reflow with an enhanced PDF-to-HTML generator.

Specific changes include:
- Replaced `PdfToMarkdownGenerator` and `PdfReflowGenerator` with `PdfToHtmlGenerator`.
- Added native bridge methods in `NativePdfiumBridge.kt` and `pdfium_bridge.cpp` to extract character bounding boxes, page objects, and image pixels.
- Implemented vertical merging of text and images in `PdfToHtmlGenerator` to maintain document layout.
- Added logic to detect and filter repeating headers and footers across PDF pages.
- Updated `ReflowWorker` to generate `.html` files instead of `.md` files and updated `FileType` handling.
- Simplified `SingleFileImporter` by removing dependencies on the legacy Markdown generator.

* Updated PDF to HTML generation to include page breaks and split HTML files into individual chapters based on page markers.

* Added junk character filtering and normalization to PDF text extraction

* - Added `allRecentFiles` to `ReaderScreenState` to track all files, including reflowed versions.
- Updated `recentFiles` in `MainViewModel` to filter out reflowed files (`_reflow`) from the main library view.
- Implemented `deleteBookPermanently` in `MainViewModel` to handle book deletion and cache cleanup.
- Added a "Delete Text View" option to the `EpubReader` controls for reflowed files.
- Improved reflow file detection in `PdfViewerScreen` by checking against `allRecentFiles`.

* Implemented a centralized data-saving mechanism in `PdfViewerScreen` using a debounced `saveAllData` function. This refactor consolidates the saving of annotations, text boxes, highlights, bookmarks, and scroll positions, adding lifecycle-aware triggers and a mutex to ensure data integrity during pauses or document navigation.

* Optimized PDF selection and annotation performance by offloading heavy operations to background threads and improving UI responsiveness.

- Moved PDF page/text opening, character range calculations, and text extraction to `Dispatchers.IO`.
- Wrapped UI updates in `updateSelectionVisuals` and selection logic with `withContext(Dispatchers.Main)`.
- Implemented a more efficient `Popup`-based magnifier to replace manual offsets and transformations.
- Updated `PdfSelectionMenuPopup` properties to prevent focus and click-outside dismissal conflicts.
This commit is contained in:
Aryan 2026-03-13 17:02:09 +05:30 committed by GitHub
parent 9a95b4afcd
commit 843a77d0ef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1270 additions and 895 deletions

View file

@ -11,12 +11,19 @@ object NativePdfiumBridge {
@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?
@JvmStatic external fun getPageCharBoxes(textPagePtr: Long, count: Int): FloatArray?
@JvmStatic external fun getAnnotCount(pagePtr: Long): Int
@JvmStatic external fun getAnnotSubtype(pagePtr: Long, index: Int): Int
@JvmStatic external fun getAnnotRect(pagePtr: Long, index: Int): FloatArray?
@JvmStatic external fun getAnnotString(pagePtr: Long, index: Int, key: String): String?
// Image/Object extraction
@JvmStatic external fun getPageObjectCount(pagePtr: Long): Int
@JvmStatic external fun getPageObjectType(pagePtr: Long, index: Int): Int
@JvmStatic external fun getPageObjectBoundingBox(pagePtr: Long, index: Int, outRect: FloatArray): Boolean
@JvmStatic external fun extractImagePixels(pagePtr: Long, index: Int, dimens: IntArray): IntArray?
const val ANNOT_TEXT = 1 // Sticky Note
const val ANNOT_LINK = 2 // Link
const val ANNOT_HIGHLIGHT = 8 // Highlight

View file

@ -196,8 +196,8 @@ internal fun PdfSelectionMenuPopup(
popupPositionProvider = popupPositionProvider,
onDismissRequest = onDismiss,
properties = PopupProperties(
focusable = true,
dismissOnClickOutside = true,
focusable = false,
dismissOnClickOutside = false,
dismissOnBackPress = true
)
) {

View file

@ -71,7 +71,6 @@ import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
@ -164,8 +163,8 @@ data class EmbeddedAnnotation(
val rect: android.graphics.RectF,
val contents: String?,
val author: String?,
val name: String?, // Unique ID
val inReplyTo: String?, // ID of parent
val name: String?,
val inReplyTo: String?,
val replies: MutableList<EmbeddedAnnotation> = mutableListOf()
)
@ -1536,90 +1535,101 @@ internal fun PdfPageComposable(
providedTextPage: PdfTextPageKt? = null
) {
if (charRange == null || currentBitmapWidth == 0 || currentBitmapHeight == 0) {
selectedWordScreenRects = emptyList()
startHandleContentPosition.value = null
endHandleContentPosition.value = null
return
}
var localPage: PdfPageKt? = null
var localTextPage: PdfTextPageKt? = null
try {
val pageToUse: PdfPageKt
val textPageToUse: PdfTextPageKt
if (providedPage != null && providedTextPage != null) {
pageToUse = providedPage
textPageToUse = providedTextPage
} else {
localPage = doc.openPage(pageIdx)
localTextPage = localPage.openTextPage()
pageToUse = localPage
textPageToUse = localTextPage
}
val (startIndex, endIndex) = charRange
if (startIndex >= endIndex) {
withContext(Dispatchers.Main) {
selectedWordScreenRects = emptyList()
startHandleContentPosition.value = null
endHandleContentPosition.value = null
return
}
return
}
val length = endIndex - startIndex
val wordPdfRectsF =
textPageToUse.textPageGetRectsForRanges(intArrayOf(startIndex, length))?.map {
it.rect
} ?: emptyList()
withContext(Dispatchers.IO) {
var localPage: PdfPageKt? = null
var localTextPage: PdfTextPageKt? = null
if (wordPdfRectsF.isNotEmpty()) {
val mappedScreenRects = wordPdfRectsF.mapNotNull { pdfRectF ->
val screenRect = pageToUse.mapRectToDevice(
startX = 0,
startY = 0,
sizeX = currentBitmapWidth,
sizeY = currentBitmapHeight,
rotate = rotation,
coords = pdfRectF
)
if (screenRect.width() > 0 && screenRect.height() > 0) screenRect
else {
Timber.d(
"updateSelectionVisuals: Filtering out invalid screen rect: $screenRect"
try {
val pageToUse: PdfPageKt
val textPageToUse: PdfTextPageKt
if (providedPage != null && providedTextPage != null) {
pageToUse = providedPage
textPageToUse = providedTextPage
} else {
localPage = doc.openPage(pageIdx)
localTextPage = localPage.openTextPage()
pageToUse = localPage
textPageToUse = localTextPage
}
val (startIndex, endIndex) = charRange
if (startIndex >= endIndex) {
withContext(Dispatchers.Main) {
selectedWordScreenRects = emptyList()
startHandleContentPosition.value = null
endHandleContentPosition.value = null
}
return@withContext
}
val length = endIndex - startIndex
val wordPdfRectsF =
textPageToUse.textPageGetRectsForRanges(intArrayOf(startIndex, length))?.map {
it.rect
} ?: emptyList()
if (wordPdfRectsF.isNotEmpty()) {
val mappedScreenRects = wordPdfRectsF.mapNotNull { pdfRectF ->
val screenRect = pageToUse.mapRectToDevice(
startX = 0,
startY = 0,
sizeX = currentBitmapWidth,
sizeY = currentBitmapHeight,
rotate = rotation,
coords = pdfRectF
)
null
if (screenRect.width() > 0 && screenRect.height() > 0) screenRect
else {
Timber.d(
"updateSelectionVisuals: Filtering out invalid screen rect: $screenRect"
)
null
}
}
withContext(Dispatchers.Main) {
selectedWordScreenRects = mappedScreenRects
if (mappedScreenRects.isNotEmpty()) {
val firstRect = mappedScreenRects.first()
val lastRect = mappedScreenRects.last()
startHandleContentPosition.value =
Offset(firstRect.left.toFloat(), firstRect.bottom.toFloat())
endHandleContentPosition.value =
Offset(lastRect.right.toFloat(), lastRect.bottom.toFloat())
} else {
selectedWordScreenRects = emptyList()
startHandleContentPosition.value = null
endHandleContentPosition.value = null
}
}
} else {
withContext(Dispatchers.Main) {
selectedWordScreenRects = emptyList()
startHandleContentPosition.value = null
endHandleContentPosition.value = null
}
}
selectedWordScreenRects = mappedScreenRects
if (mappedScreenRects.isNotEmpty()) {
val firstRect = mappedScreenRects.first()
val lastRect = mappedScreenRects.last()
startHandleContentPosition.value =
Offset(firstRect.left.toFloat(), firstRect.bottom.toFloat())
endHandleContentPosition.value =
Offset(lastRect.right.toFloat(), lastRect.bottom.toFloat())
} else {
} catch (e: Exception) {
Timber.e(e, "Error updating selection visuals for page $pageIdx, range $charRange: $e")
withContext(Dispatchers.Main) {
selectedWordScreenRects = emptyList()
startHandleContentPosition.value = null
endHandleContentPosition.value = null
}
} else {
selectedWordScreenRects = emptyList()
startHandleContentPosition.value = null
endHandleContentPosition.value = null
}
} catch (e: Exception) {
Timber.e(e, "Error updating selection visuals for page $pageIdx, range $charRange: $e")
selectedWordScreenRects = emptyList()
startHandleContentPosition.value = null
endHandleContentPosition.value = null
} finally {
if (providedPage == null && providedTextPage == null) {
withContext(NonCancellable) {
withContext(Dispatchers.IO) {
} finally {
if (providedPage == null && providedTextPage == null) {
withContext(NonCancellable) {
try {
localTextPage?.close()
} catch (_: Exception) {
@ -2105,14 +2115,14 @@ internal fun PdfPageComposable(
var pageForMenu: PdfPageKt? = null
var textPageForMenu: PdfTextPageKt? = null
try {
pageForMenu = pdfDocumentItem.openPage(
pdfPageIndex
)
textPageForMenu = pageForMenu.openTextPage()
val text = textPageForMenu.textPageGetText(
currentRange.first,
currentRange.second - currentRange.first
)
val text = withContext(Dispatchers.IO) {
pageForMenu = pdfDocumentItem.openPage(pdfPageIndex)
textPageForMenu = pageForMenu.openTextPage()
textPageForMenu.textPageGetText(
currentRange.first,
currentRange.second - currentRange.first
)
}
if (!text.isNullOrBlank()) {
val combinedRect = Rect(selectedWordScreenRects.first())
selectedWordScreenRects.forEach { combinedRect.union(it) }
@ -2220,79 +2230,81 @@ internal fun PdfPageComposable(
try {
if (!isPdfPage) return@launch
tempPage = pdfDocumentItem.openPage(pdfPageIndex)
tempTextPage = tempPage.openTextPage()
val touchInContentCoords = screenToContentCoordinates(
down.position
)
Timber.d(
"Long press: initial touch in content coords: $touchInContentCoords"
)
val touchInContentCoords = screenToContentCoordinates(down.position)
Timber.d("Long press: initial touch in content coords: $touchInContentCoords")
if (touchInContentCoords.x < 0 || touchInContentCoords.x > actualBitmapWidthPx || touchInContentCoords.y < 0 || touchInContentCoords.y > actualBitmapHeightPx) {
Timber.d(
"Long press: Touch point outside bitmap bounds."
)
Timber.d("Long press: Touch point outside bitmap bounds.")
return@launch
}
val pdfCoords = tempPage.mapDeviceCoordsToPage(
startX = 0,
startY = 0,
sizeX = actualBitmapWidthPx,
sizeY = actualBitmapHeightPx,
rotate = currentPageRotation,
deviceX = touchInContentCoords.x.toInt(),
deviceY = touchInContentCoords.y.toInt()
)
val charTolerance = 5.0
val charIndex = tempTextPage.textPageGetCharIndexAtPos(
x = pdfCoords.x.toDouble(),
y = pdfCoords.y.toDouble(),
xTolerance = charTolerance,
yTolerance = charTolerance
)
var pdfiumSelectionSuccessful = false
if (charIndex != -1) {
val pageCharCount = tempTextPage.textPageCountChars()
val wordBoundaries = findWordBoundaries(
tempTextPage, charIndex, pageCharCount
withContext(Dispatchers.IO) {
tempPage = pdfDocumentItem.openPage(pdfPageIndex)
tempTextPage = tempPage.openTextPage()
val pdfCoords = tempPage.mapDeviceCoordsToPage(
startX = 0,
startY = 0,
sizeX = actualBitmapWidthPx,
sizeY = actualBitmapHeightPx,
rotate = currentPageRotation,
deviceX = touchInContentCoords.x.toInt(),
deviceY = touchInContentCoords.y.toInt()
)
val charTolerance = 5.0
val charIndex = tempTextPage.textPageGetCharIndexAtPos(
x = pdfCoords.x.toDouble(),
y = pdfCoords.y.toDouble(),
xTolerance = charTolerance,
yTolerance = charTolerance
)
if (wordBoundaries != null) {
selectionMethodUsed = PdfSelectionMethod.PDFIUM
selectionCharRange.value = wordBoundaries
updateSelectionVisuals(
pdfDocumentItem,
pdfPageIndex,
selectionCharRange.value,
actualBitmapWidthPx,
actualBitmapHeightPx,
currentPageRotation,
providedPage = tempPage,
providedTextPage = tempTextPage
if (charIndex != -1) {
val pageCharCount = tempTextPage.textPageCountChars()
val wordBoundaries = findWordBoundaries(
tempTextPage, charIndex, pageCharCount
)
if (selectionCharRange.value != null && selectedWordScreenRects.isNotEmpty()) {
val currentRange = selectionCharRange.value!!
val text = tempTextPage.textPageGetText(
currentRange.first,
currentRange.second - currentRange.first
)
if (!text.isNullOrBlank()) {
val combinedRect = Rect(selectedWordScreenRects.first())
selectedWordScreenRects.forEach { combinedRect.union(it) }
customMenuState = CustomPdfMenuState(
selectedText = text,
anchorRect = combinedRect,
charRange = currentRange
)
pdfiumSelectionSuccessful = true
Timber.d(
"Long press: PDFIUM selection successful. Menu: ${customMenuState?.anchorRect}"
)
if (wordBoundaries != null) {
withContext(Dispatchers.Main) {
selectionMethodUsed = PdfSelectionMethod.PDFIUM
selectionCharRange.value = wordBoundaries
}
updateSelectionVisuals(
pdfDocumentItem,
pdfPageIndex,
wordBoundaries,
actualBitmapWidthPx,
actualBitmapHeightPx,
currentPageRotation,
providedPage = tempPage,
providedTextPage = tempTextPage
)
withContext(Dispatchers.Main) {
if (selectionCharRange.value != null && selectedWordScreenRects.isNotEmpty()) {
val currentRange = selectionCharRange.value!!
val text = withContext(Dispatchers.IO) {
tempTextPage.textPageGetText(
currentRange.first,
currentRange.second - currentRange.first
)
}
if (!text.isNullOrBlank()) {
val combinedRect = Rect(selectedWordScreenRects.first())
selectedWordScreenRects.forEach { combinedRect.union(it) }
customMenuState = CustomPdfMenuState(
selectedText = text,
anchorRect = combinedRect,
charRange = currentRange
)
pdfiumSelectionSuccessful = true
Timber.d(
"Long press: PDFIUM selection successful. Menu: ${customMenuState?.anchorRect}"
)
}
}
}
}
}
@ -3682,9 +3694,12 @@ internal fun PdfPageComposable(
var page: PdfPageKt? = null
var textPage: PdfTextPageKt? = null
try {
page = pdfDocumentItem.openPage(pdfPageIndex)
textPage = page.openTextPage()
val charCount = textPage.textPageCountChars()
val charCount = withContext(Dispatchers.IO) {
page = pdfDocumentItem.openPage(pdfPageIndex)
textPage = page.openTextPage()
textPage.textPageCountChars()
}
if (charCount > 0) {
selectionCharRange.value = Pair(0, charCount)
updateSelectionVisuals(
@ -3698,8 +3713,9 @@ internal fun PdfPageComposable(
providedTextPage = textPage
)
if (selectedWordScreenRects.isNotEmpty()) {
val fullText =
textPage.textPageGetText(0, charCount)
val fullText = withContext(Dispatchers.IO) {
textPage!!.textPageGetText(0, charCount)
}
if (!fullText.isNullOrBlank()) {
val combinedRect = Rect(selectedWordScreenRects.first())
selectedWordScreenRects.forEach { combinedRect.union(it) }
@ -3714,9 +3730,11 @@ internal fun PdfPageComposable(
} catch (e: Exception) {
Timber.e(e, "Failed to select all")
} finally {
withContext(Dispatchers.IO) {
textPage?.close()
page?.close()
withContext(NonCancellable) {
withContext(Dispatchers.IO) {
textPage?.close()
page?.close()
}
}
}
} else {
@ -4124,7 +4142,7 @@ internal sealed interface AnnotationRenderData {
internal object PdfAnnotationRenderHelper {
fun createRenderData(annot: PdfAnnotation, widthPx: Int, heightPx: Int): AnnotationRenderData? {
val startTime = System.nanoTime()
if (annot.points.isEmpty()) return null
if (annot.points.size == 1) {
@ -4277,6 +4295,10 @@ internal object PdfAnnotationRenderHelper {
)
}
}
val duration = (System.nanoTime() - startTime) / 1_000_000f
if (duration > 1f) {
Timber.tag("PdfPerf").v("Path Gen: Type=${annot.inkType}, Pts=${annot.points.size}, Time=${duration}ms")
}
return result
}
}
@ -4291,8 +4313,21 @@ private fun PdfAnnotationLayer(
centeringOffsetY: Float,
pageIndex: Int
) {
SideEffect { Timber.tag("PdfDrawPerf").v("ANNOT LAYER: Recomposing (Page $pageIndex)") }
SideEffect { Timber.tag("PdfPerf").v("ANNOT_LAYER: Recomposing Page $pageIndex") }
val staticAnnotations = annotationsProvider()
val staticRenderData = remember(staticAnnotations, actualBitmapWidthPx, actualBitmapHeightPx) {
val startTime = System.nanoTime()
val data = staticAnnotations.mapNotNull { annot ->
PdfAnnotationRenderHelper.createRenderData(
annot, actualBitmapWidthPx, actualBitmapHeightPx
)
}
val duration = (System.nanoTime() - startTime) / 1_000_000f
Timber.tag("PdfPerf").d("ANNOT_LAYER: Processed ${staticAnnotations.size} static annots in ${duration}ms")
data
}
val currentAnnotation = remember(drawingState, pageIndex) {
derivedStateOf {
val annot = drawingState?.currentAnnotation
@ -4312,30 +4347,21 @@ private fun PdfAnnotationLayer(
)
}
val staticRenderData = remember(staticAnnotations, actualBitmapWidthPx, actualBitmapHeightPx) {
staticAnnotations.mapNotNull { annot ->
PdfAnnotationRenderHelper.createRenderData(
annot, actualBitmapWidthPx, actualBitmapHeightPx
)
}
}
val activeRenderData = remember(
currentAnnotation,
currentAnnotation?.points?.size,
actualBitmapWidthPx,
actualBitmapHeightPx
) {
if (currentAnnotation != null) {
Timber.tag("PdfDrawPerf").v(
"ANNOT LAYER: Generating active path for ${currentAnnotation.points.size} points"
)
val startTime = System.nanoTime()
val res = currentAnnotation?.let { annot ->
PdfAnnotationRenderHelper.createRenderData(annot, actualBitmapWidthPx, actualBitmapHeightPx)
}
currentAnnotation?.let { annot ->
PdfAnnotationRenderHelper.createRenderData(
annot, actualBitmapWidthPx, actualBitmapHeightPx
)
val duration = (System.nanoTime() - startTime) / 1_000_000f
if (duration > 0.5f) {
Timber.tag("PdfPerf").v("ANNOT_LAYER: Active path gen took ${duration}ms")
}
res
}
Canvas(modifier = Modifier.fillMaxSize()) {
@ -4386,9 +4412,9 @@ private fun PdfAnnotationLayer(
activeRenderData?.let { drawData(it) }
}
val drawDuration = (System.nanoTime() - drawStart) / 1_000_000f
Timber.tag("PdfDrawPerf").v(
"ANNOT DRAW: Canvas draw took ${drawDuration}ms. Points: ${currentAnnotation?.points?.size ?: 0}"
)
if (drawDuration > 2f) {
Timber.tag("PdfPerf").v("ANNOT_DRAW: Canvas draw took ${drawDuration}ms (Page $pageIndex)")
}
}
}
@ -4528,11 +4554,15 @@ private fun PdfPageRenderer(
onHighlightUpdate: (String, PdfHighlightColor) -> Unit,
onHighlightDelete: (String) -> Unit,
) {
SideEffect {
Timber.tag("PdfPerf").v("PAGE_RENDERER: Recomposing Page ${selectionData.pageIndex}. DraggingHandle=${activeDraggingHandle != null}")
}
Box(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
Timber.tag("PdfPerf").v("GraphicsLayer Update: Scale=$scale, Offset=$offset")
scaleX = scale
scaleY = scale
translationX = offset.x
@ -4783,7 +4813,6 @@ private fun PdfPageRenderer(
}
if (showMagnifier && activeDraggingHandle != null && staticData.bitmap.item != null) {
val handleContentPos = when (activeDraggingHandle) {
Handle.START -> startHandlePos
Handle.END -> endHandlePos
@ -4795,39 +4824,44 @@ private fun PdfPageRenderer(
val magnifierWidth = 120.dp
val magnifierHeight = 60.dp
val magnifierOffsetAboveHandle = 24.dp
val effectiveScale = staticData.effectiveScale
with(density) {
val magnifierWidthPx = magnifierWidth.toPx()
val magnifierHeightPx = magnifierHeight.toPx()
val magnifierOffsetAboveHandlePx = magnifierOffsetAboveHandle.toPx()
val effectiveScale = staticData.effectiveScale
val effectiveZoomFactor = if (isVerticalScroll && effectiveScale > 1f) {
effectiveScale * 1.25f
} else {
magnifierZoomFactor
}
val modifier: Modifier
val effectiveZoomFactor: Float
val popupPositionProvider = remember(pos, layoutCoordinates, density) {
object : androidx.compose.ui.window.PopupPositionProvider {
override fun calculatePosition(
anchorBounds: androidx.compose.ui.unit.IntRect,
windowSize: androidx.compose.ui.unit.IntSize,
layoutDirection: androidx.compose.ui.unit.LayoutDirection,
popupContentSize: androidx.compose.ui.unit.IntSize
): androidx.compose.ui.unit.IntOffset {
val coords = layoutCoordinates ?: return androidx.compose.ui.unit.IntOffset.Zero
if (isVerticalScroll && effectiveScale > 1f) {
val yOffsetPixels =
pos.y - (magnifierHeightPx + magnifierOffsetAboveHandlePx) / effectiveScale
val xOffsetPixels = pos.x - (magnifierWidthPx / 2) / effectiveScale
val windowPos = coords.localToWindow(pos)
val offsetPx = with(density) { magnifierOffsetAboveHandle.toPx() }
modifier =
Modifier
.offset(x = xOffsetPixels.toDp(), y = yOffsetPixels.toDp())
.graphicsLayer(
scaleX = 1f / effectiveScale,
scaleY = 1f / effectiveScale,
transformOrigin = TransformOrigin(0f, 0f)
)
val x = (windowPos.x - popupContentSize.width / 2).toInt()
val y = (windowPos.y - popupContentSize.height - offsetPx).toInt()
effectiveZoomFactor = effectiveScale * 1.25f
} else {
val xOffsetVal = pos.x - magnifierWidthPx / 2
val yOffsetVal = pos.y - magnifierHeightPx - magnifierOffsetAboveHandlePx
modifier = Modifier.offset(x = xOffsetVal.toDp(), y = yOffsetVal.toDp())
effectiveZoomFactor = magnifierZoomFactor
return androidx.compose.ui.unit.IntOffset(x, y)
}
}
}
androidx.compose.ui.window.Popup(
popupPositionProvider = popupPositionProvider,
properties = androidx.compose.ui.window.PopupProperties(
focusable = false,
dismissOnClickOutside = false,
dismissOnBackPress = false,
usePlatformDefaultWidth = false
)
) {
MagnifierComposable(
sourceBitmap = staticData.bitmap.item.asImageBitmap(),
tiles = if (effectiveScale > 1f) staticData.tiles.item else emptyList(),
@ -4839,7 +4873,7 @@ private fun PdfPageRenderer(
selectionRectsInBitmapCoords = selectionData.mergedSelectionRects.item,
highlightColor = Color(0x6633B5E5),
colorFilter = staticData.colorFilter.item,
modifier = modifier
modifier = Modifier
)
}
}
@ -4848,9 +4882,10 @@ private fun PdfPageRenderer(
if (menuState != null) {
BackHandler(enabled = true, onBack = onMenuDismiss)
}
menuState?.let { state ->
if (state.anchorRect.width() > 0 || state.anchorRect.height() > 0) {
val popupPositionProvider = remember(state.anchorRect, density, offset, scale, layoutCoordinates) {
if (menuState != null && !isScrolling && draggingBoxId == null && activeDraggingHandle == null) {
if (menuState.anchorRect.width() > 0 || menuState.anchorRect.height() > 0) {
val popupPositionProvider = remember(menuState.anchorRect, density, offset, scale, layoutCoordinates) {
object : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
@ -4861,8 +4896,12 @@ private fun PdfPageRenderer(
val coords = layoutCoordinates ?: return IntOffset.Zero
// Map the bitmap-space anchor (the icon) to window-space
val topLeftLocal = contentToScreenCoordinates(Offset(state.anchorRect.left.toFloat(), state.anchorRect.top.toFloat()))
val bottomRightLocal = contentToScreenCoordinates(Offset(state.anchorRect.right.toFloat(), state.anchorRect.bottom.toFloat()))
val topLeftLocal = contentToScreenCoordinates(Offset(
menuState.anchorRect.left.toFloat(),
menuState.anchorRect.top.toFloat()))
val bottomRightLocal = contentToScreenCoordinates(Offset(
menuState.anchorRect.right.toFloat(),
menuState.anchorRect.bottom.toFloat()))
val topLeftWindow = coords.localToWindow(topLeftLocal)
val bottomRightWindow = coords.localToWindow(bottomRightLocal)
@ -4890,30 +4929,28 @@ private fun PdfPageRenderer(
}
PdfSelectionMenuPopup(
menuState = state,
menuState = menuState,
popupPositionProvider = popupPositionProvider,
onDismiss = onMenuDismiss,
onCopy = onCopy,
onAiDefine = onAiDefine,
onSelectAll = onSelectAll,
onColorSelected = { color ->
Timber.tag("PdfHighlightDebug").d("PdfSelectionMenuPopup onColorSelected: $color, isExisting=${state.isExistingHighlight}")
if (state.isExistingHighlight && state.highlightId != null) {
onHighlightUpdate(state.highlightId, color)
Timber.tag("PdfHighlightDebug").d("PdfSelectionMenuPopup onColorSelected: $color, isExisting=${menuState.isExistingHighlight}")
if (menuState.isExistingHighlight && menuState.highlightId != null) {
onHighlightUpdate(menuState.highlightId, color)
} else {
Timber.tag("PdfHighlightDebug").d("Calling onHighlightAdd for page ${selectionData.pageIndex}")
onHighlightAdd(
selectionData.pageIndex,
state.charRange,
state.selectedText,
selectionData.pageIndex, menuState.charRange, menuState.selectedText,
color
)
}
onMenuDismiss()
},
onDelete = {
if (state.isExistingHighlight && state.highlightId != null) {
onHighlightDelete(state.highlightId)
if (menuState.isExistingHighlight && menuState.highlightId != null) {
onHighlightDelete(menuState.highlightId)
}
onMenuDismiss()
}

View file

@ -1,135 +0,0 @@
package com.aryan.reader.pdf
import android.content.Context
import com.aryan.reader.epub.EpubBook
import com.aryan.reader.epub.EpubChapter
import com.aryan.reader.pdf.data.PdfTextRepository
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.util.UUID
object PdfReflowGenerator {
suspend fun generateReflowBook(
context: Context,
bookId: String,
document: PdfDocumentKt,
repository: PdfTextRepository,
totalPages: Int
): EpubBook = withContext(Dispatchers.Default) {
val cacheDir = File(context.cacheDir, "reflow_cache/$bookId")
if (cacheDir.exists()) {
cacheDir.deleteRecursively()
}
cacheDir.mkdirs()
val chapters = mutableListOf<EpubChapter>()
val css = """
body { font-family: sans-serif; line-height: 1.6; padding: 1em; }
p { margin-bottom: 1em; }
h1, h2 { color: #333; margin-top: 1.5em; }
.page-marker { color: #888; font-size: 0.8em; margin-bottom: 2em; border-bottom: 1px solid #eee; }
""".trimIndent()
// We generate a chapter for every page to keep sync simple
for (i in 0 until totalPages) {
val rawText = repository.getOrExtractText(bookId, document, i)
val cleanedHtml = processTextToHtml(rawText, i + 1)
val fileName = "page_$i.html"
val file = File(cacheDir, fileName)
val fullHtml = """
<!DOCTYPE html>
<html>
<head>
<title>Page ${i + 1}</title>
<style>$css</style>
</head>
<body>
$cleanedHtml
</body>
</html>
""".trimIndent()
file.writeText(fullHtml)
chapters.add(
EpubChapter(
chapterId = "${bookId}_page_$i",
absPath = fileName,
title = "Page ${i + 1}",
htmlFilePath = fileName,
plainTextContent = rawText, // Raw text for search/TTS
htmlContent = fullHtml,
depth = 0,
isInToc = true
)
)
}
EpubBook(
fileName = "Reflow_Session",
title = document.getDocumentMeta().title ?: "Reflow View",
author = document.getDocumentMeta().author ?: "",
language = "en",
coverImage = null,
chapters = chapters,
chaptersForPagination = chapters,
images = emptyList(),
pageList = emptyList(),
extractionBasePath = cacheDir.absolutePath,
css = emptyMap()
)
}
private fun processTextToHtml(rawText: String, pageNumber: Int): String {
if (rawText.isBlank()) return "<p><i>(No text on this page)</i></p>"
val lines = rawText.split('\n')
val sb = StringBuilder()
sb.append("<div class='page-marker'>Page $pageNumber</div>")
var currentParagraph = StringBuilder()
for (line in lines) {
val trimmed = line.trim()
if (trimmed.isEmpty()) {
if (currentParagraph.isNotEmpty()) {
sb.append("<p>${currentParagraph.toString()}</p>")
currentParagraph.clear()
}
continue
}
// Heuristic: Header detection (All caps, short line, no punctuation at end)
val isHeader = trimmed.length < 50 && trimmed.all { it.isUpperCase() || !it.isLetter() } && !trimmed.endsWith(".")
if (isHeader) {
if (currentParagraph.isNotEmpty()) {
sb.append("<p>${currentParagraph.toString()}</p>")
currentParagraph.clear()
}
sb.append("<h2>$trimmed</h2>")
continue
}
if (currentParagraph.isNotEmpty()) {
currentParagraph.append(" ")
}
currentParagraph.append(trimmed)
if (trimmed.endsWith(".") || trimmed.endsWith("?") || trimmed.endsWith("!") || trimmed.endsWith(":")) {
}
}
if (currentParagraph.isNotEmpty()) {
sb.append("<p>${currentParagraph.toString()}</p>")
}
return sb.toString()
}
}

View file

@ -0,0 +1,514 @@
// PdfToHtmlGenerator.kt
package com.aryan.reader.pdf
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import android.util.Base64
import io.legere.pdfiumandroid.PdfiumCore
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
import io.legere.pdfiumandroid.suspend.PdfiumCoreKt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.ByteArrayOutputStream
import java.io.File
import kotlin.math.roundToInt
private const val TAG = "PdfToHtml"
object PdfToHtmlGenerator {
suspend fun generateHtmlFile(
context: Context,
pdfUri: Uri,
destFile: File,
startPage: Int = 1,
onProgress: (Float) -> Unit
): Boolean = withContext(Dispatchers.IO) {
val t0 = System.currentTimeMillis()
Timber.tag(TAG).d("generateHtmlFile START | uri=$pdfUri | startPage=$startPage")
val pdfiumCore = PdfiumCoreKt(Dispatchers.Default)
val pfd = context.contentResolver.openFileDescriptor(pdfUri, "r") ?: run {
Timber.tag(TAG).e("Failed to open ParcelFileDescriptor")
return@withContext false
}
try {
val doc = pdfiumCore.newDocument(pfd)
val totalPages = doc.getPageCount()
Timber.tag(TAG).d("Document loaded. Total pages: $totalPages")
val headerFooterStrings = detectRepeatingHeaderFooter(doc, totalPages)
destFile.bufferedWriter().use { writer ->
writer.write(buildGlobalHtmlHeader())
for (pageIdx in (startPage - 1) until totalPages) {
if (pageIdx > startPage - 1) {
writer.write("\n<page-break></page-break>\n")
}
val pageHtml = extractPageHtml(doc, pageIdx, pageIdx + 1, headerFooterStrings)
writer.write(pageHtml)
if (pageIdx % 5 == 0 || pageIdx == totalPages - 1) {
onProgress((pageIdx + 1).toFloat() / totalPages.toFloat())
}
}
writer.write(buildGlobalHtmlFooter())
}
doc.close()
pfd.close()
Timber.tag(TAG).d("generateHtmlFile SUCCESS | ${System.currentTimeMillis() - t0}ms")
return@withContext true
} catch (e: Exception) {
Timber.tag(TAG).e(e, "Failed to generate HTML from PDF")
try { pfd.close() } catch (_: Exception) {}
return@withContext false
}
}
private data class TextSpan(
val text: String,
val size: Float,
val isBold: Boolean,
val isItalic: Boolean
)
private sealed interface PageElement {
val yPos: Float
}
private data class TextElement(
val line: TextLine,
override val yPos: Float
) : PageElement
private data class ImageElement(
val base64Data: String,
val width: Int,
val height: Int,
override val yPos: Float
) : PageElement
private data class TextLine(
val spans: List<TextSpan>,
val yPos: Float,
val charCount: Int
)
private fun buildGlobalHtmlHeader(): String = """
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body { font-family: sans-serif; line-height: 1.65; padding: 1em; max-width: 100%; margin: 0; }
h1 { font-size: 1.9em; font-weight: bold; margin: 1.2em 0 0.4em; }
h2 { font-size: 1.55em; font-weight: bold; margin: 1.1em 0 0.35em; }
h3 { font-size: 1.3em; font-weight: bold; margin: 1.0em 0 0.3em; }
h4 { font-size: 1.1em; font-weight: bold; margin: 0.9em 0 0.25em; }
p { margin: 0.5em 0; }
ul, ol { padding-left: 1.5em; margin: 0.5em 0; }
li { margin-bottom: 0.2em; }
hr { border: none; border-top: 1px solid currentColor; opacity: 0.25; margin: 1.4em 0; }
.page-section { margin-bottom: 0.5em; }
.page-marker { opacity: 0.4; font-size: 0.72em; margin-bottom: 1.2em; letter-spacing: 0.04em; }
.page-divider { border: none; border-top: 1px solid currentColor; opacity: 0.12; margin: 2em 0 1.5em; }
</style>
</head>
<body>
""".trimIndent() + "\n"
private fun buildGlobalHtmlFooter(): String = "\n</body>\n</html>\n"
private suspend fun extractPageHtml(
doc: PdfDocumentKt,
pageIdx: Int,
pageNumber: Int,
headerFooterStrings: Set<String>
): String {
return try {
doc.openPage(pageIdx).use { page ->
if (page == null) return buildEmptyPageSection(pageNumber)
page.openTextPage().use { textPage ->
val charCount = textPage.textPageCountChars()
val pagePtr = page.page.pagePtr
val textPagePtr = textPage.page.pagePtr
val imageElements = mutableListOf<ImageElement>()
val objCount = NativePdfiumBridge.getPageObjectCount(pagePtr)
for (i in 0 until objCount) {
if (NativePdfiumBridge.getPageObjectType(pagePtr, i) == 3) {
val bbox = FloatArray(4)
if (NativePdfiumBridge.getPageObjectBoundingBox(pagePtr, i, bbox)) {
val topY = bbox[3]
val dimens = IntArray(2)
val pixels = NativePdfiumBridge.extractImagePixels(pagePtr, i, dimens)
if (pixels != null && dimens[0] > 0 && dimens[1] > 0) {
try {
val bmp = Bitmap.createBitmap(pixels, dimens[0], dimens[1], Bitmap.Config.ARGB_8888)
val baos = ByteArrayOutputStream()
bmp.compress(Bitmap.CompressFormat.JPEG, 80, baos)
val b64 = Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP)
imageElements.add(ImageElement(b64, dimens[0], dimens[1], topY))
bmp.recycle()
} catch (_: Exception) {
Timber.tag(TAG).w("Failed to process image $i on page $pageIdx")
}
}
}
}
}
if (charCount <= 0) {
return@use if (imageElements.isNotEmpty()) {
buildPageHtml(pageNumber, imageElements.sortedByDescending { it.yPos }, headerFooterStrings)
} else buildEmptyPageSection(pageNumber)
}
val rawText = textPage.textPageGetText(0, charCount) ?: ""
val actualCount = minOf(charCount, rawText.length)
val sizes: FloatArray?
val weights: IntArray?
val flags: IntArray?
val charBoxes: FloatArray?
synchronized(PdfiumCore.lock) {
sizes = NativePdfiumBridge.getPageFontSizes(textPagePtr, actualCount)
weights = NativePdfiumBridge.getPageFontWeights(textPagePtr, actualCount)
flags = NativePdfiumBridge.getPageFontFlags(textPagePtr, actualCount)
charBoxes = NativePdfiumBridge.getPageCharBoxes(textPagePtr, actualCount)
}
if (sizes == null || weights == null || flags == null) {
return@use buildFallbackPageSection(pageNumber, rawText)
}
val textLines = mutableListOf<TextLine>()
val currentSpans = mutableListOf<TextSpan>()
val currentSpanBuf = StringBuilder()
var curSize = -1f
var curBold = false
var curItalic = false
var lineBaseline = 0f
fun commitSpan() {
if (currentSpanBuf.isNotEmpty()) {
currentSpans.add(TextSpan(currentSpanBuf.toString(), curSize, curBold, curItalic))
currentSpanBuf.clear()
}
}
fun commitLine() {
commitSpan()
if (currentSpans.isNotEmpty()) {
val text = currentSpans.joinToString("") { it.text }
if (text.isNotBlank()) {
textLines.add(TextLine(currentSpans.toList(), lineBaseline, text.length))
}
currentSpans.clear()
}
lineBaseline = 0f
}
for (i in 0 until actualCount) {
val c = rawText[i]
val code = c.code
if (code == 0 || code == 13) continue
if (c == '\n') {
commitLine()
continue
}
val charToProcess = when (c) {
'\u00A0' -> ' '
'\u00AD' -> '-'
'\u0009' -> ' '
else -> c
}
val type = Character.getType(c).toByte()
val isJunk = when {
code == 0xFFFE || code == 0xFFFF -> true
code == 0xFFFD -> true
type == Character.PRIVATE_USE -> true
type == Character.SURROGATE -> true
type == Character.UNASSIGNED -> true
(type == Character.CONTROL && code > 31) -> true
else -> false
}
if (isJunk) {
val prefix = rawText.substring(maxOf(0, i - 2), i).replace("\n", "\\n")
val suffix = rawText.substring(minOf(actualCount, i + 1), minOf(actualCount, i + 3)).replace("\n", "\\n")
Timber.tag("PdfToHtml").w("Filtered Junk: 0x${Integer.toHexString(code).uppercase()} at pg $pageIdx. Context: '$prefix[$c]$suffix'")
continue
}
val size = sizes[i].coerceAtLeast(0f)
val isBold = weights[i] > 600
val isItalic = (flags[i] and 64) != 0
if (currentSpanBuf.isEmpty() && currentSpans.isEmpty() && !charToProcess.isWhitespace()) {
lineBaseline = if (charBoxes != null && i * 4 + 1 < charBoxes.size) charBoxes[i * 4 + 1] else 0f
}
if (currentSpanBuf.isEmpty()) {
curSize = size; curBold = isBold; curItalic = isItalic
currentSpanBuf.append(charToProcess)
} else if (!charToProcess.isWhitespace() && (size != curSize || isBold != curBold || isItalic != curItalic)) {
commitSpan()
curSize = size; curBold = isBold; curItalic = isItalic
currentSpanBuf.append(charToProcess)
} else {
currentSpanBuf.append(charToProcess)
}
}
commitLine()
// 3. MERGE TEXT AND IMAGES VERTICALLY
val finalElements = mutableListOf<PageElement>()
var imgIdx = 0
val sortedImages = imageElements.sortedByDescending { it.yPos }
for (line in textLines) {
// Place images physically positioned above this text line
while (imgIdx < sortedImages.size && sortedImages[imgIdx].yPos >= line.yPos) {
finalElements.add(sortedImages[imgIdx])
imgIdx++
}
finalElements.add(TextElement(line, line.yPos))
}
// Place any remaining images at the bottom of the page
while (imgIdx < sortedImages.size) {
finalElements.add(sortedImages[imgIdx])
imgIdx++
}
buildPageHtml(pageNumber, finalElements, headerFooterStrings)
}
}
} catch (e: Exception) {
Timber.tag(TAG).w(e, "Error extracting page $pageIdx")
buildEmptyPageSection(pageNumber)
}
}
private fun buildEmptyPageSection(pageNumber: Int) =
"<section class=\"page-section\">\n" +
"<p class=\"page-marker\">— Page $pageNumber —</p>\n" +
"<p><em>(No text on this page)</em></p>\n</section>\n"
private fun buildFallbackPageSection(pageNumber: Int, rawText: String) =
"<section class=\"page-section\">\n" +
"<p class=\"page-marker\">— Page $pageNumber —</p>\n" +
"<p>${rawText.escapeHtml()}</p>\n</section>\n"
private fun buildPageHtml(
pageNumber: Int,
elements: List<PageElement>,
headerFooterStrings: Set<String>
): String {
val textElements = elements.filterIsInstance<TextElement>()
val sizeFreq = HashMap<Int, Int>()
textElements.forEach { te ->
te.line.spans.forEach { span ->
val s = span.size.roundToInt().coerceAtLeast(1)
sizeFreq[s] = (sizeFreq[s] ?: 0) + span.text.length
}
}
val baseSize = sizeFreq.maxByOrNull { it.value }?.key?.toFloat() ?: 12f
val lineLengths = textElements.filter { it.line.charCount > 10 }.map { it.line.charCount }.sorted()
val typicalLineLen = if (lineLengths.isNotEmpty())
lineLengths[(lineLengths.size * 0.80).toInt().coerceAtMost(lineLengths.size - 1)]
else 80
val wrapThreshold = (typicalLineLen * 0.80).toInt()
val sb = StringBuilder()
sb.append("<section class=\"page-section\">\n")
sb.append("<p class=\"page-marker\">— Page $pageNumber —</p>\n")
var inParagraph = false
var inUl = false
var inOl = false
fun closeParagraph() { if (inParagraph) { sb.append("</p>\n"); inParagraph = false } }
fun closeList() {
if (inUl) { sb.append("</ul>\n"); inUl = false }
if (inOl) { sb.append("</ol>\n"); inOl = false }
}
for ((index, element) in elements.withIndex()) {
when (element) {
is ImageElement -> {
closeParagraph()
closeList()
sb.append("<div style=\"text-align:center; margin: 1.5em 0;\">\n")
sb.append("<img src=\"data:image/jpeg;base64,${element.base64Data}\" style=\"max-width:100%; height:auto; border-radius: 6px;\"/>\n")
sb.append("</div>\n")
}
is TextElement -> {
val line = element.line
val lineText = line.spans.joinToString("") { it.text }
val trimmed = lineText.trim()
if (trimmed.isEmpty() || headerFooterStrings.any { hf -> trimmed.equals(hf, ignoreCase = true) }) {
closeParagraph()
continue
}
val maxSize = line.spans.filter { it.text.isNotBlank() }.maxOfOrNull { it.size } ?: baseSize
val headingLevel = when {
maxSize > baseSize * 1.6f -> 1
maxSize > baseSize * 1.28f -> 2
maxSize > baseSize * 1.10f -> 3
maxSize > baseSize * 1.04f -> 4
else -> 0
}
val lineLen = trimmed.length
val isShort = lineLen < 60
val isAllCaps = isShort && lineLen >= 3 && trimmed.any { it.isLetter() } && trimmed.all { it.isUpperCase() || !it.isLetter() } && !trimmed.endsWith(".")
val isBullet = trimmed.startsWith("") || trimmed.startsWith("") || trimmed.startsWith("") || trimmed.startsWith("") || (trimmed.startsWith("- ") && trimmed.length > 2 && !trimmed.startsWith("--"))
val numberedMatch = Regex("""^(\d{1,3}[.)]\s|\p{L}[.)]\s)""").containsMatchIn(trimmed)
val isHr = isShort && trimmed.length >= 3 && trimmed.all { it == '-' || it == '=' || it == '_' || it == '—' || it.isWhitespace() }
val effectiveHeading = when {
headingLevel > 0 -> headingLevel
isAllCaps && !isBullet && !numberedMatch -> 2
else -> 0
}
val nextTextElem = elements.drop(index + 1).firstOrNull { it is TextElement && it.line.spans.joinToString(""){ s->s.text}.isNotBlank() } as? TextElement
val shouldBreakParagraph = effectiveHeading > 0 || isBullet || numberedMatch || isHr ||
lineLen < wrapThreshold ||
trimmed.last().let { it == '.' || it == '!' || it == '?' || it == ':' || it == '"' || it == '\u201d' } ||
(nextTextElem != null && nextTextElem.line.spans.joinToString("") { it.text }.trimStart().let { it.startsWith("\u201c") || it.startsWith("\"") || it.startsWith("-") })
when {
isHr -> {
closeParagraph(); closeList()
sb.append("<hr>\n")
}
effectiveHeading > 0 -> {
closeParagraph(); closeList()
val tag = "h${effectiveHeading.coerceIn(1, 4)}"
sb.append("<$tag>${renderSpans(line.spans, insideHeading = true)}</$tag>\n")
}
isBullet -> {
closeParagraph()
if (inOl) { sb.append("</ol>\n"); inOl = false }
if (!inUl) { sb.append("<ul>\n"); inUl = true }
val content = trimmed.removePrefix("").removePrefix("").removePrefix("").removePrefix("").removePrefix("- ").trim()
sb.append("<li>${content.escapeHtml()}</li>\n")
}
numberedMatch -> {
closeParagraph()
if (inUl) { sb.append("</ul>\n"); inUl = false }
if (!inOl) { sb.append("<ol>\n"); inOl = true }
val content = trimmed.substringAfter(" ").trim()
sb.append("<li>${content.escapeHtml()}</li>\n")
}
shouldBreakParagraph -> {
closeList()
if (!inParagraph) { sb.append("<p>"); inParagraph = true }
sb.append(renderSpans(line.spans))
closeParagraph()
}
else -> {
closeList()
if (!inParagraph) { sb.append("<p>"); inParagraph = true } else sb.append(" ")
sb.append(renderSpans(line.spans))
}
}
}
}
}
closeParagraph()
closeList()
sb.append("</section>\n")
return sb.toString()
}
private fun renderSpans(spans: List<TextSpan>, insideHeading: Boolean = false): String {
val sb = StringBuilder()
for (span in spans) {
val s = span.text.escapeHtml()
if (s.isBlank()) { sb.append(s); continue }
val leadCount = s.length - s.trimStart().length
val trailCount = s.length - s.trimEnd().length
val pre = s.take(leadCount)
val post = if (trailCount > 0) s.takeLast(trailCount) else ""
val mid = s.substring(leadCount, s.length - trailCount)
if (mid.isEmpty()) { sb.append(s); continue }
sb.append(pre)
if (!insideHeading) {
if (span.isBold && span.isItalic) sb.append("<strong><em>")
else if (span.isBold) sb.append("<strong>")
else if (span.isItalic) sb.append("<em>")
}
sb.append(mid)
if (!insideHeading) {
if (span.isBold && span.isItalic) sb.append("</em></strong>")
else if (span.isBold) sb.append("</strong>")
else if (span.isItalic) sb.append("</em>")
}
sb.append(post)
}
return sb.toString()
}
private suspend fun detectRepeatingHeaderFooter(
doc: PdfDocumentKt,
totalPages: Int
): Set<String> = withContext(Dispatchers.Default) {
if (totalPages < 5) return@withContext emptySet()
val step = maxOf(1, totalPages / 8)
val samplePages = (0 until totalPages).filter { it % step == 0 }.take(8)
val frequency = HashMap<String, Int>()
for (pageIdx in samplePages) {
try {
doc.openPage(pageIdx).use { page ->
page.openTextPage().use { textPage ->
val charCount = textPage.textPageCountChars()
if (charCount <= 0) return@use
val rawText = textPage.textPageGetText(0, charCount) ?: return@use
val lines = rawText.split('\n').map { it.trim() }.filter { it.length > 2 }
if (lines.isNotEmpty()) {
val edgeLines = lines.take(2) + lines.takeLast(2)
for (line in edgeLines) {
frequency[line] = (frequency[line] ?: 0) + 1
}
}
}
}
} catch (e: Exception) {
Timber.tag(TAG).w(e, "Header/footer sampling failed for page $pageIdx")
}
}
frequency.filter { it.value >= 3 }.keys.toSet()
}
private fun String.escapeHtml(): String = this
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;")
}

View file

@ -1,273 +0,0 @@
// PdfToMarkdownGenerator.kt
package com.aryan.reader.pdf
import android.content.Context
import android.net.Uri
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
import java.io.File
import kotlin.math.roundToInt
object PdfToMarkdownGenerator {
const val PAGE_DELIMITER = "\n\n[[PAGE_BREAK]]\n\n"
suspend fun generateMarkdownFile(
context: Context,
pdfUri: Uri,
destFile: File,
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 {
val doc = pdfiumCore.newDocument(pfd)
val totalPages = doc.getPageCount()
Timber.tag("PdfToMdPerf").d("Document loaded natively. Total Pages: $totalPages")
destFile.bufferedWriter().use { writer ->
for (pageIdx in (startPage - 1) until totalPages) {
val pageMd = extractPageMarkdown(doc, pageIdx)
writer.write(pageMd)
writer.write(PAGE_DELIMITER)
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 natively")
pfd.close()
return@withContext false
}
}
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 ""
val text = textPage.textPageGetText(0, charCount) ?: ""
val actualCount = minOf(charCount, text.length)
val rawPtr = textPage.page.pagePtr
val sizes: FloatArray?
val weights: IntArray?
val flags: IntArray?
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.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

@ -18,7 +18,9 @@
* mail: epistemereader@gmail.com
*/
// PdfViewerScreen.kt
@file:Suppress("COMPOSE_APPLIER_CALL_MISMATCH", "Unused", "UnusedVariable")
@file:Suppress("COMPOSE_APPLIER_CALL_MISMATCH", "Unused", "UnusedVariable",
"SimplifyBooleanWithConstants"
)
package com.aryan.reader.pdf
@ -27,6 +29,11 @@ import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.pm.PackageManager
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.compose.ui.platform.LocalLifecycleOwner
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import android.graphics.Bitmap
import android.graphics.RectF
import android.net.Uri
@ -221,6 +228,7 @@ import androidx.core.graphics.createBitmap
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.media3.common.util.UnstableApi
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
@ -740,9 +748,9 @@ fun PdfViewerScreen(
val uiState by viewModel.uiState.collectAsState()
val reflowBookId = remember(bookId) { "${bookId}_reflow" }
val hasReflowFile by remember(uiState.recentFiles, reflowBookId) {
val hasReflowFile by remember(uiState.allRecentFiles, reflowBookId) {
derivedStateOf {
uiState.recentFiles.any { it.bookId == reflowBookId && !it.isDeleted }
uiState.allRecentFiles.any { it.bookId == reflowBookId && !it.isDeleted }
}
}
val originalFileName by remember(uiState.recentFiles, pdfUri) {
@ -1029,18 +1037,155 @@ fun PdfViewerScreen(
var areAnnotationsLoaded by remember { mutableStateOf(false) }
LaunchedEffect(allAnnotations) {
if (areAnnotationsLoaded && currentBookId != null) {
delay(1000)
withContext(Dispatchers.IO) {
Timber.d("Auto-saving annotations locally for book $currentBookId")
annotationRepository.saveAnnotations(currentBookId!!, allAnnotations)
val richTextRepository = remember(context) { PdfRichTextRepository(context) }
val richTextController = remember(currentBookId) {
if (currentBookId != null) RichTextController(
richTextRepository,
coroutineScope,
currentBookId!!
)
else null
}
var pdfDocument by remember { mutableStateOf<PdfDocumentKt?>(null) }
var pfdState by remember { mutableStateOf<ParcelFileDescriptor?>(null) }
var totalPages by remember { mutableIntStateOf(0) }
var currentPageScale by remember { mutableFloatStateOf(1f) }
val textBoxes = remember { mutableStateListOf<PdfTextBox>() }
var selectedTextBoxId by remember { mutableStateOf<String?>(null) }
val userHighlights = remember { mutableStateListOf<PdfUserHighlight>() }
val drawingState = remember { PdfDrawingState() }
val pdfiumCore = remember(context) { PdfiumCoreKt(Dispatchers.Default) }
val verticalReaderState = rememberVerticalPdfReaderState()
var virtualPages by remember { mutableStateOf<List<VirtualPage>>(emptyList()) }
val totalDisplayPages by remember(virtualPages, totalPages) {
derivedStateOf { if (virtualPages.isNotEmpty()) virtualPages.size else totalPages }
}
val pagerState = rememberPagerState(initialPage = 0, pageCount = { totalDisplayPages })
val currentPage by remember {
derivedStateOf {
when (displayMode) {
DisplayMode.PAGINATION -> pagerState.currentPage
DisplayMode.VERTICAL_SCROLL -> verticalReaderState.currentPage
}
}
}
val drawingState = remember { PdfDrawingState() }
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
val saveMutex = remember { Mutex() }
var initialScrollDone by remember { mutableStateOf(false) }
var isDocumentReady by remember { mutableStateOf(false) }
val lastSavedHashes = remember(currentBookId) { IntArray(5) { 0 } }
val currentAnnotations by rememberUpdatedState(allAnnotations)
val currentTextBoxes by rememberUpdatedState(textBoxes.toList())
val currentHighlights by rememberUpdatedState(userHighlights.toList())
val currentBookmarks by rememberUpdatedState(bookmarks)
val currentTotalPages by rememberUpdatedState(totalDisplayPages)
val currentPageState by rememberUpdatedState(currentPage)
val saveAllData = remember(currentBookId, annotationRepository, textBoxRepository, highlightRepository) {
{ force: Boolean ->
coroutineScope.launch {
val bookId = currentBookId ?: return@launch
val annots = currentAnnotations
val boxes = currentTextBoxes
val highlights = currentHighlights
val bms = currentBookmarks
val page = currentPageState
val totalPgs = currentTotalPages
val annotsHash = annots.hashCode()
val boxesHash = boxes.hashCode()
val highlightsHash = highlights.hashCode()
val bmsHash = bms.hashCode()
// Protect the lock and I/O execution with NonCancellable
withContext(NonCancellable) {
saveMutex.withLock {
withContext(Dispatchers.IO) {
var didSave = false
if (force || annotsHash != lastSavedHashes[0]) {
annotationRepository.saveAnnotations(bookId, annots)
lastSavedHashes[0] = annotsHash
didSave = true
}
if (force || boxesHash != lastSavedHashes[1]) {
textBoxRepository.saveTextBoxes(bookId, boxes)
lastSavedHashes[1] = boxesHash
didSave = true
}
if (force || highlightsHash != lastSavedHashes[2]) {
highlightRepository.saveHighlights(bookId, highlights)
lastSavedHashes[2] = highlightsHash
didSave = true
}
if (force || bmsHash != lastSavedHashes[3]) {
val objectList = bms.map { bookmark ->
JSONObject().apply {
put("pageIndex", bookmark.pageIndex)
put("title", bookmark.title)
put("totalPages", bookmark.totalPages)
}
}
val bookmarksJson = JSONArray(objectList).toString()
withContext(Dispatchers.Main) {
onBookmarksChanged(bookmarksJson)
}
lastSavedHashes[3] = bmsHash
didSave = true
}
if (force || page != lastSavedHashes[4]) {
if (totalPgs > 0) {
withContext(Dispatchers.Main) {
onSavePosition(page, totalPgs)
}
}
lastSavedHashes[4] = page
}
if (didSave) {
Timber.tag("PdfSavePerf").d("Saved data for book $bookId")
}
}
}
}
}
}
}
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_PAUSE || event == Lifecycle.Event.ON_STOP) {
Timber.tag("PdfSavePerf").i("Lifecycle $event triggered, forcing save.")
coroutineScope.launch {
if (richTextController != null) {
withContext(NonCancellable) { richTextController.saveImmediate() }
}
saveAllData(true).join()
}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
LaunchedEffect(
allAnnotations,
textBoxes.toList(),
userHighlights.toList(),
bookmarks,
currentPage
) {
if (areAnnotationsLoaded && currentBookId != null && initialScrollDone) {
delay(2000) // Debounce period
saveAllData(false)
}
}
val allAnnotationsProvider = remember { { allAnnotations } }
@ -1049,19 +1194,6 @@ fun PdfViewerScreen(
Timber.d("PdfViewerScreen init: Loaded ${bookmarks.size} bookmarks initially.")
}
LaunchedEffect(bookmarks) {
val objectList = bookmarks.map { bookmark ->
JSONObject().apply {
put("pageIndex", bookmark.pageIndex)
put("title", bookmark.title)
put("totalPages", bookmark.totalPages)
}
}
val bookmarksJson = JSONArray(objectList).toString()
Timber.d("Bookmarks changed. Firing onBookmarksChanged with JSON: $bookmarksJson")
onBookmarksChanged(bookmarksJson)
}
var flatTableOfContents by remember { mutableStateOf<List<TocEntry>>(emptyList()) }
var showDictionaryUpsellDialog by remember { mutableStateOf(false) }
var showSummarizationUpsellDialog by remember { mutableStateOf(false) }
@ -1080,17 +1212,8 @@ fun PdfViewerScreen(
var errorMessage by remember { mutableStateOf<String?>(null) }
var isLoadingDocument by remember { mutableStateOf(true) }
var pdfDocument by remember { mutableStateOf<PdfDocumentKt?>(null) }
var pfdState by remember { mutableStateOf<ParcelFileDescriptor?>(null) }
var totalPages by remember { mutableIntStateOf(0) }
var currentPageScale by remember { mutableFloatStateOf(1f) }
var initialScrollDone by remember { mutableStateOf(false) }
var isDocumentReady by remember { mutableStateOf(false) }
var selectionClearTrigger by remember { mutableLongStateOf(0L) }
var virtualPages by remember { mutableStateOf<List<VirtualPage>>(emptyList()) }
val displayPageRatios by remember(pageAspectRatios, virtualPages) {
derivedStateOf {
if (virtualPages.isEmpty()) {
@ -1108,16 +1231,6 @@ fun PdfViewerScreen(
}
}
val richTextRepository = remember(context) { PdfRichTextRepository(context) }
val richTextController = remember(currentBookId) {
if (currentBookId != null) RichTextController(
richTextRepository,
coroutineScope,
currentBookId!!
)
else null
}
LaunchedEffect(richTextController, toolSettings.textStyle) {
richTextController?.let { controller ->
val config = toolSettings.textStyle
@ -1142,13 +1255,6 @@ fun PdfViewerScreen(
}
}
val pdfiumCore = remember(context) { PdfiumCoreKt(Dispatchers.Default) }
val verticalReaderState = rememberVerticalPdfReaderState()
val totalDisplayPages by remember(virtualPages, totalPages) {
derivedStateOf { if (virtualPages.isNotEmpty()) virtualPages.size else totalPages }
}
val pagerState = rememberPagerState(initialPage = 0, pageCount = { totalDisplayPages })
LaunchedEffect(currentBookId) {
if (currentBookId != null) richTextRepository.load(currentBookId!!)
}
@ -1170,21 +1276,8 @@ fun PdfViewerScreen(
}
}
val currentPage by remember {
derivedStateOf {
when (displayMode) {
DisplayMode.PAGINATION -> pagerState.currentPage
DisplayMode.VERTICAL_SCROLL -> verticalReaderState.currentPage
}
}
}
Timber.d("Derived currentPage recomposed. New value: $currentPage (Mode: $displayMode)")
val textBoxes = remember { mutableStateListOf<PdfTextBox>() }
var selectedTextBoxId by remember { mutableStateOf<String?>(null) }
val userHighlights = remember { mutableStateListOf<PdfUserHighlight>() }
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)}...")
@ -1695,16 +1788,6 @@ fun PdfViewerScreen(
Timber.d("Pager state changed: pagerState.currentPage is now ${pagerState.currentPage}")
}
LaunchedEffect(currentPage, totalPages) {
if (totalPages > 0 && initialScrollDone) {
delay(500L)
Timber.d(
"Debounced save: Calling onSavePosition(page=$currentPage, totalPages=$totalPages)"
)
onSavePosition(currentPage, totalPages)
}
}
LaunchedEffect(displayMode) {
coroutineScope.launch {
if (displayMode == DisplayMode.VERTICAL_SCROLL) {
@ -1861,26 +1944,6 @@ fun PdfViewerScreen(
}
}
LaunchedEffect(textBoxes.toList()) {
if (currentBookId != null) {
delay(1000)
withContext(Dispatchers.IO) {
Timber.d("Auto-saving text boxes locally for book $currentBookId")
textBoxRepository.saveTextBoxes(currentBookId!!, textBoxes.toList())
}
}
}
LaunchedEffect(userHighlights.toList()) {
if (currentBookId != null) {
delay(1000)
withContext(Dispatchers.IO) {
Timber.d("Auto-saving highlights locally for book $currentBookId")
highlightRepository.saveHighlights(currentBookId!!, userHighlights.toList())
}
}
}
var pendingSaveMode by remember { mutableStateOf<SaveMode?>(null) }
val saveLauncher = rememberLauncherForActivityResult(
@ -1970,33 +2033,15 @@ fun PdfViewerScreen(
ttsController.stop()
coroutineScope.launch {
withContext(NonCancellable) {
if (richTextController != null) {
if (richTextController != null) {
withContext(NonCancellable) {
Timber.tag("RichTextFlow").d("Forcing RichTextController immediate sync and save...")
richTextController.saveImmediate()
}
if (totalDisplayPages > 0) {
onSavePosition(currentPage, totalDisplayPages)
if (currentBookId != null) {
annotationRepository.saveAnnotations(currentBookId!!, allAnnotations)
textBoxRepository.saveTextBoxes(currentBookId!!, textBoxes.toList())
highlightRepository.saveHighlights(currentBookId!!, userHighlights.toList())
}
val objectList = bookmarks.map { bookmark ->
JSONObject().apply {
put("pageIndex", bookmark.pageIndex)
put("title", bookmark.title)
put("totalPages", bookmark.totalPages)
}
}
val bookmarksJson = JSONArray(objectList).toString()
onBookmarksChanged(bookmarksJson)
}
}
saveAllData(true).join()
Timber.tag("AnnotationSync").d("Save complete. Navigating back.")
onNavigateBack()
}
@ -4954,18 +4999,33 @@ fun PdfViewerScreen(
enabled = pdfDocument != null && !isReflowingThisBook,
onClick = {
showMoreMenu = false
if (hasReflowFile) {
val item = uiState.recentFiles.find { it.bookId == reflowBookId }
if (item != null) {
viewModel.switchToFileSeamlessly(item, currentPage)
coroutineScope.launch {
if (richTextController != null) {
withContext(NonCancellable) { richTextController.saveImmediate() }
}
saveAllData(true).join()
if (hasReflowFile) {
val item = uiState.allRecentFiles.find { it.bookId == reflowBookId }
if (item != null) {
viewModel.switchToFileSeamlessly(item, currentPage)
} else {
viewModel.generateAndImportReflowFile(
pdfBookId = bookId,
pdfUri = pdfUri,
originalTitle = originalFileName,
autoOpenPage = currentPage
)
}
} else {
viewModel.generateAndImportReflowFile(
pdfBookId = bookId,
pdfUri = pdfUri,
originalTitle = originalFileName,
autoOpenPage = currentPage
)
}
} else {
viewModel.generateAndImportReflowFile(
pdfBookId = bookId,
pdfUri = pdfUri,
originalTitle = originalFileName,
autoOpenPage = currentPage
)
}
},
leadingIcon = {

View file

@ -21,86 +21,83 @@ class ReflowWorker(
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
val workStartTime = System.currentTimeMillis()
Timber.tag("PdfToMdPerf").d("=== ReflowWorker START ===")
Timber.tag("PdfToHtmlPerf").d("=== ReflowWorker START ===")
val bookId = inputData.getString(KEY_BOOK_ID) ?: run {
Timber.tag("PdfToMdPerf").e("FAILURE: KEY_BOOK_ID is null")
Timber.tag("PdfToHtmlPerf").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")
Timber.tag("PdfToHtmlPerf").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")
Timber.tag("PdfToHtmlPerf").d(
"Input | bookId=$bookId | reflowBookId=$reflowBookId | pdfUri=$pdfUriString | title=$originalTitle"
)
val destFile = File(applicationContext.filesDir, "${bookId}_reflow.md")
val destFile = File(applicationContext.filesDir, "${bookId}_reflow.html")
val pdfUri = pdfUriString.toUri()
Timber.tag("PdfToMdPerf").d("Dest file path: ${destFile.absolutePath} | exists=${destFile.exists()}")
Timber.tag("PdfToMdPerf").d("Starting PdfToMarkdownGenerator.generateMarkdownFile...")
val genStartTime = System.currentTimeMillis()
Timber.tag("PdfToHtmlPerf").d("Dest: ${destFile.absolutePath} | exists=${destFile.exists()}")
val success = PdfToMarkdownGenerator.generateMarkdownFile(
applicationContext,
pdfUri,
destFile,
val genStartTime = System.currentTimeMillis()
val success = PdfToHtmlGenerator.generateHtmlFile(
context = applicationContext,
pdfUri = pdfUri,
destFile = destFile,
startPage = 1
) { 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")
Timber.tag("PdfToHtmlPerf").d(
"generateHtmlFile done | success=$success | ${System.currentTimeMillis() - genStartTime}ms"
)
if (success && destFile.exists()) {
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()
Timber.tag("PdfToHtmlPerf").d("Output size: ${fileSizeKB}KB")
val repo = RecentFilesRepository(applicationContext)
val newItem = RecentFileItem(
bookId = reflowBookId,
uriString = destFile.toUri().toString(),
type = FileType.MD,
displayName = "$originalTitle (Text View)",
timestamp = System.currentTimeMillis(),
coverImagePath = null,
title = "$originalTitle (Reflow)",
author = "Generated",
isAvailable = true,
isRecent = true,
bookId = reflowBookId,
uriString = destFile.toUri().toString(),
type = FileType.HTML,
displayName = "$originalTitle (Text View)",
timestamp = System.currentTimeMillis(),
coverImagePath = null,
title = "$originalTitle (Reflow)",
author = "Generated",
isAvailable = true,
isRecent = true,
lastModifiedTimestamp = System.currentTimeMillis(),
isDeleted = false,
sourceFolderUri = null
isDeleted = false,
sourceFolderUri = null
)
repo.addRecentFile(newItem)
Timber.tag("PdfToMdPerf").d("Database import completed in ${System.currentTimeMillis() - dbStartTime}ms")
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")
Timber.tag("PdfToHtmlPerf").d("=== ReflowWorker SUCCESS === | ${totalTime}ms")
return@withContext Result.success()
} else {
val totalTime = System.currentTimeMillis() - workStartTime
Timber.tag("PdfToMdPerf").e("=== ReflowWorker FAILURE === | success=$success | fileExists=${destFile.exists()} | totalTime=${totalTime}ms")
Timber.tag("PdfToHtmlPerf").e(
"=== ReflowWorker FAILURE === | success=$success | fileExists=${destFile.exists()} | ${totalTime}ms"
)
return@withContext Result.failure()
}
}
companion object {
const val WORK_NAME = "reflow_work"
const val KEY_BOOK_ID = "book_id"
const val KEY_PDF_URI = "pdf_uri"
const val WORK_NAME = "reflow_work"
const val KEY_BOOK_ID = "book_id"
const val KEY_PDF_URI = "pdf_uri"
const val KEY_ORIGINAL_TITLE = "original_title"
const val KEY_PROGRESS = "progress"
}