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:
parent
9a95b4afcd
commit
843a77d0ef
13 changed files with 1270 additions and 895 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -196,8 +196,8 @@ internal fun PdfSelectionMenuPopup(
|
|||
popupPositionProvider = popupPositionProvider,
|
||||
onDismissRequest = onDismiss,
|
||||
properties = PopupProperties(
|
||||
focusable = true,
|
||||
dismissOnClickOutside = true,
|
||||
focusable = false,
|
||||
dismissOnClickOutside = false,
|
||||
dismissOnBackPress = true
|
||||
)
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
514
app/src/main/java/com/aryan/reader/pdf/PdfToHtmlGenerator.kt
Normal file
514
app/src/main/java/com/aryan/reader/pdf/PdfToHtmlGenerator.kt
Normal 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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'")
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue