Initial commit

This commit is contained in:
Aryan 2026-02-24 17:37:40 +05:30
commit 6072b2ba29
844 changed files with 220532 additions and 0 deletions

View file

@ -0,0 +1,275 @@
// AnnotationDock.kt
package com.aryan.reader.pdf
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Redo
import androidx.compose.material.icons.automirrored.filled.Undo
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.aryan.reader.R
@Composable
fun AnnotationDock(
selectedTool: InkType,
activePenColor: Color,
activeHighlighterColor: Color,
onToolClick: (InkType) -> Unit,
onUndo: () -> Unit,
onRedo: () -> Unit,
onClose: () -> Unit,
canUndo: Boolean,
canRedo: Boolean,
lastPenTool: InkType,
modifier: Modifier = Modifier,
isSticky: Boolean = false,
isMinimized: Boolean,
onToggleMinimize: () -> Unit
) {
val showFullDock = isSticky || !isMinimized
val dockHeight = 56.dp
val buttonSize = 36.dp
val iconSize = 20.dp
val spacing = 8.dp
val horizontalPadding = 12.dp
if (showFullDock) {
val shape = if (isSticky) RectangleShape else RoundedCornerShape(percent = 50)
Surface(
color = Color(0xFF1E1E1E),
shape = shape,
shadowElevation = if (isSticky) 0.dp else 8.dp,
modifier = modifier.height(dockHeight)
) {
Row(
modifier = Modifier.padding(horizontal = horizontalPadding),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(spacing)
) {
// Close Button
Box(
modifier = Modifier
.size(buttonSize)
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.1f))
.clickable(onClick = onClose),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Close Edit Mode",
tint = Color.White,
modifier = Modifier.size(iconSize)
)
}
val visIcon = if (isMinimized) Icons.Default.VisibilityOff else Icons.Default.Visibility
val visTint = Color.White
Box(
modifier = Modifier
.size(buttonSize)
.clip(CircleShape)
.clickable(onClick = onToggleMinimize),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = visIcon,
contentDescription = "Toggle Visibility",
tint = visTint,
modifier = Modifier.size(iconSize)
)
}
// Vertical Divider
Box(
modifier = Modifier
.height(20.dp)
.width(1.dp)
.background(Color.White.copy(alpha = 0.2f))
)
val toolsAlpha = if (isMinimized) 0.3f else 1f
Row(
horizontalArrangement = Arrangement.spacedBy(spacing),
modifier = Modifier.alpha(toolsAlpha)
) {
// Pen Group
val isPenActive = !isMinimized && (selectedTool == InkType.PEN ||
selectedTool == InkType.FOUNTAIN_PEN ||
selectedTool == InkType.PENCIL)
DockIcon(
iconRes = R.drawable.pen,
isActive = isPenActive,
tintColor = if(isMinimized) Color.Gray else activePenColor,
description = "Pen",
size = buttonSize,
iconSize = iconSize,
onClick = { if(!isMinimized) onToolClick(lastPenTool) }
)
// Highlighter
val isHighlighterActive = !isMinimized && (selectedTool == InkType.HIGHLIGHTER || selectedTool == InkType.HIGHLIGHTER_ROUND)
DockIcon(
iconRes = R.drawable.marker,
isActive = isHighlighterActive,
tintColor = if(isMinimized) Color.Gray else activeHighlighterColor.copy(alpha = 1f),
description = "Highlighter",
size = buttonSize,
iconSize = iconSize,
onClick = {
if (!isMinimized) {
if (selectedTool != InkType.HIGHLIGHTER && selectedTool != InkType.HIGHLIGHTER_ROUND) {
onToolClick(InkType.HIGHLIGHTER)
} else {
onToolClick(selectedTool)
}
}
}
)
// Text Annotation
DockIcon(
iconRes = R.drawable.keyboard,
isActive = !isMinimized && selectedTool == InkType.TEXT,
tintColor = if(isMinimized) Color.Gray else Color.White,
description = "Text",
size = buttonSize,
iconSize = iconSize,
onClick = { if(!isMinimized) onToolClick(InkType.TEXT) }
)
// Eraser
DockIcon(
iconRes = R.drawable.eraser,
isActive = !isMinimized && selectedTool == InkType.ERASER,
tintColor = if(isMinimized) Color.Gray else Color.White,
description = "Eraser",
size = buttonSize,
iconSize = iconSize,
onClick = { if(!isMinimized) onToolClick(InkType.ERASER) }
)
}
Spacer(modifier = Modifier.weight(1f))
// Undo
Box(
modifier = Modifier
.size(buttonSize)
.clip(CircleShape)
.clickable(enabled = canUndo && !isMinimized, onClick = onUndo),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Undo,
contentDescription = "Undo",
tint = if (canUndo && !isMinimized) Color.White else Color.White.copy(alpha = 0.3f),
modifier = Modifier.size(iconSize)
)
}
// Redo
Box(
modifier = Modifier
.size(buttonSize)
.clip(CircleShape)
.clickable(enabled = canRedo && !isMinimized, onClick = onRedo),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Redo,
contentDescription = "Redo",
tint = if (canRedo && !isMinimized) Color.White else Color.White.copy(alpha = 0.3f),
modifier = Modifier.size(iconSize)
)
}
}
}
} else {
// Minimized Floating State (Small Circle)
Surface(
color = Color(0xFF1E1E1E),
shape = CircleShape,
shadowElevation = 8.dp,
modifier = modifier.size(48.dp) // Reduced from 56dp
) {
Box(
modifier = Modifier.clickable(onClick = onToggleMinimize),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.VisibilityOff,
contentDescription = "Show Dock",
tint = Color.White,
modifier = Modifier.size(20.dp)
)
}
}
}
}
private fun Modifier.alpha(alpha: Float) = this.then(
Modifier.graphicsLayer { this.alpha = alpha }
)
@Composable
private fun DockIcon(
iconRes: Int,
isActive: Boolean,
tintColor: Color,
description: String,
size: androidx.compose.ui.unit.Dp,
iconSize: androidx.compose.ui.unit.Dp,
onClick: () -> Unit
) {
val backgroundAlpha = if (isActive) 0.15f else 0f
Box(
modifier = Modifier
.size(size)
.clip(CircleShape)
.background(Color.White.copy(alpha = backgroundAlpha))
.semantics { this.selected = isActive }
.testTag("DockItem_$description")
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
Icon(
painter = painterResource(id = iconRes),
contentDescription = description,
tint = tintColor,
modifier = Modifier.size(iconSize)
)
}
}

View file

@ -0,0 +1,204 @@
// DemoAnnotationGenerator.kt
package com.aryan.reader.pdf
import android.graphics.Path
import androidx.compose.ui.graphics.Color
import androidx.core.graphics.PathParser
import com.aryan.reader.pdf.data.PdfAnnotation
object DemoAnnotationGenerator {
// --- SVG Configuration ---
private const val SVG_WIDTH = 800f
// Extracted from your Figma SVG
private val DECORATIVE_DOTS = listOf(
DotData(120f, 90f, 5f, Color(0xFFF59E0B), 0.7f),
DotData(680f, 210f, 6f, Color(0xFFEC4899), 0.7f),
DotData(700f, 110f, 4f, Color(0xFF8B5CF6), 0.7f),
DotData(150f, 230f, 5f, Color(0xFF10B981), 0.6f),
DotData(650f, 80f, 4f, Color(0xFFF59E0B), 0.6f),
DotData(90f, 180f, 3f, Color(0xFFEC4899), 0.5f),
DotData(720f, 170f, 5f, Color(0xFF8B5CF6), 0.6f)
)
private val TEXT_STROKES_DATA = listOf(
// T
"M 80 115 L 140 115 M 110 115 L 110 175 Q 110 185 115 185",
// r
"M 150 145 L 150 180 M 150 155 Q 155 145 165 145 Q 172 145 175 150",
// y (Improvised: Smoother curves and proper descender loop)
"M 182 148 Q 188 165 195 175 M 208 148 Q 200 165 195 175 L 192 195 Q 188 215 175 212 Q 165 208 172 198",
// " E" (Space included in coordinates)
"M 240 110 L 240 180 M 240 110 L 285 110 M 240 145 L 275 145 M 240 180 L 285 180",
// p
"M 305 145 L 305 215 M 305 158 Q 305 145 320 145 Q 340 145 345 160 Q 348 170 345 180 Q 340 195 320 195 Q 305 195 305 182",
// i
"M 365 145 L 365 180 M 365 130 L 365 132",
// s
"M 428 148 Q 418 143 408 145 Q 398 147 395 155 Q 393 162 400 165 Q 410 170 420 168 Q 428 166 430 172 Q 432 180 422 183 Q 412 186 402 182",
// t
"M 445 125 L 445 175 Q 445 185 455 185 Q 465 185 470 180 M 435 145 L 460 145",
// e (Fixed: Standard cursive loop instead of inverted shape)
"M 495 165 L 522 165 Q 522 145 508 145 Q 488 145 492 170 Q 495 190 525 185",
// m
"M 545 145 L 545 180 M 545 155 Q 545 145 555 145 Q 565 145 565 155 L 565 180 M 565 155 Q 565 145 575 145 Q 585 145 585 155 L 585 180",
// e (Fixed: Shifted +110 relative to previous 'e')
"M 605 165 L 632 165 Q 632 145 618 145 Q 598 145 602 170 Q 605 190 635 185",
// !
"M 660 125 L 660 165 M 660 178 L 660 182"
)
private const val UNDERLINE_DATA = "M 180 200 Q 400 220 620 200"
fun generateDemoAnnotations(pageIndex: Int): List<PdfAnnotation> {
val annotations = mutableListOf<PdfAnnotation>()
// --- Layout Calculation ---
// We want the SVG to occupy 80% of the page width, centered.
// PDF coordinates are 0..1.
val targetWidthPercent = 0.8f
// SVG aspect ratio 300 / 800 = 0.375
// Calculate scale factor relative to normalized page coordinates
val scaleX = targetWidthPercent / SVG_WIDTH
val scaleY = scaleX // Keep uniform scale in abstract space
// Center offsets (0.5 is middle of page)
val startX = (1f - targetWidthPercent) / 2f
val startY = 0.4f // Position slightly above center vertically
var currentTime = System.currentTimeMillis()
// Helper to transform SVG points to PDF Page Points
fun transformPoint(x: Float, y: Float): PdfPoint {
val pdfX = startX + (x * scaleX)
val pdfY = startY + (y * scaleY)
return PdfPoint(pdfX, pdfY, currentTime)
}
// 1. Render Decorative Dots
DECORATIVE_DOTS.forEach { dot ->
val pdfPoint = transformPoint(dot.cx, dot.cy)
// To make a "Dot" with the pen, we need at least 2 points very close together
// or a single point might not render depending on the implementation.
val points = listOf(
pdfPoint,
pdfPoint.copy(x = pdfPoint.x + 0.0001f, timestamp = currentTime + 10)
)
// Convert SVG radius to stroke width
val relativeThickness = (dot.r / SVG_WIDTH) * 2.5f
annotations.add(
PdfAnnotation(
type = AnnotationType.INK,
inkType = InkType.PEN, // Standard pen for dots
pageIndex = pageIndex,
points = points,
color = dot.color.copy(alpha = dot.alpha),
strokeWidth = relativeThickness
)
)
currentTime += 50
}
// 2. Render Text ("Try Episteme!")
val textPaths = splitSvgPaths(TEXT_STROKES_DATA)
textPaths.forEach { pathString ->
val path = PathParser.createPathFromPathData(pathString)
val flattenedPoints = flattenPath(path)
if (flattenedPoints.isNotEmpty()) {
val pdfPoints = flattenedPoints.mapIndexed { _, p ->
// Increment time to simulate drawing speed for Fountain Pen physics
currentTime += 8
transformPoint(p.x, p.y).copy(timestamp = currentTime)
}
annotations.add(
PdfAnnotation(
type = AnnotationType.INK,
inkType = InkType.FOUNTAIN_PEN, // Handwriting looks best with this
pageIndex = pageIndex,
points = pdfPoints,
color = Color(0xFF418377), // Updated Green
strokeWidth = 0.004f // Fine tip
)
)
currentTime += 150 // Pen lift delay
}
}
// 3. Render Underline
val underlinePath = PathParser.createPathFromPathData(UNDERLINE_DATA)
val underlinePointsRaw = flattenPath(underlinePath)
val underlinePdfPoints = underlinePointsRaw.map { p ->
currentTime += 5
transformPoint(p.x, p.y).copy(timestamp = currentTime)
}
annotations.add(
PdfAnnotation(
type = AnnotationType.INK,
inkType = InkType.PEN, // Consistent width for underline
pageIndex = pageIndex,
points = underlinePdfPoints,
color = Color(0xFFEC4899).copy(alpha = 0.6f), // Pink
strokeWidth = 0.005f
)
)
return annotations
}
// --- Helpers ---
private data class DotData(val cx: Float, val cy: Float, val r: Float, val color: Color, val alpha: Float)
private data class PointF(val x: Float, val y: Float)
/**
* Android's Path doesn't give us points directly. We use approximate().
*/
private fun flattenPath(path: Path): List<PointF> {
// Approximate the path with error tolerance 0.5 (pixels in SVG space)
val approximation = path.approximate(0.5f)
val points = mutableListOf<PointF>()
// approximation array format: [t0, x0, y0, t1, x1, y1, ...]
var i = 0
while (i < approximation.size) {
val x = approximation[i + 1]
val y = approximation[i + 2]
points.add(PointF(x, y))
i += 3
}
return points
}
/**
* The SVG string might contain separate letters, but even within a letter
* (like 'i' or 't') there might be a Move (M) command.
* We must split by 'M' to ensure we don't draw connecting lines where the pen should lift.
*/
private fun splitSvgPaths(@Suppress("SameParameterValue") rawPaths: List<String>): List<String> {
val result = mutableListOf<String>()
rawPaths.forEach { fullPathString ->
// Clean up and standardize
val cleanStr = fullPathString.trim()
// Split by "M" (Move command).
val parts = cleanStr.split("M")
parts.forEach { part ->
if (part.isNotBlank()) {
// Re-prepend M because split removed it
result.add("M ${part.trim()}")
}
}
}
return result
}
}

View file

@ -0,0 +1,211 @@
// MagnifierComposable.kt
package com.aryan.reader.pdf
import android.graphics.Rect
import timber.log.Timber
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt
@Composable
fun MagnifierComposable(
sourceBitmap: ImageBitmap,
tiles: List<PdfTile>,
currentScale: Float,
magnifierCenterOnBitmap: Offset,
modifier: Modifier = Modifier,
magnifierWidth: Dp = 120.dp,
magnifierHeight: Dp = 60.dp,
zoomFactor: Float = 1.5f,
selectionRectsInBitmapCoords: List<Rect>,
highlightColor: Color,
colorFilter: ColorFilter? = null
) {
val stadiumShape = RoundedCornerShape(magnifierHeight / 2)
Box(
modifier = modifier
.width(magnifierWidth)
.height(magnifierHeight)
.shadow(4.dp, stadiumShape)
.clip(stadiumShape)
) {
Canvas(modifier = Modifier.fillMaxSize()) {
val magnifierWidthPx = size.width
val magnifierHeightPx = size.height
Timber.d("Magnifier: START. scale=$currentScale, centerOnBitmap=$magnifierCenterOnBitmap")
val relevantTile = if (currentScale > 1f) {
tiles.find {
it.renderRect.contains(magnifierCenterOnBitmap.x.toInt(), magnifierCenterOnBitmap.y.toInt())
}
} else null
if (relevantTile != null) {
// --- HIGH-RES TILE PATH ---
Timber.d("Magnifier: Using HIGH-RES TILE path.")
Timber.d("Magnifier: Tile.renderRect=${relevantTile.renderRect}, Tile.bitmap.size=${relevantTile.bitmap.width}x${relevantTile.bitmap.height}")
val bitmapToUse = relevantTile.bitmap.asImageBitmap()
val tileBitmapWidth = relevantTile.bitmap.width.toFloat()
val tileRenderRectWidth = relevantTile.renderRect.width().toFloat()
val tileScale = if (tileRenderRectWidth > 0) {
tileBitmapWidth / tileRenderRectWidth
} else {
1f
}
Timber.d("Magnifier: Using derived tileScale=$tileScale instead of parent's currentScale=$currentScale")
val centerInTileBitmap = Offset(
x = (magnifierCenterOnBitmap.x - relevantTile.renderRect.left) * tileScale,
y = (magnifierCenterOnBitmap.y - relevantTile.renderRect.top) * tileScale
)
Timber.d("Magnifier: Calculated centerInTileBitmap=$centerInTileBitmap")
val sourceRectWidth = magnifierWidthPx / zoomFactor
val sourceRectHeight = magnifierHeightPx / zoomFactor
Timber.d("Magnifier: Desired sourceRect size=${sourceRectWidth}x$sourceRectHeight")
val srcLeft = (centerInTileBitmap.x - sourceRectWidth / 2f)
val srcTop = (centerInTileBitmap.y - sourceRectHeight / 2f)
Timber.d("Magnifier: Calculated source top-left=($srcLeft, $srcTop)")
val clampedSrcLeft = srcLeft.coerceIn(0f, bitmapToUse.width.toFloat() - sourceRectWidth.coerceAtLeast(1f))
val clampedSrcTop = srcTop.coerceIn(0f, bitmapToUse.height.toFloat() - sourceRectHeight.coerceAtLeast(1f))
Timber.d("Magnifier: Clamped source top-left=($clampedSrcLeft, $clampedSrcTop)")
val finalSrcLeftInt = clampedSrcLeft.roundToInt()
val finalSrcTopInt = clampedSrcTop.roundToInt()
val finalSrcWidthInt = (bitmapToUse.width - finalSrcLeftInt)
.coerceAtMost(sourceRectWidth.roundToInt()).coerceAtLeast(1)
val finalSrcHeightInt = (bitmapToUse.height - finalSrcTopInt)
.coerceAtMost(sourceRectHeight.roundToInt()).coerceAtLeast(1)
Timber.d("Magnifier: Final source rect to draw from tile: offset=($finalSrcLeftInt, $finalSrcTopInt), size=${finalSrcWidthInt}x$finalSrcHeightInt")
if (finalSrcWidthInt <= 0 || finalSrcHeightInt <= 0 || finalSrcLeftInt >= bitmapToUse.width || finalSrcTopInt >= bitmapToUse.height) {
Timber.w("Magnifier: Final source rect is invalid, returning.")
return@Canvas
}
drawImage(
image = bitmapToUse,
srcOffset = IntOffset(finalSrcLeftInt, finalSrcTopInt),
srcSize = IntSize(finalSrcWidthInt, finalSrcHeightInt),
dstSize = IntSize(magnifierWidthPx.roundToInt(), magnifierHeightPx.roundToInt()),
colorFilter = colorFilter
)
selectionRectsInBitmapCoords.forEach { rectInBitmap ->
val translatedLeft = (rectInBitmap.left - relevantTile.renderRect.left) * tileScale
val translatedTop = (rectInBitmap.top - relevantTile.renderRect.top) * tileScale
val translatedRight = (rectInBitmap.right - relevantTile.renderRect.left) * tileScale
val translatedBottom = (rectInBitmap.bottom - relevantTile.renderRect.top) * tileScale
val finalLeft = translatedLeft - clampedSrcLeft
val finalTop = translatedTop - clampedSrcTop
val finalRight = translatedRight - clampedSrcLeft
val finalBottom = translatedBottom - clampedSrcTop
val magnifiedLeft = finalLeft * zoomFactor
val magnifiedTop = finalTop * zoomFactor
val magnifiedRight = finalRight * zoomFactor
val magnifiedBottom = finalBottom * zoomFactor
if (magnifiedRight > 0 && magnifiedLeft < magnifierWidthPx && magnifiedBottom > 0 && magnifiedTop < magnifierHeightPx) {
drawRect(
color = highlightColor,
topLeft = Offset(magnifiedLeft, magnifiedTop),
size = androidx.compose.ui.geometry.Size(
width = magnifiedRight - magnifiedLeft,
height = magnifiedBottom - magnifiedTop
)
)
}
}
} else {
// --- LOW-RES / NO-ZOOM PATH ---
Timber.d("Magnifier: Using LOW-RES (base bitmap) path.")
val sourceRectWidth = magnifierWidthPx / zoomFactor
val sourceRectHeight = magnifierHeightPx / zoomFactor
Timber.d("Magnifier: Desired sourceRect size=${sourceRectWidth}x$sourceRectHeight")
val srcLeft = (magnifierCenterOnBitmap.x - sourceRectWidth / 2f)
val srcTop = (magnifierCenterOnBitmap.y - sourceRectHeight / 2f)
Timber.d("Magnifier: Calculated source top-left=($srcLeft, $srcTop)")
val clampedSrcLeft = srcLeft.coerceIn(0f, sourceBitmap.width.toFloat() - sourceRectWidth.coerceAtLeast(1f))
val clampedSrcTop = srcTop.coerceIn(0f, sourceBitmap.height.toFloat() - sourceRectHeight.coerceAtLeast(1f))
Timber.d("Magnifier: Clamped source top-left=($clampedSrcLeft, $clampedSrcTop)")
val finalSrcLeftInt = clampedSrcLeft.roundToInt()
val finalSrcTopInt = clampedSrcTop.roundToInt()
val finalSrcWidthInt = (sourceBitmap.width - finalSrcLeftInt)
.coerceAtMost(sourceRectWidth.roundToInt()).coerceAtLeast(1)
val finalSrcHeightInt = (sourceBitmap.height - finalSrcTopInt)
.coerceAtMost(sourceRectHeight.roundToInt()).coerceAtLeast(1)
Timber.d("Magnifier: Final source rect to draw from base: offset=($finalSrcLeftInt, $finalSrcTopInt), size=${finalSrcWidthInt}x$finalSrcHeightInt")
if (finalSrcWidthInt <= 0 || finalSrcHeightInt <= 0 || finalSrcLeftInt >= sourceBitmap.width || finalSrcTopInt >= sourceBitmap.height) {
Timber.w("Magnifier: Final source rect is invalid, returning.")
return@Canvas
}
drawImage(
image = sourceBitmap,
srcOffset = IntOffset(finalSrcLeftInt, finalSrcTopInt),
srcSize = IntSize(finalSrcWidthInt, finalSrcHeightInt),
dstSize = IntSize(magnifierWidthPx.roundToInt(), magnifierHeightPx.roundToInt()),
colorFilter = colorFilter
)
selectionRectsInBitmapCoords.forEach { rectInBitmap ->
val translatedLeft = rectInBitmap.left - clampedSrcLeft
val translatedTop = rectInBitmap.top - clampedSrcTop
val rectWidthInBitmap = rectInBitmap.width().toFloat()
val rectHeightInBitmap = rectInBitmap.height().toFloat()
val magnifiedLeft = translatedLeft * zoomFactor
val magnifiedTop = translatedTop * zoomFactor
val magnifiedWidth = rectWidthInBitmap * zoomFactor
val magnifiedHeight = rectHeightInBitmap * zoomFactor
if (magnifiedLeft + magnifiedWidth > 0 && magnifiedLeft < magnifierWidthPx &&
magnifiedTop + magnifiedHeight > 0 && magnifiedTop < magnifierHeightPx) {
drawRect(
color = highlightColor,
topLeft = Offset(magnifiedLeft, magnifiedTop),
size = androidx.compose.ui.geometry.Size(
width = magnifiedWidth,
height = magnifiedHeight
)
)
}
}
}
}
}
}

View file

@ -0,0 +1,34 @@
package com.aryan.reader.pdf.ocr
import android.graphics.Rect
/**
* Platform-agnostic OCR result models to decouple the app from Google ML Kit.
*/
data class OcrResult(
val text: String,
val textBlocks: List<OcrBlock>
)
data class OcrBlock(
val text: String,
val boundingBox: Rect?,
val lines: List<OcrLine>
)
data class OcrLine(
val text: String,
val boundingBox: Rect?,
val elements: List<OcrElement>
)
data class OcrElement(
val text: String,
val boundingBox: Rect?,
val symbols: List<OcrSymbol>
)
data class OcrSymbol(
val text: String,
val boundingBox: Rect?
)

View file

@ -0,0 +1,73 @@
// PdfCoverGenerator.kt
package com.aryan.reader.pdf
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import timber.log.Timber
import androidx.core.graphics.createBitmap
import io.legere.pdfiumandroid.suspend.PdfiumCoreKt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
private const val TAG = "PdfCoverGenerator"
class PdfCoverGenerator(context: Context) {
private val appContext = context.applicationContext
private val pdfiumCore = PdfiumCoreKt(Dispatchers.IO)
/**
* Generates a Bitmap cover for the first page of a PDF.
* This function is safe to call from any thread and performs its work on Dispatchers.IO.
*
* @param pdfUri The Uri of the PDF file.
* @param targetHeight The desired height of the output Bitmap. Width is scaled proportionally.
* @return A Bitmap of the first page, or null if an error occurs.
*/
suspend fun generateCover(pdfUri: Uri, targetHeight: Int = 800): Bitmap? {
return withContext(Dispatchers.IO) {
try {
appContext.contentResolver.openFileDescriptor(pdfUri, "r").use { pfd ->
if (pfd == null) {
Timber.e("Failed to open ParcelFileDescriptor for URI: $pdfUri")
return@withContext null
}
pdfiumCore.newDocument(pfd).use { doc ->
if (doc.getPageCount() == 0) {
Timber.w("PDF has no pages, cannot generate cover: $pdfUri")
return@withContext null
}
doc.openPage(0).use { page ->
val originalWidth = page.getPageWidthPoint()
val originalHeight = page.getPageHeightPoint()
if (originalWidth <= 0 || originalHeight <= 0) {
Timber.e("Invalid page dimensions for cover: $pdfUri")
return@withContext null
}
val aspectRatio = originalWidth.toFloat() / originalHeight.toFloat()
val targetWidth = (targetHeight * aspectRatio).toInt()
if (targetWidth <= 0) {
Timber.e("Calculated invalid bitmap width for cover: $targetWidth")
return@withContext null
}
val bitmap = createBitmap(targetWidth, targetHeight)
page.renderPageBitmap(
bitmap = bitmap,
startX = 0, startY = 0,
drawSizeX = targetWidth, drawSizeY = targetHeight,
renderAnnot = false
)
bitmap
}
}
}
} catch (e: Exception) {
Timber.e(e, "Error generating PDF cover for URI: $pdfUri")
null
}
}
}
}

View file

@ -0,0 +1,993 @@
// PdfExporter.kt
package com.aryan.reader.pdf
import android.content.Context
import android.graphics.BitmapShader
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.Shader
import android.net.Uri
import androidx.compose.ui.graphics.Color
import com.tom_roush.pdfbox.pdmodel.font.PDType0Font
import java.io.File
import java.io.FileInputStream
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.isSpecified
import androidx.core.graphics.createBitmap
import com.aryan.reader.pdf.data.PdfAnnotation
import com.aryan.reader.pdf.data.PdfTextBox
import com.aryan.reader.pdf.data.VirtualPage
import com.tom_roush.pdfbox.pdmodel.PDDocument
import com.tom_roush.pdfbox.pdmodel.PDPage
import com.tom_roush.pdfbox.pdmodel.PDPageContentStream
import com.tom_roush.pdfbox.pdmodel.common.PDRectangle
import com.tom_roush.pdfbox.pdmodel.font.PDFont
import com.tom_roush.pdfbox.pdmodel.font.PDType1Font
import com.tom_roush.pdfbox.pdmodel.graphics.blend.BlendMode
import com.tom_roush.pdfbox.pdmodel.graphics.image.LosslessFactory
import com.tom_roush.pdfbox.pdmodel.graphics.state.PDExtendedGraphicsState
import com.tom_roush.pdfbox.pdmodel.graphics.state.RenderingMode
import com.tom_roush.pdfbox.util.Matrix
import java.io.OutputStream
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.util.StringTokenizer
object PdfExporter {
private class PdfBoxFontCache(val doc: PDDocument, val context: Context) {
private val cache = mutableMapOf<String, PDFont>()
fun getFont(fontPath: String?, fontName: String?, isBold: Boolean, isItalic: Boolean): PDFont {
if (!fontPath.isNullOrBlank()) {
Timber.tag("PdfFontDebug").d("Exporter: Requesting font at $fontPath")
val cached = cache[fontPath]
if (cached != null) return cached
try {
val font = if (fontPath.startsWith("asset:")) {
val assetPath = fontPath.removePrefix("asset:")
Timber.tag("PdfFontDebug").i("Exporter: Loading preset font from assets: $assetPath")
PDType0Font.load(doc, context.assets.open(assetPath))
} else {
val file = File(fontPath)
if (file.exists()) {
PDType0Font.load(doc, FileInputStream(file))
} else null
}
if (font != null) {
cache[fontPath] = font
return font
}
} catch (e: Exception) {
Timber.tag("PdfFontDebug").e(e, "Exporter: Failed to embed $fontPath")
}
}
// 2. Map Standard Presets via fontName
if (fontName != null) {
when (fontName) {
"Serif" -> return when {
isBold && isItalic -> PDType1Font.TIMES_BOLD_ITALIC
isBold -> PDType1Font.TIMES_BOLD
isItalic -> PDType1Font.TIMES_ITALIC
else -> PDType1Font.TIMES_ROMAN
}
"Monospace" -> return when {
isBold && isItalic -> PDType1Font.COURIER_BOLD_OBLIQUE
isBold -> PDType1Font.COURIER_BOLD
isItalic -> PDType1Font.COURIER_OBLIQUE
else -> PDType1Font.COURIER
}
// "Sans" and others fall through to Helvetica
}
}
// 3. Fallback to Helvetica (Sans-Serif)
return when {
isBold && isItalic -> PDType1Font.HELVETICA_BOLD_OBLIQUE
isBold -> PDType1Font.HELVETICA_BOLD
isItalic -> PDType1Font.HELVETICA_OBLIQUE
else -> PDType1Font.HELVETICA
}
}
}
private fun applyStyleSimulations(
cs: PDPageContentStream,
fontSize: Float,
isBold: Boolean,
isItalic: Boolean,
isCustomFont: Boolean,
x: Float,
y: Float
) {
if (isCustomFont) {
if (isBold) {
cs.setRenderingMode(RenderingMode.FILL_STROKE)
cs.setLineWidth(fontSize * 0.03f)
} else {
cs.setRenderingMode(RenderingMode.FILL)
}
if (isItalic) {
cs.setTextMatrix(Matrix(1f, 0f, 0.3f, 1f, x, y))
} else {
cs.setTextMatrix(Matrix(1f, 0f, 0f, 1f, x, y))
}
} else {
cs.setRenderingMode(RenderingMode.FILL)
cs.setTextMatrix(Matrix(1f, 0f, 0f, 1f, x, y))
}
}
suspend fun exportAnnotatedPdf(
context: Context,
sourceUri: Uri,
destStream: OutputStream,
virtualPages: List<VirtualPage>?,
inkAnnotations: Map<Int, List<PdfAnnotation>>,
richTextPageLayouts: List<PageTextLayout>? = null,
textBoxes: List<PdfTextBox>? = null
) {
withContext(Dispatchers.IO) {
var sourceDocument: PDDocument? = null
var destDocument: PDDocument? = null
try {
val inputStream = context.contentResolver.openInputStream(sourceUri)
sourceDocument = PDDocument.load(inputStream)
destDocument = PDDocument()
// Determine the sequence of pages to export
val pagesToProcess: List<VirtualPage> =
virtualPages
?: (0 until sourceDocument.numberOfPages).map {
VirtualPage.PdfPage(it)
}
val referencePage =
if (sourceDocument.numberOfPages > 0) sourceDocument.getPage(0) else null
val fontCache = PdfBoxFontCache(destDocument, context)
pagesToProcess.forEachIndexed { virtualIndex, vPage ->
val pageToDecorate: PDPage =
when (vPage) {
is VirtualPage.PdfPage -> {
if (vPage.pdfIndex < sourceDocument.numberOfPages) {
destDocument.importPage(
sourceDocument.getPage(vPage.pdfIndex)
)
} else {
Timber.w(
"Source page ${vPage.pdfIndex} is out of bounds! Creating blank page as fallback."
)
val blank =
PDPage(referencePage?.mediaBox ?: PDRectangle.A4)
destDocument.addPage(blank)
blank
}
}
is VirtualPage.BlankPage -> {
Timber.tag("PdfExportSize").d("Creating blank page with explicit dimensions: ${vPage.width}x${vPage.height}")
val blank = PDPage(PDRectangle(vPage.width.toFloat(), vPage.height.toFloat()))
destDocument.addPage(blank)
blank
}
}
val pageInkAnnos = inkAnnotations[virtualIndex] ?: emptyList()
val richTextLayout = richTextPageLayouts?.find { it.pageIndex == virtualIndex }
val cropBox = pageToDecorate.cropBox
val pageWidth = cropBox.width
val pageHeight = cropBox.height
val lowerLeftY = cropBox.lowerLeftY
if (pageInkAnnos.isNotEmpty()) {
val (pencilAnnos, vectorAnnos) =
pageInkAnnos.partition { it.inkType == InkType.PENCIL }
if (pencilAnnos.isNotEmpty()) {
drawPencilOverlay(
destDocument,
pageToDecorate,
pencilAnnos,
pageWidth,
pageHeight,
lowerLeftY
)
}
if (vectorAnnos.isNotEmpty()) {
PDPageContentStream(
destDocument,
pageToDecorate,
PDPageContentStream.AppendMode.APPEND,
true,
true
)
.use { cs ->
vectorAnnos.forEach { annotation ->
if (annotation.inkType == InkType.FOUNTAIN_PEN) {
drawFountainPen(
cs,
annotation,
pageWidth,
pageHeight,
lowerLeftY
)
} else {
drawStandardAnnotation(
cs,
annotation,
pageWidth,
pageHeight,
lowerLeftY
)
}
}
}
}
}
if (richTextLayout != null && richTextLayout.visibleText.isNotEmpty()) {
PDPageContentStream(destDocument, pageToDecorate, PDPageContentStream.AppendMode.APPEND, true, true).use { cs ->
drawRichTextLayout(cs, richTextLayout, pageWidth, pageHeight, lowerLeftY, fontCache)
}
}
val pageTextBoxes = textBoxes?.filter { it.pageIndex == virtualIndex }
if (!pageTextBoxes.isNullOrEmpty()) {
PDPageContentStream(destDocument, pageToDecorate, PDPageContentStream.AppendMode.APPEND, true, true).use { cs ->
drawTextBoxes(cs, pageTextBoxes, pageWidth, pageHeight, lowerLeftY, fontCache)
}
}
}
destDocument.save(destStream)
} catch (e: Exception) {
Timber.e(e, "Export failed")
throw e
} finally {
sourceDocument?.close()
destDocument?.close()
destStream.close()
}
}
}
private fun drawTextBoxes(
cs: PDPageContentStream,
boxes: List<PdfTextBox>,
pageWidth: Float,
pageHeight: Float,
lowerLeftY: Float,
fontCache: PdfBoxFontCache
) {
for (box in boxes) {
if (box.text.isBlank()) continue
val font = fontCache.getFont(box.fontPath, box.fontName, box.isBold, box.isItalic)
val fontSize = box.fontSize * pageHeight
val lineHeight = fontSize * 1.2f
val boxX = box.relativeBounds.left * pageWidth
val boxWidth = box.relativeBounds.width * pageWidth
val topY = lowerLeftY + pageHeight - (box.relativeBounds.top * pageHeight)
val wrappedLines = mutableListOf<String>()
val paragraphs = box.text.split('\n')
for (paragraph in paragraphs) {
if (paragraph.isEmpty()) {
wrappedLines.add("")
continue
}
val tokenizer = StringTokenizer(paragraph, " ", true)
var currentLine = StringBuilder()
var currentLineWidth = 0f
while (tokenizer.hasMoreTokens()) {
val token = tokenizer.nextToken()
fun getStringWidth(s: String): Float = try {
(font.getStringWidth(s) / 1000f) * fontSize
} catch (_: Exception) { 0f }
val tokenWidth = getStringWidth(token)
if (tokenWidth > boxWidth) {
if (currentLine.isNotEmpty()) {
wrappedLines.add(currentLine.toString())
currentLine = StringBuilder()
currentLineWidth = 0f
}
var tempWord = StringBuilder()
var tempWidth = 0f
for (char in token) {
val charW = getStringWidth(char.toString())
if (tempWidth + charW > boxWidth) {
wrappedLines.add(tempWord.toString())
tempWord = StringBuilder(char.toString())
tempWidth = charW
} else {
tempWord.append(char)
tempWidth += charW
}
}
currentLine.append(tempWord)
currentLineWidth = tempWidth
} else if (currentLineWidth + tokenWidth <= boxWidth) {
currentLine.append(token)
currentLineWidth += tokenWidth
} else {
wrappedLines.add(currentLine.toString())
if (token.isBlank()) {
currentLine = StringBuilder()
currentLineWidth = 0f
} else {
currentLine = StringBuilder(token)
currentLineWidth = tokenWidth
}
}
}
if (currentLine.isNotEmpty()) {
wrappedLines.add(currentLine.toString())
}
}
if (box.backgroundColor != Color.Transparent &&
box.backgroundColor != Color.Unspecified) {
val r = box.backgroundColor.red
val g = box.backgroundColor.green
val b = box.backgroundColor.blue
val a = box.backgroundColor.alpha
if (a < 1.0f) {
val gs = PDExtendedGraphicsState()
gs.nonStrokingAlphaConstant = a
cs.setGraphicsStateParameters(gs)
}
cs.setNonStrokingColor(r, g, b)
var currentBgY = topY
for (line in wrappedLines) {
if (line.isNotEmpty()) {
val lineWidth = try { (font.getStringWidth(line) / 1000f) * fontSize } catch(_: Exception) { 0f }
val padding = fontSize * 0.1f
cs.addRect(boxX - padding, currentBgY - lineHeight, lineWidth + (padding * 2), lineHeight)
cs.fill()
}
currentBgY -= lineHeight
}
if (a < 1.0f) {
val gs = PDExtendedGraphicsState()
gs.nonStrokingAlphaConstant = 1.0f
cs.setGraphicsStateParameters(gs)
}
}
val tr = box.color.red
val tg = box.color.green
val tb = box.color.blue
cs.setNonStrokingColor(tr, tg, tb)
cs.setFont(font, fontSize)
val textY = topY - (fontSize * 0.85f)
cs.beginText()
for ((index, line) in wrappedLines.withIndex()) {
val currentLineY = textY - (index * lineHeight)
applyStyleSimulations(
cs = cs,
fontSize = fontSize,
isBold = box.isBold,
isItalic = box.isItalic,
isCustomFont = !box.fontPath.isNullOrBlank(),
x = boxX,
y = currentLineY
)
if (line.isNotEmpty()) {
try {
cs.showText(line)
} catch (e: Exception) {
Timber.e(e, "Error drawing text line")
}
}
}
cs.endText()
if (box.isUnderline || box.isStrikeThrough) {
cs.setStrokingColor(tr, tg, tb)
cs.setLineWidth(fontSize / 15f)
var decorY = topY - (fontSize * 0.85f)
for (line in wrappedLines) {
if (line.isNotEmpty()) {
val lineWidth = try { (font.getStringWidth(line) / 1000f) * fontSize } catch(_:Exception){0f}
if (box.isUnderline) {
val underlineY = decorY - (fontSize * 0.15f)
cs.moveTo(boxX, underlineY)
cs.lineTo(boxX + lineWidth, underlineY)
cs.stroke()
}
if (box.isStrikeThrough) {
val strikeY = decorY + (fontSize * 0.3f)
cs.moveTo(boxX, strikeY)
cs.lineTo(boxX + lineWidth, strikeY)
cs.stroke()
}
}
decorY -= lineHeight
}
}
}
}
private fun drawPencilOverlay(
document: PDDocument,
page: PDPage,
annotations: List<PdfAnnotation>,
pageWidth: Float,
pageHeight: Float,
lowerLeftY: Float
) {
val scale = 2.0f
val bitmapW = (pageWidth * scale).toInt()
val bitmapH = (pageHeight * scale).toInt()
if (bitmapW <= 0 || bitmapH <= 0) return
val bitmap = createBitmap(bitmapW, bitmapH)
val canvas = Canvas(bitmap)
val texture = PdfTextureGenerator.getNoiseTexture()
val paint =
Paint().apply {
isAntiAlias = true
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
shader = BitmapShader(texture, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT)
}
annotations.forEach { annot ->
if (annot.points.size > 1) {
val strokeWidthPx = annot.strokeWidth * bitmapW
paint.strokeWidth = strokeWidthPx
val adjustedAlpha = (annot.color.alpha * 0.8f).coerceIn(0f, 1f)
paint.colorFilter =
PorterDuffColorFilter(
android.graphics.Color.argb(
(adjustedAlpha * 255).toInt(),
(annot.color.red * 255).toInt(),
(annot.color.green * 255).toInt(),
(annot.color.blue * 255).toInt()
),
PorterDuff.Mode.SRC_IN
)
val path = android.graphics.Path()
val startP = annot.points[0]
path.moveTo(startP.x * bitmapW, startP.y * bitmapH)
for (i in 1 until annot.points.size) {
val p0 = annot.points[i - 1]
val p1 = annot.points[i]
val p0x = p0.x * bitmapW
val p0y = p0.y * bitmapH
val p1x = p1.x * bitmapW
val p1y = p1.y * bitmapH
val midX = (p0x + p1x) / 2f
val midY = (p0y + p1y) / 2f
if (i == 1) path.lineTo(midX, midY) else path.quadTo(p0x, p0y, midX, midY)
}
val last = annot.points.last()
path.lineTo(last.x * bitmapW, last.y * bitmapH)
canvas.drawPath(path, paint)
}
}
val pdImage = LosslessFactory.createFromImage(document, bitmap)
bitmap.recycle()
PDPageContentStream(document, page, PDPageContentStream.AppendMode.APPEND, true, true)
.use { cs -> cs.drawImage(pdImage, 0f, lowerLeftY, pageWidth, pageHeight) }
}
private fun drawFountainPen(
cs: PDPageContentStream,
annotation: PdfAnnotation,
pageWidth: Float,
pageHeight: Float,
lowerLeftY: Float
) {
if (annotation.points.size < 2) return
val r = annotation.color.red
val g = annotation.color.green
val b = annotation.color.blue
val a = annotation.color.alpha
cs.setNonStrokingColor(r, g, b)
if (a < 1.0f) {
val graphicsState = PDExtendedGraphicsState()
graphicsState.nonStrokingAlphaConstant = a
cs.setGraphicsStateParameters(graphicsState)
}
val baseStrokeWidth = annotation.strokeWidth * pageWidth
val (leftSide, rightSide) =
PdfInkGeometry.calculateFountainPenPoints(
annotation.points,
baseStrokeWidth,
pageWidth,
pageHeight
)
if (leftSide.isNotEmpty()) {
fun fixY(y: Float): Float = lowerLeftY + pageHeight - y
cs.moveTo(leftSide[0].x, fixY(leftSide[0].y))
for (i in 1 until leftSide.size) {
cs.lineTo(leftSide[i].x, fixY(leftSide[i].y))
}
for (i in rightSide.size - 1 downTo 0) {
cs.lineTo(rightSide[i].x, fixY(rightSide[i].y))
}
@Suppress("DEPRECATION") cs.closeSubPath()
cs.fill()
}
if (a < 1.0f) {
val resetState = PDExtendedGraphicsState()
resetState.nonStrokingAlphaConstant = 1.0f
cs.setGraphicsStateParameters(resetState)
}
}
private fun drawStandardAnnotation(
cs: PDPageContentStream,
annotation: PdfAnnotation,
pageWidth: Float,
pageHeight: Float,
lowerLeftY: Float
) {
if (annotation.points.isEmpty()) return
val r = annotation.color.red
val g = annotation.color.green
val b = annotation.color.blue
val a = annotation.color.alpha
cs.setStrokingColor(r, g, b)
if (a < 1.0f ||
annotation.inkType == InkType.HIGHLIGHTER ||
annotation.inkType == InkType.HIGHLIGHTER_ROUND
) {
val graphicsState = PDExtendedGraphicsState()
graphicsState.strokingAlphaConstant = a
if (annotation.inkType == InkType.HIGHLIGHTER ||
annotation.inkType == InkType.HIGHLIGHTER_ROUND
) {
graphicsState.blendMode = BlendMode.MULTIPLY
}
cs.setGraphicsStateParameters(graphicsState)
}
val lineWidth = annotation.strokeWidth * pageWidth
cs.setLineWidth(lineWidth)
when (annotation.inkType) {
InkType.HIGHLIGHTER -> cs.setLineCapStyle(0)
else -> cs.setLineCapStyle(1)
}
cs.setLineJoinStyle(1)
val points = annotation.points
val startX = points[0].x * pageWidth
val startY = lowerLeftY + pageHeight - (points[0].y * pageHeight)
cs.moveTo(startX, startY)
for (i in 1 until points.size) {
val p0 = points[i - 1]
val p1 = points[i]
val p0x = p0.x * pageWidth
val p0y = lowerLeftY + pageHeight - (p0.y * pageHeight)
val p1x = p1.x * pageWidth
val p1y = lowerLeftY + pageHeight - (p1.y * pageHeight)
val midX = (p0x + p1x) / 2f
val midY = (p0y + p1y) / 2f
if (i == 1) {
cs.lineTo(midX, midY)
} else {
cs.curveTo2(p0x, p0y, midX, midY)
}
}
val lastP = points.last()
val lastX = lastP.x * pageWidth
val lastY = lowerLeftY + pageHeight - (lastP.y * pageHeight)
cs.lineTo(lastX, lastY)
cs.stroke()
val resetState = PDExtendedGraphicsState()
resetState.strokingAlphaConstant = 1.0f
resetState.blendMode = BlendMode.NORMAL
cs.setGraphicsStateParameters(resetState)
}
private data class StyledRun(
val text: String,
val fontSize: Float,
val isBold: Boolean,
val isItalic: Boolean,
val isUnderline: Boolean,
val isStrikethrough: Boolean,
val colorArgb: Int,
val backgroundColorArgb: Int,
val fontPath: String?,
val fontName: String? // Add this field
)
private fun buildStyledRuns(
text: AnnotatedString,
@Suppress("SameParameterValue") startIndex: Int,
endIndex: Int,
scaleFactor: Float
): List<StyledRun> {
if (startIndex >= endIndex || text.text.isEmpty()) return emptyList()
val runs = mutableListOf<StyledRun>()
var currentRunStart = startIndex
val currentStyle = getStyleAt(text, startIndex)
// Updated Tuple to 9 elements
data class StyleProps(
val fontSize: Float,
val isBold: Boolean,
val isItalic: Boolean,
val isUnderline: Boolean,
val isStrikethrough: Boolean,
val colorArgb: Int,
val backgroundColorArgb: Int,
val fontPath: String?,
val fontName: String?
)
fun extractRunProperties(style: SpanStyle): StyleProps {
val fontSize = if (style.fontSize.isSpecified) style.fontSize.value * scaleFactor else 16f * scaleFactor
val isBold = style.fontWeight == FontWeight.Bold
val isItalic = style.fontStyle == FontStyle.Italic
val decoration = style.textDecoration ?: TextDecoration.None
val isUnderline = decoration.contains(TextDecoration.Underline)
val isStrikethrough = decoration.contains(TextDecoration.LineThrough)
val colorArgb = if (style.color != Color.Unspecified) style.color.toArgb() else android.graphics.Color.BLACK
val bgColorArgb = if (style.background != Color.Unspecified) style.background.toArgb() else android.graphics.Color.TRANSPARENT
val fontPath = PdfFontCache.getPath(style.fontFamily)
// Map standard families back to names for the exporter
val fontName = when (style.fontFamily) {
FontFamily.Serif -> "Serif"
FontFamily.Monospace -> "Monospace"
FontFamily.SansSerif -> "Sans"
else -> null
}
return StyleProps(fontSize, isBold, isItalic, isUnderline, isStrikethrough, colorArgb, bgColorArgb, fontPath, fontName)
}
var currentProps = extractRunProperties(currentStyle)
for (i in (startIndex + 1) until endIndex) {
val charStyle = getStyleAt(text, i)
val charProps = extractRunProperties(charStyle)
if (charProps != currentProps) {
val runText = text.text.substring(currentRunStart, i)
runs.add(
StyledRun(
text = runText,
fontSize = currentProps.fontSize,
isBold = currentProps.isBold,
isItalic = currentProps.isItalic,
isUnderline = currentProps.isUnderline,
isStrikethrough = currentProps.isStrikethrough,
colorArgb = currentProps.colorArgb,
backgroundColorArgb = currentProps.backgroundColorArgb,
fontPath = currentProps.fontPath,
fontName = currentProps.fontName // Pass fontName
)
)
currentRunStart = i
currentProps = charProps
}
}
val lastRunText = text.text.substring(currentRunStart, endIndex)
if (lastRunText.isNotEmpty()) {
runs.add(
StyledRun(
text = lastRunText,
fontSize = currentProps.fontSize,
isBold = currentProps.isBold,
isItalic = currentProps.isItalic,
isUnderline = currentProps.isUnderline,
isStrikethrough = currentProps.isStrikethrough,
colorArgb = currentProps.colorArgb,
backgroundColorArgb = currentProps.backgroundColorArgb,
fontPath = currentProps.fontPath,
fontName = currentProps.fontName
)
)
}
return runs
}
private fun drawRichTextLayout(
cs: PDPageContentStream,
layout: PageTextLayout,
pageWidth: Float,
pageHeight: Float,
lowerLeftY: Float,
fontCache: PdfBoxFontCache
) {
val text = layout.visibleText
val layoutPageHeightPx = layout.pageHeightPx
if (text.text.isEmpty()) return
Timber.tag("PdfExportWrap").d("Starting export for Page ${layout.pageIndex}")
val estimatedDensity = 2.3f
val scaleFactor =
if (layoutPageHeightPx > 0) {
estimatedDensity * pageHeight / layoutPageHeightPx
} else {
1.15f
}
val marginX = pageWidth * 0.1f
val marginY = pageHeight * 0.08f
val contentWidth = pageWidth - (marginX * 2)
Timber.tag("PdfExportWrap").d("Layout Constants: pageWidth=$pageWidth, contentWidth=$contentWidth, scaleFactor=$scaleFactor")
val allRuns = buildStyledRuns(text, 0, text.text.length, scaleFactor)
val firstFontSize = allRuns.firstOrNull()?.fontSize ?: (16f * scaleFactor)
var currentY = lowerLeftY + pageHeight - marginY - (firstFontSize * 1.25f)
data class LineRun(val run: StyledRun, val width: Float)
val currentLineRuns = mutableListOf<LineRun>()
var currentLineWidth = 0f
var maxFontSizeInLine = 0f
fun flushLine() {
if (currentLineRuns.isEmpty()) return
Timber.tag("PdfExportWrap").d("Flushing Line: width=$currentLineWidth, y=$currentY, runsCount=${currentLineRuns.size}")
drawLineOfRuns(cs, currentLineRuns.map { it.run }, marginX, currentY, contentWidth, fontCache)
currentY -= (maxFontSizeInLine * 1.2f)
currentLineRuns.clear()
currentLineWidth = 0f
maxFontSizeInLine = 0f
}
for (run in allRuns) {
val parts = run.text.split('\n')
parts.forEachIndexed { partIndex, part ->
if (partIndex > 0) {
flushLine()
if (part.isEmpty()) {
currentY -= (run.fontSize * 1.2f)
return@forEachIndexed
}
}
if (part.isEmpty()) return@forEachIndexed
val tokenizer = StringTokenizer(part, " \t\u000B\u000C\r", true)
while (tokenizer.hasMoreTokens()) {
val token = tokenizer.nextToken()
var remainingToken = token
while (remainingToken.isNotEmpty()) {
val font = fontCache.getFont(run.fontPath, run.fontName, run.isBold, run.isItalic)
fun measure(s: String): Float = try {
(font.getStringWidth(s) / 1000f) * run.fontSize
} catch (_: Exception) { 0f }
val tokenWidth = measure(remainingToken)
if (currentLineWidth + tokenWidth <= contentWidth) {
currentLineRuns.add(LineRun(run.copy(text = remainingToken), tokenWidth))
currentLineWidth += tokenWidth
if (run.fontSize > maxFontSizeInLine) maxFontSizeInLine = run.fontSize
remainingToken = ""
}
else if (currentLineRuns.isNotEmpty()) {
flushLine()
}
else {
var low = 1
var high = remainingToken.length
var bestIndex = 1
while (low <= high) {
val mid = (low + high) / 2
if (measure(remainingToken.take(mid)) <= contentWidth) {
bestIndex = mid
low = mid + 1
} else {
high = mid - 1
}
}
val chunk = remainingToken.take(bestIndex)
val chunkWidth = measure(chunk)
currentLineRuns.add(LineRun(run.copy(text = chunk), chunkWidth))
currentLineWidth = chunkWidth
maxFontSizeInLine = run.fontSize
flushLine()
remainingToken = remainingToken.substring(bestIndex)
}
}
}
}
}
flushLine()
}
private fun drawLineOfRuns(
cs: PDPageContentStream,
runs: List<StyledRun>,
startX: Float,
y: Float,
@Suppress("UNUSED_PARAMETER") contentWidth: Float,
fontCache: PdfBoxFontCache
) {
if (runs.isEmpty()) return
var bgX = startX
for (run in runs) {
val font = fontCache.getFont(run.fontPath, run.fontName, run.isBold, run.isItalic)
val safeText = run.text.replace("\n", " ")
.replace("\r", "")
.replace("\u000C", "")
.replace("\u200B", "")
val runWidth = (font.getStringWidth(safeText) / 1000f) * run.fontSize
if (run.backgroundColorArgb != android.graphics.Color.TRANSPARENT) {
val r = android.graphics.Color.red(run.backgroundColorArgb) / 255f
val g = android.graphics.Color.green(run.backgroundColorArgb) / 255f
val b = android.graphics.Color.blue(run.backgroundColorArgb) / 255f
cs.setNonStrokingColor(r, g, b)
cs.addRect(bgX, y - (run.fontSize * 0.2f), runWidth, run.fontSize * 1.2f)
cs.fill()
}
bgX += runWidth
}
cs.beginText()
cs.newLineAtOffset(startX, y)
var currentFont: PDFont? = null
var currentFontSize = -1f
var currentColor = -1
android.graphics.Color.BLACK
var currentX = startX
for (run in runs) {
val font = fontCache.getFont(run.fontPath, run.fontName, run.isBold, run.isItalic)
val isCustom = !run.fontPath.isNullOrBlank()
if (font != currentFont || run.fontSize != currentFontSize) {
cs.setFont(font, run.fontSize)
currentFont = font
currentFontSize = run.fontSize
}
if (run.colorArgb != currentColor) {
val r = android.graphics.Color.red(run.colorArgb) / 255f
val g = android.graphics.Color.green(run.colorArgb) / 255f
val b = android.graphics.Color.blue(run.colorArgb) / 255f
cs.setNonStrokingColor(r, g, b)
currentColor = run.colorArgb
}
applyStyleSimulations(cs, run.fontSize, run.isBold, run.isItalic, isCustom, currentX, y)
try {
val safeText = run.text.replace("\n", " ").replace("\r", "").replace("\u000C", "").replace("\u200B", "")
cs.showText(safeText)
val runWidth = (font.getStringWidth(safeText) / 1000f) * run.fontSize
currentX += runWidth
} catch (e: Exception) {
Timber.e(e, "Error drawing run: ${run.text}")
}
}
cs.endText()
var decorationX = startX
for (run in runs) {
val font = fontCache.getFont(run.fontPath, run.fontName, run.isBold, run.isItalic)
val safeText = run.text.replace("\n", " ")
.replace("\r", "")
.replace("\u000C", "")
.replace("\u200B", "")
val runWidth = (font.getStringWidth(safeText) / 1000f) * run.fontSize
if (run.isUnderline) {
val r = android.graphics.Color.red(run.colorArgb) / 255f
val g = android.graphics.Color.green(run.colorArgb) / 255f
val b = android.graphics.Color.blue(run.colorArgb) / 255f
cs.setStrokingColor(r, g, b)
cs.setLineWidth(run.fontSize / 15f)
cs.moveTo(decorationX, y - (run.fontSize * 0.15f))
cs.lineTo(decorationX + runWidth, y - (run.fontSize * 0.15f))
cs.stroke()
}
if (run.isStrikethrough) {
val r = android.graphics.Color.red(run.colorArgb) / 255f
val g = android.graphics.Color.green(run.colorArgb) / 255f
val b = android.graphics.Color.blue(run.colorArgb) / 255f
cs.setStrokingColor(r, g, b)
cs.setLineWidth(run.fontSize / 15f)
cs.moveTo(decorationX, y + (run.fontSize * 0.25f))
cs.lineTo(decorationX + runWidth, y + (run.fontSize * 0.25f))
cs.stroke()
}
decorationX += runWidth
}
}
private fun getStyleAt(text: AnnotatedString, index: Int): SpanStyle {
val styles = text.spanStyles.filter { index >= it.start && index < it.end }
var style = SpanStyle()
styles.forEach { style = style.merge(it.item) }
return style
}
}

View file

@ -0,0 +1,277 @@
// PdfHelper.kt
package com.aryan.reader.pdf
import android.graphics.Bitmap
import android.graphics.Rect
import android.os.Handler
import android.os.Looper
import timber.log.Timber
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
import com.aryan.reader.countWords
import io.legere.pdfiumandroid.suspend.PdfTextPageKt
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import com.aryan.reader.OcrEngine
import com.aryan.reader.pdf.ocr.OcrElement
import com.aryan.reader.pdf.ocr.OcrLine
import com.aryan.reader.pdf.ocr.OcrResult
import com.aryan.reader.pdf.ocr.OcrSymbol
enum class OcrLanguage(val displayName: String) {
LATIN("English, Spanish, French, etc."),
DEVANAGARI("Hindi, Marathi, Sanskrit + English"),
CHINESE("Chinese + English"),
JAPANESE("Japanese + English"),
KOREAN("Korean + English")
}
internal data class OcrSymbolInfo(
val symbol: OcrSymbol,
val parentElement: OcrElement,
val parentLine: OcrLine
)
internal data class CustomPdfMenuState(
val selectedText: String,
val anchorRect: Rect,
val charRange: Pair<Int, Int>
)
internal enum class PdfSelectionMethod {
PDFIUM, OCR
}
internal object OcrHelper {
fun init(language: OcrLanguage) {
OcrEngine.init(language)
}
suspend fun extractTextFromBitmap(
bitmap: Bitmap,
onModelDownloading: () -> Unit
): OcrResult? {
return OcrEngine.extractTextFromBitmap(bitmap, onModelDownloading)
}
}
internal suspend fun findWordBoundaries(
textPage: PdfTextPageKt,
initialCharIndex: Int,
pageCharCount: Int
): Pair<Int, Int>? {
if (initialCharIndex !in 0..<pageCharCount) return null
val initialChar = textPage.textPageGetUnicode(initialCharIndex)
if (!initialChar.isLetterOrDigit()) {
Timber.d("Initial char '$initialChar' at index $initialCharIndex is not letter/digit.")
return null
}
var wordStartIndex = initialCharIndex
while (wordStartIndex > 0) {
val char = textPage.textPageGetUnicode(wordStartIndex - 1)
if (!char.isLetterOrDigit()) {
break
}
wordStartIndex--
}
var wordEndIndex = initialCharIndex
while (wordEndIndex < pageCharCount) {
val char = textPage.textPageGetUnicode(wordEndIndex)
if (!char.isLetterOrDigit()) {
break
}
wordEndIndex++
}
return if (wordStartIndex < wordEndIndex) {
Timber.d("Word boundaries: $wordStartIndex to $wordEndIndex (exclusive)")
Pair(wordStartIndex, wordEndIndex)
} else {
Timber.w("Word boundary detection resulted in startIndex >= endIndex ($wordStartIndex >= $wordEndIndex)")
null
}
}
@Composable
internal fun PdfSelectionMenuPopup(
menuState: CustomPdfMenuState,
popupPositionProvider: PopupPositionProvider,
onCopy: (String) -> Unit,
onAiDefine: (String) -> Unit,
onSelectAll: () -> Unit,
isProUser: Boolean,
onShowUpsellDialog: () -> Unit,
) {
Popup(
popupPositionProvider = popupPositionProvider,
onDismissRequest = null,
properties = PopupProperties(
focusable = false,
dismissOnClickOutside = false,
dismissOnBackPress = false
)
) {
Surface(
shape = RoundedCornerShape(8.dp),
shadowElevation = 4.dp,
color = MaterialTheme.colorScheme.surfaceVariant,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f))
) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
TextButton(onClick = { onCopy(menuState.selectedText) }) {
Text("Copy")
}
if (menuState.selectedText.length <= 2000) {
TextButton(onClick = {
if (isProUser || countWords(menuState.selectedText) <= 1) {
onAiDefine(menuState.selectedText)
} else {
onShowUpsellDialog()
}
}) {
Text("Dictionary")
}
}
TextButton(onClick = onSelectAll) {
Text("Select All")
}
}
}
}
}
internal fun mergeRectsIntoLines(rects: List<Rect>): List<Rect> {
if (rects.isEmpty()) return emptyList()
val sortedRects = rects.sortedWith(compareBy({ it.top }, { it.left }))
val mergedLines = mutableListOf<Rect>()
var currentLineCombinedRect: Rect? = null
for (rect in sortedRects) {
if (currentLineCombinedRect == null) {
currentLineCombinedRect = Rect(rect)
} else {
val isSameLine = (maxOf(currentLineCombinedRect.top, rect.top) <
minOf(currentLineCombinedRect.bottom, rect.bottom))
if (isSameLine) {
currentLineCombinedRect.union(rect)
} else {
mergedLines.add(currentLineCombinedRect)
currentLineCombinedRect = Rect(rect)
}
}
}
currentLineCombinedRect?.let { mergedLines.add(it) }
return mergedLines
}
internal fun findRectsForTextChunkInOcrVisual(
visionText: OcrResult,
textChunkToHighlight: String
): List<Rect> {
if (textChunkToHighlight.isBlank()) return emptyList()
val allOcrElements = visionText.textBlocks.flatMap { tb -> tb.lines.flatMap { l -> l.elements } }
if (allOcrElements.isEmpty()) return emptyList()
val targetWords = textChunkToHighlight.split(Regex("\\s+")).filter { it.isNotEmpty() }
if (targetWords.isEmpty()) return emptyList()
val matchedRects = mutableListOf<Rect>()
for (i in 0 .. allOcrElements.size - targetWords.size) {
var currentMatch = true
val tempRects = mutableListOf<Rect>()
var ocrTextCombined = ""
for (j in targetWords.indices) {
val ocrElement = allOcrElements[i + j]
ocrTextCombined += ocrElement.text + " "
if (!ocrElement.text.equals(targetWords[j], ignoreCase = true) &&
!ocrElement.text.replace(Regex("[.,;:!?\"')$]"), "").equals(targetWords[j], ignoreCase = true) &&
!targetWords[j].replace(Regex("[.,;:!?\"'(]$"), "").equals(ocrElement.text, ignoreCase = true)
) {
currentMatch = false
break
}
ocrElement.boundingBox?.let {
tempRects.add(
Rect(
it.left,
it.top,
it.right,
it.bottom
)
)
}
}
if (currentMatch) {
Timber.d("OCR Highlight Match: Found sequence for '$textChunkToHighlight' starting with '${allOcrElements[i].text}' -> Combined: $ocrTextCombined")
matchedRects.addAll(tempRects)
return matchedRects
}
}
Timber.d("OCR Highlight No Match: Could not find sequence for '$textChunkToHighlight'")
return emptyList()
}
internal data class ProcessedText(
val cleanText: String,
val indexMap: List<Int>
)
internal sealed class TtsHighlightData {
data class Pdfium(val startIndex: Int, val length: Int) : TtsHighlightData()
data class Ocr(val text: String) : TtsHighlightData()
}
internal fun preprocessTextForTts(rawText: String): ProcessedText {
if (rawText.isBlank()) {
return ProcessedText("", emptyList())
}
val cleanTextBuilder = StringBuilder(rawText.length)
val indexMap = mutableListOf<Int>()
rawText.forEachIndexed { index, char ->
when (char) {
'\n' -> {
val lastChar = cleanTextBuilder.trimEnd().lastOrNull()
if (lastChar != null && lastChar !in ".?!") {
if (cleanTextBuilder.isNotEmpty() && !cleanTextBuilder.last().isWhitespace()) {
cleanTextBuilder.append(' ')
indexMap.add(index)
}
}
}
'\r' -> {
// Ignore carriage returns completely
}
else -> {
cleanTextBuilder.append(char)
indexMap.add(index)
}
}
}
return ProcessedText(cleanTextBuilder.toString().trim(), indexMap)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,377 @@
// PdfTextBox.kt
package com.aryan.reader.pdf
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.ui.zIndex
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.aryan.reader.R
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import com.aryan.reader.pdf.data.PdfTextBox
import timber.log.Timber
import kotlin.math.roundToInt
private enum class ResizeHandle {
TOP_LEFT, TOP_CENTER, TOP_RIGHT,
RIGHT_CENTER,
BOTTOM_RIGHT, BOTTOM_CENTER, BOTTOM_LEFT,
LEFT_CENTER,
NONE
}
enum class HandlePosition {
TOP, BOTTOM, AUTO
}
@Composable
fun ResizableTextBox(
box: PdfTextBox,
isSelected: Boolean,
isEditMode: Boolean,
isDarkMode: Boolean,
pageWidthPx: Float,
pageHeightPx: Float,
onBoundsChanged: (Rect) -> Unit,
onTextChanged: (String) -> Unit,
onSelect: () -> Unit,
onDragStart: (Offset) -> Unit,
onDrag: (Offset, Rect) -> Unit,
onDragEnd: () -> Unit,
modifier: Modifier = Modifier,
onDragCancel: () -> Unit = {},
handlePosition: HandlePosition = HandlePosition.AUTO
) {
if (pageWidthPx <= 0 || pageHeightPx <= 0) return
val density = LocalDensity.current
val focusRequester = remember { FocusRequester() }
val handleSize = 10.dp
val handleTouchSize = 40.dp
val handleSizePx = with(density) { handleSize.toPx() }
val halfHandlePx = handleSizePx / 2f
val handleTouchSizePx = with(density) { handleTouchSize.toPx() }
val borderColor = if (isDarkMode) Color.White else Color.Black
val handleColor = if (isDarkMode) Color.White else Color.Black
var isDraggingOrResizing by remember { mutableStateOf(false) }
val fontFamily = remember(box.fontPath, box.fontName) {
Timber.tag("PdfFontDebug").d("Rendering Box ${box.id}: FontPath=${box.fontPath}, FontName=${box.fontName}")
if (box.fontPath != null) {
PdfFontCache.getFontFamily(box.fontPath)
} else {
when (box.fontName) {
"Serif" -> FontFamily.Serif
"Sans" -> FontFamily.SansSerif
"Monospace" -> FontFamily.Monospace
"Cursive" -> FontFamily.Cursive
else -> FontFamily.Default
}
}
}
var currentRectPx by remember {
mutableStateOf(
Rect(
left = box.relativeBounds.left * pageWidthPx,
top = box.relativeBounds.top * pageHeightPx,
right = box.relativeBounds.right * pageWidthPx,
bottom = box.relativeBounds.bottom * pageHeightPx
)
)
}
LaunchedEffect(isSelected) {
if (isSelected && isEditMode) {
focusRequester.requestFocus()
}
}
LaunchedEffect(box.relativeBounds, pageWidthPx, pageHeightPx) {
if (!isDraggingOrResizing) {
val newPx = Rect(
left = box.relativeBounds.left * pageWidthPx,
top = box.relativeBounds.top * pageHeightPx,
right = box.relativeBounds.right * pageWidthPx,
bottom = box.relativeBounds.bottom * pageHeightPx
)
if (kotlin.math.abs(newPx.left - currentRectPx.left) > 1f ||
kotlin.math.abs(newPx.top - currentRectPx.top) > 1f ||
kotlin.math.abs(newPx.width - currentRectPx.width) > 1f ||
kotlin.math.abs(newPx.height - currentRectPx.height) > 1f
) {
currentRectPx = newPx
}
}
}
val requiredBottomSpacePx = with(density) { 60.dp.toPx() }
val isHandleAtTop by remember(currentRectPx, pageHeightPx, handlePosition) {
derivedStateOf {
when (handlePosition) {
HandlePosition.TOP -> true
HandlePosition.BOTTOM -> false
HandlePosition.AUTO -> {
if (pageHeightPx <= 0f) {
false
} else {
val spaceBelow = pageHeightPx - currentRectPx.bottom
spaceBelow < requiredBottomSpacePx
}
}
}
}
}
Box(
modifier = modifier
.zIndex(if (isSelected) 10f else 0f)
.offset {
IntOffset(
(currentRectPx.left - halfHandlePx).roundToInt(),
(currentRectPx.top - halfHandlePx).roundToInt()
)
}
.size(
width = with(density) { (currentRectPx.width + handleSizePx).toDp() },
height = with(density) { (currentRectPx.height + handleSizePx).toDp() }
)
) {
// --- 1. Content Body ---
Box(
modifier = Modifier
.fillMaxSize()
.padding(handleSize / 2)
.pointerInput(Unit) {
detectTapGestures { onSelect() }
}
.then(
if (isSelected) Modifier.border(1.5.dp, borderColor) else Modifier
)
) {
BasicTextField(
value = box.text,
onValueChange = onTextChanged,
modifier = Modifier
.fillMaxSize()
.padding(8.dp)
.verticalScroll(rememberScrollState())
.focusRequester(focusRequester),
textStyle = TextStyle(
color = box.color,
background = box.backgroundColor,
fontFamily = fontFamily,
fontSize = with(LocalDensity.current) {
(box.fontSize * pageHeightPx).coerceAtLeast(10f).toSp()
},
fontWeight = if (box.isBold) FontWeight.Bold else FontWeight.Normal,
fontStyle = if (box.isItalic) FontStyle.Italic else FontStyle.Normal,
textDecoration = run {
val decs = mutableListOf<TextDecoration>()
if (box.isUnderline) decs.add(TextDecoration.Underline)
if (box.isStrikeThrough) decs.add(TextDecoration.LineThrough)
if (decs.isEmpty()) TextDecoration.None else TextDecoration.combine(decs)
}
),
cursorBrush = SolidColor(if (isDarkMode) Color.White else MaterialTheme.colorScheme.primary),
enabled = isEditMode && isSelected,
readOnly = !isEditMode
)
}
if (isSelected) {
val handles = ResizeHandle.entries.filter { it != ResizeHandle.NONE }
fun getHandleCenter(handle: ResizeHandle, w: Float, h: Float): Offset {
return when (handle) {
ResizeHandle.TOP_LEFT -> Offset(halfHandlePx, halfHandlePx)
ResizeHandle.TOP_CENTER -> Offset(halfHandlePx + w / 2, halfHandlePx)
ResizeHandle.TOP_RIGHT -> Offset(halfHandlePx + w, halfHandlePx)
ResizeHandle.RIGHT_CENTER -> Offset(halfHandlePx + w, halfHandlePx + h / 2)
ResizeHandle.BOTTOM_RIGHT -> Offset(halfHandlePx + w, halfHandlePx + h)
ResizeHandle.BOTTOM_CENTER -> Offset(halfHandlePx + w / 2, halfHandlePx + h)
ResizeHandle.BOTTOM_LEFT -> Offset(halfHandlePx, halfHandlePx + h)
ResizeHandle.LEFT_CENTER -> Offset(halfHandlePx, halfHandlePx + h / 2)
else -> Offset.Zero
}
}
handles.forEach { handle ->
val center = getHandleCenter(handle, currentRectPx.width, currentRectPx.height)
Box(
modifier = Modifier
.offset {
IntOffset(
(center.x - handleTouchSizePx / 2).roundToInt(),
(center.y - handleTouchSizePx / 2).roundToInt()
)
}
.size(handleTouchSize)
.pointerInput(onBoundsChanged) {
detectDragGestures(
onDragStart = { isDraggingOrResizing = true },
onDragEnd = {
isDraggingOrResizing = false
val normalized = Rect(
left = currentRectPx.left / pageWidthPx,
top = currentRectPx.top / pageHeightPx,
right = currentRectPx.right / pageWidthPx,
bottom = currentRectPx.bottom / pageHeightPx
)
onBoundsChanged(normalized)
},
onDragCancel = { isDraggingOrResizing = false }
) { change, dragAmount ->
change.consume()
var l = currentRectPx.left
var t = currentRectPx.top
var r = currentRectPx.right
var b = currentRectPx.bottom
val dx = dragAmount.x
val dy = dragAmount.y
val minSize = 50f
when (handle) {
ResizeHandle.TOP_LEFT -> {
l = (l + dx).coerceIn(0f, r - minSize)
t = (t + dy).coerceIn(0f, b - minSize)
}
ResizeHandle.TOP_CENTER -> t = (t + dy).coerceIn(0f, b - minSize)
ResizeHandle.TOP_RIGHT -> {
r = (r + dx).coerceIn(l + minSize, pageWidthPx)
t = (t + dy).coerceIn(0f, b - minSize)
}
ResizeHandle.RIGHT_CENTER -> r = (r + dx).coerceIn(l + minSize, pageWidthPx)
ResizeHandle.BOTTOM_RIGHT -> {
r = (r + dx).coerceIn(l + minSize, pageWidthPx)
b = (b + dy).coerceIn(t + minSize, pageHeightPx)
}
ResizeHandle.BOTTOM_CENTER -> b = (b + dy).coerceIn(t + minSize, pageHeightPx)
ResizeHandle.BOTTOM_LEFT -> {
l = (l + dx).coerceIn(0f, r - minSize)
b = (b + dy).coerceIn(t + minSize, pageHeightPx)
}
ResizeHandle.LEFT_CENTER -> l = (l + dx).coerceIn(0f, r - minSize)
else -> {}
}
currentRectPx = Rect(l, t, r, b)
}
}
) {
Box(
modifier = Modifier
.size(handleSize)
.background(handleColor, CircleShape)
.align(Alignment.Center)
)
}
}
DragPill(
isDarkMode = isDarkMode,
modifier = Modifier
.align(if (isHandleAtTop) Alignment.TopCenter else Alignment.BottomCenter)
.offset(y = if (isHandleAtTop) (-32).dp else 32.dp)
.zIndex(20f)
.pointerInput(pageWidthPx, pageHeightPx, onDragStart, onDragEnd, onDragCancel) {
detectDragGestures(
onDragStart = { offset ->
isDraggingOrResizing = true
onDragStart(offset)
},
onDragEnd = {
isDraggingOrResizing = false
val normalized = Rect(
left = currentRectPx.left / pageWidthPx,
top = currentRectPx.top / pageHeightPx,
right = currentRectPx.right / pageWidthPx,
bottom = currentRectPx.bottom / pageHeightPx
)
onBoundsChanged(normalized)
onDragEnd()
},
onDragCancel = {
isDraggingOrResizing = false
onDragCancel()
}
) { change, dragAmount ->
change.consume()
val w = currentRectPx.width
val h = currentRectPx.height
val rawLeft = currentRectPx.left + dragAmount.x
val rawTop = currentRectPx.top + dragAmount.y
val newLeft = rawLeft.coerceIn(0f, pageWidthPx - w)
val newTop = rawTop.coerceIn(0f, pageHeightPx - h)
val newRect = Rect(newLeft, newTop, newLeft + w, newTop + h)
currentRectPx = newRect
onDrag(dragAmount, newRect)
}
}
)
}
}
}
@Composable
private fun DragPill(
modifier: Modifier = Modifier,
isDarkMode: Boolean
) {
Surface(
modifier = modifier
.size(width = 48.dp, height = 24.dp),
shape = CircleShape,
color = if (isDarkMode) Color.White else Color.Black,
contentColor = if (isDarkMode) Color.Black else Color.White,
shadowElevation = 4.dp
) {
Box(contentAlignment = Alignment.Center) {
Icon(
painter = painterResource(id = R.drawable.drag_handle),
contentDescription = "Drag to move text box",
modifier = Modifier.size(20.dp)
)
}
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,553 @@
// PenIcons.kt
package com.aryan.reader.pdf
import android.graphics.BitmapShader
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.Shader
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathMeasure
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke as ComposeStroke
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import com.aryan.reader.pdf.data.PdfAnnotation
import android.graphics.Paint as NativePaint
private val BODY_COLOR = Color(0xFF454545)
private val SILVER_NIB_COLOR = Color(0xFFCFD8DC)
@Composable
fun PenIcon(
color: Color,
modifier: Modifier = Modifier,
type: PenType = PenType.FOUNTAIN_PEN,
isSelected: Boolean = false,
strokeWidth: Float = 0.005f,
forcedInkType: InkType? = null,
inkColor: Color? = null
) {
val animatedColor by animateColorAsState(targetValue = color, label = "color")
val targetInkColor = inkColor ?: color
val animatedInkColor by animateColorAsState(targetValue = targetInkColor, label = "ink_color")
val inkProgress by animateFloatAsState(
targetValue = if (isSelected) 1f else 0f,
animationSpec = tween(durationMillis = 600, easing = LinearEasing),
label = "ink_progress"
)
Canvas(modifier = modifier) {
val w = size.width
val h = size.height
val penWidth = w * 0.65f
val startX = (w - penWidth) / 2f
val tipHeight = h * 0.45f
val collarHeight = h * 0.15f
val bodyHeight = h * 0.35f
val topPadding = h * 0.05f
val tipRect = Rect(offset = Offset(startX, topPadding), size = Size(penWidth, tipHeight))
val collarRect = Rect(offset = Offset(startX, topPadding + tipHeight), size = Size(penWidth, collarHeight))
val bodyRect = Rect(offset = Offset(startX, topPadding + tipHeight + collarHeight), size = Size(penWidth, bodyHeight))
drawMatteCylinder(BODY_COLOR, bodyRect)
when (type) {
PenType.FOUNTAIN_PEN -> {
drawMatteCylinder(animatedColor, collarRect)
drawFountainNib(SILVER_NIB_COLOR, animatedColor, tipRect)
}
PenType.PENCIL -> {
drawMatteCylinder(animatedColor, collarRect)
drawPencilHead(animatedColor, tipRect)
}
PenType.MARKER -> {
drawMatteCylinder(animatedColor, collarRect)
drawMarkerHead(animatedColor, tipRect)
}
PenType.BRUSH -> {
drawMatteCylinder(animatedColor, collarRect)
drawBrushHead(
animatedColor,
Rect(offset = tipRect.topLeft, size = Size(tipRect.width, tipHeight + collarHeight))
)
}
PenType.HIGHLIGHTER -> {
drawHighlighterChiselParts(animatedColor, collarRect, tipRect)
}
PenType.HIGHLIGHTER_ROUND -> {
drawHighlighterRoundParts(animatedColor, collarRect, tipRect)
}
}
if (inkProgress > 0.01f) {
val tipX = size.width / 2f
val tipY = when (type) {
PenType.HIGHLIGHTER -> topPadding
PenType.HIGHLIGHTER_ROUND -> topPadding + tipHeight * 0.15f
else -> topPadding
}
drawInkSquiggle(
type = type,
forcedInkType = forcedInkType,
color = animatedInkColor, // Use the specific ink color
progress = inkProgress,
startPoint = Offset(tipX, tipY),
baseStrokeWidth = strokeWidth
)
}
}
}
// Helpers
private fun DrawScope.drawMatteCylinder(color: Color, rect: Rect) {
val gradient = Brush.horizontalGradient(
0.0f to color.darker(0.6f),
0.3f to color.lighter(0.1f),
0.5f to color,
0.85f to color.darker(0.5f),
1.0f to color.darker(0.7f),
startX = rect.left,
endX = rect.right
)
drawRect(brush = gradient, topLeft = rect.topLeft, size = rect.size)
}
private fun DrawScope.drawFountainNib(metalColor: Color, inkColor: Color, rect: Rect) {
val cx = rect.left + rect.width / 2
val path = Path().apply {
moveTo(rect.left + rect.width * 0.15f, rect.bottom)
lineTo(rect.right - rect.width * 0.15f, rect.bottom)
cubicTo(
rect.right - rect.width * 0.1f, rect.bottom - rect.height * 0.6f,
rect.right, rect.top + rect.height * 0.2f,
cx, rect.top
)
cubicTo(
rect.left, rect.top + rect.height * 0.2f,
rect.left + rect.width * 0.1f, rect.bottom - rect.height * 0.6f,
rect.left + rect.width * 0.15f, rect.bottom
)
close()
}
drawPath(
path = path,
brush = Brush.horizontalGradient(
0.0f to metalColor.darker(0.6f),
0.4f to Color.White,
0.6f to metalColor,
1.0f to metalColor.darker(0.6f),
startX = rect.left,
endX = rect.right
)
)
drawCircle(
color = Color.Black.copy(alpha=0.7f),
radius = rect.width * 0.06f,
center = Offset(cx, rect.bottom - rect.height * 0.5f)
)
drawLine(
color = Color.Black.copy(alpha=0.6f),
start = Offset(cx, rect.top),
end = Offset(cx, rect.bottom - rect.height * 0.5f),
strokeWidth = 2f
)
drawCircle(
color = inkColor.copy(alpha = 0.5f),
radius = rect.width * 0.04f,
center = Offset(cx, rect.bottom - rect.height * 0.5f)
)
}
private fun DrawScope.drawMarkerHead(inkColor: Color, rect: Rect) {
val cx = rect.left + rect.width / 2
val coneHeight = rect.height * 0.8f
val conePath = Path().apply {
moveTo(rect.left, rect.bottom)
lineTo(rect.right, rect.bottom)
lineTo(cx + rect.width * 0.15f, rect.top + (rect.height - coneHeight))
lineTo(cx - rect.width * 0.15f, rect.top + (rect.height - coneHeight))
close()
}
val plasticColor = Color(0xFF616161)
drawPath(
path = conePath,
brush = Brush.horizontalGradient(
0.0f to plasticColor.darker(0.5f),
0.5f to plasticColor,
1.0f to plasticColor.darker(0.5f),
startX = rect.left,
endX = rect.right
)
)
val tipPath = Path().apply {
moveTo(cx - rect.width * 0.15f, rect.top + (rect.height - coneHeight))
lineTo(cx + rect.width * 0.15f, rect.top + (rect.height - coneHeight))
quadraticTo(cx, rect.top, cx, rect.top) // Round tip
lineTo(cx - rect.width * 0.15f, rect.top + (rect.height - coneHeight))
}
drawPath(path = tipPath, color = inkColor)
}
private fun DrawScope.drawPencilHead(inkColor: Color, rect: Rect) {
val cx = rect.left + rect.width / 2
val woodColor = Color(0xFFFFCC80)
val woodPath = Path().apply {
moveTo(rect.left, rect.bottom)
val scallops = 3
val step = rect.width / scallops
for (i in 0 until scallops) {
quadraticTo(
rect.left + (i * step) + (step / 2), rect.bottom - (rect.width * 0.1f),
rect.left + ((i + 1) * step), rect.bottom
)
}
lineTo(cx + rect.width * 0.12f, rect.top + rect.height * 0.25f)
lineTo(cx - rect.width * 0.12f, rect.top + rect.height * 0.25f)
close()
}
drawPath(
path = woodPath,
brush = Brush.horizontalGradient(
0.0f to woodColor.darker(0.3f),
0.5f to woodColor.lighter(0.1f),
1.0f to woodColor.darker(0.3f),
startX = rect.left,
endX = rect.right
)
)
val leadPath = Path().apply {
moveTo(cx - rect.width * 0.12f, rect.top + rect.height * 0.25f)
lineTo(cx + rect.width * 0.12f, rect.top + rect.height * 0.25f)
lineTo(cx, rect.top)
close()
}
drawPath(path = leadPath, color = inkColor)
}
private fun DrawScope.drawBrushHead(inkColor: Color, rect: Rect) {
val cx = rect.left + rect.width / 2
val brushPath = Path().apply {
moveTo(rect.left + rect.width * 0.15f, rect.bottom)
lineTo(rect.right - rect.width * 0.15f, rect.bottom)
quadraticTo(rect.right, rect.bottom - rect.height * 0.4f, cx, rect.top)
quadraticTo(rect.left, rect.bottom - rect.height * 0.4f, rect.left + rect.width * 0.15f, rect.bottom)
close()
}
val gradient = Brush.radialGradient(
colors = listOf(inkColor.lighter(0.4f), inkColor.darker(0.6f)),
center = Offset(cx, rect.top + rect.height * 0.3f),
radius = rect.height
)
drawPath(path = brushPath, brush = gradient)
}
private fun DrawScope.drawHighlighterChiselParts(color: Color, collarRect: Rect, tipRect: Rect) {
drawMatteCylinder(color, collarRect)
val neckHeight = tipRect.height * 0.65f
val inkTipHeight = tipRect.height - neckHeight
val neckBottomY = tipRect.bottom
val neckTopY = tipRect.bottom - neckHeight
val cx = tipRect.center.x
val neckTopHalfWidth = tipRect.width * 0.25f
val neckPath = Path().apply {
moveTo(tipRect.left, neckBottomY)
lineTo(tipRect.right, neckBottomY)
lineTo(cx + neckTopHalfWidth, neckTopY)
lineTo(cx - neckTopHalfWidth, neckTopY)
close()
}
val neckGradient = Brush.horizontalGradient(
0.0f to BODY_COLOR.darker(0.6f),
0.3f to BODY_COLOR.lighter(0.1f),
0.5f to BODY_COLOR,
0.85f to BODY_COLOR.darker(0.5f),
1.0f to BODY_COLOR.darker(0.7f),
startX = tipRect.left,
endX = tipRect.right
)
drawPath(path = neckPath, brush = neckGradient)
val tipBottomY = neckTopY
val tipTopY = tipRect.top
val slantDrop = inkTipHeight * 0.4f
val tipPath = Path().apply {
moveTo(cx - neckTopHalfWidth, tipBottomY)
lineTo(cx + neckTopHalfWidth, tipBottomY)
lineTo(cx + neckTopHalfWidth, tipTopY + slantDrop)
lineTo(cx - neckTopHalfWidth, tipTopY)
close()
}
drawPath(
path = tipPath,
brush = Brush.horizontalGradient(
0.0f to color.darker(0.8f),
0.5f to color,
1.0f to color.darker(0.8f),
startX = cx - neckTopHalfWidth,
endX = cx + neckTopHalfWidth
)
)
val facePath = Path().apply {
moveTo(cx - neckTopHalfWidth, tipTopY)
lineTo(cx + neckTopHalfWidth, tipTopY + slantDrop)
quadraticTo(cx, tipTopY + slantDrop * 0.5f, cx - neckTopHalfWidth, tipTopY)
close()
}
drawPath(path = facePath, color = color.lighter(0.2f))
}
private fun DrawScope.drawHighlighterRoundParts(color: Color, collarRect: Rect, tipRect: Rect) {
drawMatteCylinder(color, collarRect)
val neckHeight = tipRect.height * 0.65f
val neckBottomY = tipRect.bottom
val neckTopY = tipRect.bottom - neckHeight
val cx = tipRect.center.x
val neckTopHalfWidth = tipRect.width * 0.25f
val neckPath = Path().apply {
moveTo(tipRect.left, neckBottomY)
lineTo(tipRect.right, neckBottomY)
lineTo(cx + neckTopHalfWidth, neckTopY)
lineTo(cx - neckTopHalfWidth, neckTopY)
close()
}
val neckGradient = Brush.horizontalGradient(
0.0f to BODY_COLOR.darker(0.6f),
0.3f to BODY_COLOR.lighter(0.1f),
0.5f to BODY_COLOR,
0.85f to BODY_COLOR.darker(0.5f),
1.0f to BODY_COLOR.darker(0.7f),
startX = tipRect.left,
endX = tipRect.right
)
drawPath(path = neckPath, brush = neckGradient)
neckTopHalfWidth * 2
val tipHeight = tipRect.height - neckHeight
val domeRect = Rect(
left = cx - neckTopHalfWidth,
top = neckTopY - tipHeight,
right = cx + neckTopHalfWidth,
bottom = neckTopY
)
val domePath = Path().apply {
moveTo(domeRect.left, domeRect.bottom)
lineTo(domeRect.right, domeRect.bottom)
arcTo(
rect = domeRect,
startAngleDegrees = 0f,
sweepAngleDegrees = -180f,
forceMoveTo = false
)
close()
}
drawPath(
path = domePath,
brush = Brush.radialGradient(
colors = listOf(color.lighter(0.3f), color, color.darker(0.6f)),
center = Offset(domeRect.center.x - domeRect.width * 0.2f, domeRect.top + domeRect.height * 0.4f),
radius = domeRect.width
)
)
}
private fun DrawScope.drawInkSquiggle(
type: PenType,
forcedInkType: InkType?,
color: Color,
progress: Float,
startPoint: Offset,
baseStrokeWidth: Float
) {
val x = startPoint.x
val y = startPoint.y - 2f
val path = Path().apply {
moveTo(x, y)
if (type == PenType.HIGHLIGHTER || type == PenType.HIGHLIGHTER_ROUND) {
val waveWidth = 70f
val amplitude = 20f
cubicTo(
x + waveWidth * 0.35f, y - amplitude,
x + waveWidth * 0.65f, y + amplitude,
x + waveWidth, y
)
} else {
cubicTo(
x + 35f, y - 40f,
x - 35f, y - 90f,
x - 15f, y - 45f
)
cubicTo(
x - 5f, y - 10f,
x + 50f, y - 25f,
x + 70f, y - 55f
)
}
}
val inkType = forcedInkType ?: when(type) {
PenType.FOUNTAIN_PEN -> InkType.FOUNTAIN_PEN
PenType.PENCIL -> InkType.PENCIL
PenType.MARKER -> InkType.PEN
PenType.HIGHLIGHTER, PenType.HIGHLIGHTER_ROUND -> InkType.HIGHLIGHTER
else -> InkType.PEN
}
val pathMeasure = PathMeasure()
pathMeasure.setPath(path, false)
val length = pathMeasure.length
val targetLength = length * progress
val pointCount = (targetLength / 2f).toInt().coerceAtLeast(2)
val points = ArrayList<PdfPoint>(pointCount)
var currentTime = 0L
for (i in 0 until pointCount) {
val distance = (i.toFloat() / pointCount) * targetLength
val timeDelta = 15L
currentTime += timeDelta
pathMeasure.getPosition(distance).let { offset ->
points.add(PdfPoint(offset.x, offset.y, timestamp = currentTime))
}
}
if (points.isEmpty()) return
val simulationScale = 1000f
val strokeMultiplier = if (type == PenType.HIGHLIGHTER || type == PenType.HIGHLIGHTER_ROUND) 1.0f else 1f
val scaledStrokeWidth = baseStrokeWidth * simulationScale * strokeMultiplier
val annotation = PdfAnnotation(
type = AnnotationType.INK,
inkType = inkType,
pageIndex = 0,
points = points,
color = color,
strokeWidth = scaledStrokeWidth
)
val renderData = PdfAnnotationRenderHelper.createRenderData(
annot = annotation,
widthPx = 1,
heightPx = 1
)
if (renderData != null) {
when (renderData) {
is AnnotationRenderData.Standard -> {
val effectiveBlendMode = if (type == PenType.HIGHLIGHTER || type == PenType.HIGHLIGHTER_ROUND) {
BlendMode.SrcOver
} else if (renderData.blendMode == BlendMode.Darken) {
BlendMode.SrcOver
} else {
renderData.blendMode
}
// Handle caps for specific highlighters
val strokeCap = when (type) {
PenType.HIGHLIGHTER -> StrokeCap.Square
PenType.HIGHLIGHTER_ROUND -> StrokeCap.Round
else -> renderData.cap
}
drawPath(
path = renderData.path,
color = renderData.color,
style = ComposeStroke(
width = renderData.strokeWidth,
cap = strokeCap,
join = StrokeJoin.Round
),
blendMode = effectiveBlendMode
)
}
is AnnotationRenderData.Fountain -> {
drawPath(
path = renderData.path,
color = renderData.color,
style = androidx.compose.ui.graphics.drawscope.Fill
)
}
is AnnotationRenderData.Pencil -> {
val texture = PdfTextureGenerator.getNoiseTexture()
drawIntoCanvas { canvas ->
val paint = NativePaint().apply {
isAntiAlias = true
style = NativePaint.Style.STROKE
strokeCap = NativePaint.Cap.ROUND
strokeJoin = NativePaint.Join.ROUND
strokeWidth = renderData.strokeWidth
shader = BitmapShader(
texture, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT
)
colorFilter = PorterDuffColorFilter(
renderData.color.toArgb(), PorterDuff.Mode.SRC_IN
)
alpha = (renderData.color.alpha * renderData.velocityAlpha * 255).toInt()
}
canvas.nativeCanvas.drawPath(renderData.path, paint)
}
}
}
}
}
enum class PenType {
FOUNTAIN_PEN, PENCIL, MARKER, BRUSH, HIGHLIGHTER, HIGHLIGHTER_ROUND
}
fun Color.darker(factor: Float = 0.7f): Color {
return Color(
red = this.red * factor,
green = this.green * factor,
blue = this.blue * factor,
alpha = this.alpha
)
}
fun Color.lighter(factor: Float = 0.3f): Color {
val r = this.red + (1 - this.red) * factor
val g = this.green + (1 - this.green) * factor
val b = this.blue + (1 - this.blue) * factor
return Color(r, g, b, this.alpha)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,250 @@
// SvgToAnnotationConverter.kt
@file:Suppress("SameParameterValue")
package com.aryan.reader.pdf
import android.content.Context
import android.graphics.Path
import android.util.Xml
import androidx.compose.ui.graphics.Color
import androidx.core.graphics.PathParser
import com.aryan.reader.pdf.data.PdfAnnotation
import org.xmlpull.v1.XmlPullParser
import timber.log.Timber
import java.util.Stack
import kotlin.math.hypot
import androidx.core.graphics.toColorInt
object SvgToAnnotationConverter {
private data class SvgStyle(
val strokeColor: Color? = null,
val strokeWidth: Float? = null,
val fill: Color? = null,
val opacity: Float = 1.0f
)
fun importSvgFromAssets(
context: Context,
fileName: String,
pageIndex: Int,
): List<PdfAnnotation> {
val annotations = mutableListOf<PdfAnnotation>()
var currentTime = System.currentTimeMillis()
try {
context.assets.open(fileName).use { inputStream ->
val parser = Xml.newPullParser()
parser.setInput(inputStream, null)
var eventType = parser.eventType
var viewBoxWidth = 800f
@Suppress("VariableNeverRead") var viewBoxHeight = 300f
val styleStack = Stack<SvgStyle>()
styleStack.push(SvgStyle(strokeColor = Color.Black, strokeWidth = 2f))
val targetWidthPercent = 0.8f
val startX = (1f - targetWidthPercent) / 2f
val startY = 0.3f
var scale = 1f
while (eventType != XmlPullParser.END_DOCUMENT) {
val tagName = parser.name
when (eventType) {
XmlPullParser.START_TAG -> {
if (tagName.equals("svg", ignoreCase = true)) {
val viewBox = parser.getAttributeValue(null, "viewBox")
if (viewBox != null) {
val parts = viewBox.split(" ").mapNotNull { it.toFloatOrNull() }
if (parts.size == 4) {
viewBoxWidth = parts[2]
viewBoxHeight = parts[3]
}
}
scale = targetWidthPercent / viewBoxWidth
}
val rawStroke = parser.getAttributeValue(null, "stroke")
val rawStrokeWidth = parser.getAttributeValue(null, "stroke-width")?.toFloatOrNull()
val rawFill = parser.getAttributeValue(null, "fill")
val rawOpacity = parser.getAttributeValue(null, "opacity")?.toFloatOrNull() ?: 1.0f
val strokeColor = parseSvgColor(rawStroke)
val fillColor = parseSvgColor(rawFill)
val parentStyle = styleStack.peek()
val currentStyle = SvgStyle(
strokeColor = strokeColor ?: parentStyle.strokeColor,
strokeWidth = rawStrokeWidth ?: parentStyle.strokeWidth,
fill = fillColor ?: parentStyle.fill,
opacity = rawOpacity * parentStyle.opacity
)
if (tagName.equals("g", ignoreCase = true)) {
styleStack.push(currentStyle)
}
if (tagName.equals("circle", ignoreCase = true)) {
val cx = parser.getAttributeValue(null, "cx")?.toFloatOrNull() ?: 0f
val cy = parser.getAttributeValue(null, "cy")?.toFloatOrNull() ?: 0f
val r = parser.getAttributeValue(null, "r")?.toFloatOrNull() ?: 0f
if (r > 0) {
val finalColor = (currentStyle.fill ?: currentStyle.strokeColor ?: Color.Black)
.copy(alpha = currentStyle.opacity)
val pdfDiameter = (2 * r) * scale
val pdfCx = startX + (cx * scale)
val pdfCy = startY + (cy * scale)
val points = listOf(
PdfPoint(pdfCx, pdfCy, currentTime),
PdfPoint(pdfCx + 0.00001f, pdfCy, currentTime + 1)
)
annotations.add(
PdfAnnotation(
type = AnnotationType.INK,
inkType = InkType.PEN,
pageIndex = pageIndex,
points = points,
color = finalColor,
strokeWidth = pdfDiameter
)
)
currentTime += 5
}
}
// --- PATH HANDLING ---
if (tagName.equals("path", ignoreCase = true)) {
val d = parser.getAttributeValue(null, "d")
if (!d.isNullOrBlank()) {
val subPathDataStrings = d.split(Regex("(?=[Mm])")).filter { it.isNotBlank() }
subPathDataStrings.forEach { subPathData ->
try {
val finalColor = (currentStyle.strokeColor ?: currentStyle.fill ?: Color.Black)
.copy(alpha = currentStyle.opacity)
val svgStrokeWidth = currentStyle.strokeWidth ?: 1f
val pdfStrokeWidth = svgStrokeWidth * scale
val path = PathParser.createPathFromPathData(subPathData)
val points = flattenPathToPdfPoints(
path = path,
scale = scale,
offsetX = startX,
offsetY = startY,
baseTime = currentTime
)
if (points.isNotEmpty()) {
annotations.add(
PdfAnnotation(
type = AnnotationType.INK,
inkType = InkType.PEN,
pageIndex = pageIndex,
points = points,
color = finalColor,
strokeWidth = pdfStrokeWidth
)
)
currentTime += points.size
}
} catch (e: Exception) {
Timber.e(e, "Failed to parse sub-path data")
}
}
}
}
}
XmlPullParser.END_TAG -> {
if (tagName.equals("g", ignoreCase = true)) {
if (styleStack.size > 1) {
styleStack.pop()
}
}
}
}
eventType = parser.next()
}
}
} catch (e: Exception) {
Timber.e(e, "Error importing SVG")
}
return annotations
}
private fun parseSvgColor(hexOrName: String?): Color? {
if (hexOrName.isNullOrBlank() || hexOrName.equals("none", ignoreCase = true)) return null
return try {
Color(hexOrName.toColorInt())
} catch (_: Exception) {
null
}
}
private fun flattenPathToPdfPoints(
path: Path,
scale: Float,
offsetX: Float,
offsetY: Float,
baseTime: Long
): List<PdfPoint> {
val coords = path.approximate(0.5f)
val rawPoints = mutableListOf<PdfPoint>()
var timeOffset = 0L
var i = 0
while (i < coords.size) {
val x = coords[i + 1]
val y = coords[i + 2]
val pdfX = offsetX + (x * scale)
val pdfY = offsetY + (y * scale)
rawPoints.add(PdfPoint(pdfX, pdfY, baseTime + timeOffset))
timeOffset++
i += 3
}
return densifyPoints(rawPoints, threshold = 0.001f)
}
private fun densifyPoints(points: List<PdfPoint>, threshold: Float): List<PdfPoint> {
if (points.size < 2) return points
val result = mutableListOf<PdfPoint>()
result.add(points[0])
for (i in 0 until points.size - 1) {
val p1 = points[i]
val p2 = points[i + 1]
val dist = hypot(p2.x - p1.x, p2.y - p1.y)
if (dist > threshold) {
val steps = (dist / threshold).toInt()
for (j in 1..steps) {
val fraction = j.toFloat() / (steps + 1)
val newX = p1.x + (p2.x - p1.x) * fraction
val newY = p1.y + (p2.y - p1.y) * fraction
val newTime = p1.timestamp + ((p2.timestamp - p1.timestamp) * fraction).toLong()
result.add(PdfPoint(newX, newY, newTime))
}
}
result.add(p2)
}
return result
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,989 @@
// ToolSettingsPopup.kt
package com.aryan.reader.pdf
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.drag
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.graphics.toColorInt
import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ToolSettingsPopup(
selectedTool: InkType,
activeToolThickness: Float,
fountainPenColor: Color,
markerColor: Color,
pencilColor: Color,
highlighterColor: Color,
highlighterRoundColor: Color,
activePalette: List<Color>,
onToolTypeChanged: (InkType) -> Unit,
onColorChanged: (Color) -> Unit,
onThicknessChanged: (Float) -> Unit,
onPaletteChange: (List<Color>) -> Unit,
modifier: Modifier = Modifier
) {
val isHighlighter = selectedTool == InkType.HIGHLIGHTER || selectedTool == InkType.HIGHLIGHTER_ROUND
val activeColor = when (selectedTool) {
InkType.FOUNTAIN_PEN -> fountainPenColor
InkType.PEN -> markerColor
InkType.PENCIL -> pencilColor
InkType.HIGHLIGHTER -> highlighterColor
InkType.HIGHLIGHTER_ROUND -> highlighterRoundColor
else -> markerColor
}
val currentAlpha = activeColor.alpha
val safeOnColorChanged: (Color) -> Unit = { newColor ->
if (isHighlighter) {
onColorChanged(newColor.copy(alpha = currentAlpha))
} else {
onColorChanged(newColor)
}
}
// Thickness settings
val thicknessRange = if (isHighlighter) 0.01f..0.06f else 0.001f..0.015f
@Suppress("UnusedExpression") if (isHighlighter) 0.005f else 0.001f
var showColorPicker by remember { mutableStateOf(false) }
var colorPickerSlotIndex by remember { mutableIntStateOf(-1) }
val currentOnColorChanged by rememberUpdatedState(safeOnColorChanged)
val selectedPaletteIndex = remember(activePalette, activeColor, isHighlighter) {
activePalette.indexOfFirst { paletteColor ->
if (isHighlighter) {
paletteColor.copy(alpha = 1f) == activeColor.copy(alpha = 1f)
} else {
paletteColor == activeColor
}
}
}
val circleSize = 28.dp
Surface(
modifier = modifier
.width(360.dp)
.padding(12.dp),
shape = RoundedCornerShape(28.dp),
color = Color(0xFF1E1E1E),
shadowElevation = 12.dp,
tonalElevation = 0.dp
) {
Column(
modifier = Modifier.padding(20.dp), // Reduced padding
horizontalAlignment = Alignment.CenterHorizontally
) {
// Pen Type Selector
Box(
modifier = Modifier
.fillMaxWidth()
.height(125.dp),
contentAlignment = Alignment.BottomCenter
) {
Row(
horizontalArrangement = Arrangement.spacedBy(28.dp),
verticalAlignment = Alignment.Bottom
) {
if (isHighlighter) {
PenItem(
type = PenType.HIGHLIGHTER,
forcedInkType = InkType.HIGHLIGHTER,
color = highlighterColor.copy(alpha = 1f),
inkColor = highlighterColor,
isSelected = selectedTool == InkType.HIGHLIGHTER,
strokeWidth = activeToolThickness,
onClick = { onToolTypeChanged(InkType.HIGHLIGHTER) }
)
PenItem(
type = PenType.HIGHLIGHTER_ROUND,
forcedInkType = InkType.HIGHLIGHTER_ROUND,
color = highlighterRoundColor.copy(alpha = 1f),
inkColor = highlighterRoundColor,
isSelected = selectedTool == InkType.HIGHLIGHTER_ROUND,
strokeWidth = activeToolThickness,
onClick = { onToolTypeChanged(InkType.HIGHLIGHTER_ROUND) }
)
} else {
PenItem(
type = PenType.FOUNTAIN_PEN,
forcedInkType = InkType.FOUNTAIN_PEN,
color = fountainPenColor,
isSelected = selectedTool == InkType.FOUNTAIN_PEN,
strokeWidth = activeToolThickness,
onClick = { onToolTypeChanged(InkType.FOUNTAIN_PEN) }
)
PenItem(
type = PenType.MARKER,
forcedInkType = InkType.PEN,
color = markerColor,
isSelected = selectedTool == InkType.PEN,
strokeWidth = activeToolThickness,
onClick = { onToolTypeChanged(InkType.PEN) }
)
PenItem(
type = PenType.PENCIL,
forcedInkType = InkType.PENCIL,
color = pencilColor,
isSelected = selectedTool == InkType.PENCIL,
strokeWidth = activeToolThickness,
onClick = { onToolTypeChanged(InkType.PENCIL) }
)
}
}
}
Spacer(Modifier.height(16.dp))
// THICKNESS SLIDER
StyledPropertySlider(
value = activeToolThickness,
onValueChange = onThicknessChanged,
valueRange = thicknessRange, isOpacity = false,
trackColor = Color(0xFF424242),
thumbColor = Color(0xFF757575),
activeColor = activeColor
)
// Darkness (Opacity) Slider for Highlighters
if (isHighlighter) {
Spacer(Modifier.height(16.dp))
StyledPropertySlider(
value = currentAlpha,
onValueChange = { newAlpha ->
onColorChanged(activeColor.copy(alpha = newAlpha))
},
valueRange = 0.1f..1.0f, isOpacity = true,
trackColor = activeColor.copy(alpha = 1f),
thumbColor = activeColor.copy(alpha = 1f),
activeColor = activeColor
)
}
Spacer(Modifier.height(16.dp)) // Reduced spacing
// --- Color Palette ---
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.SpaceBetween
) {
activePalette.take(6).forEachIndexed { index, color ->
val isSelected = index == selectedPaletteIndex
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(circleSize)
.testTag("Palette_Item_$index")
.pointerInput(color) {
detectTapGestures(
onTap = {
currentOnColorChanged(color)
},
onLongPress = {
colorPickerSlotIndex = index
showColorPicker = true
}
)
}
) {
Canvas(modifier = Modifier.fillMaxSize()) {
drawCircle(color = color.copy(alpha = 1f))
if (isSelected) {
drawCircle(
color = Color.White,
radius = size.minDimension / 2,
style = Stroke(width = 2.dp.toPx())
)
}
}
}
}
}
Spacer(Modifier.width(16.dp))
// Divider
Box(
modifier = Modifier
.width(1.dp)
.height(circleSize)
.background(Color.White.copy(alpha = 0.15f))
)
Spacer(Modifier.width(16.dp))
// Spectrum / Color Wheel Button
val rainbowColors = listOf(
Color.Red, Color.Magenta, Color.Blue, Color.Cyan, Color.Green, Color.Yellow, Color.Red
)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(circleSize)
.clip(CircleShape)
.background(Brush.sweepGradient(rainbowColors))
.clickable {
if (selectedPaletteIndex != -1) {
colorPickerSlotIndex = selectedPaletteIndex
showColorPicker = true
}
}
) {}
}
}
}
if (showColorPicker && colorPickerSlotIndex != -1) {
val initialColor = activePalette.getOrElse(colorPickerSlotIndex) { Color.Black }
ColorPickerDialog(
initialColor = initialColor,
onDismiss = { showColorPicker = false },
onColorSelected = { newColor ->
val mutableList = activePalette.toMutableList()
if (colorPickerSlotIndex in mutableList.indices) {
mutableList[colorPickerSlotIndex] = newColor
onPaletteChange(mutableList)
if (isHighlighter) {
onColorChanged(newColor.copy(alpha = currentAlpha))
} else {
onColorChanged(newColor)
}
}
showColorPicker = false
}
)
}
}
@Composable
private fun ColorPickerDialog(
initialColor: Color,
onDismiss: () -> Unit,
onColorSelected: (Color) -> Unit
) {
val lockedInitialColor = remember { initialColor }
val initialHsv = remember(initialColor) {
val hsv = FloatArray(3)
android.graphics.Color.colorToHSV(initialColor.toArgb(), hsv)
hsv
}
var hue by remember { mutableFloatStateOf(initialHsv[0]) }
var saturation by remember { mutableFloatStateOf(initialHsv[1]) }
var value by remember { mutableFloatStateOf(initialHsv[2]) }
val alpha = 1.0f
val currentColor by remember {
derivedStateOf {
val hsv = floatArrayOf(hue, saturation, value)
val argb = android.graphics.Color.HSVToColor((alpha * 255).toInt(), hsv)
Color(argb)
}
}
fun updateFromColor(color: Color) {
val hsv = FloatArray(3)
android.graphics.Color.colorToHSV(color.toArgb(), hsv)
hue = hsv[0]
saturation = hsv[1]
value = hsv[2]
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Surface(
shape = RoundedCornerShape(24.dp),
color = Color(0xFF2C2C2C),
modifier = Modifier
.fillMaxWidth(0.85f)
.padding(8.dp)
) {
Column(
modifier = Modifier.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.background(Color(0xFF3E3E3E), RoundedCornerShape(16.dp))
.padding(horizontal = 24.dp, vertical = 8.dp)
) {
Text(
text = "Spectrum",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
}
Spacer(Modifier.height(20.dp))
SpectrumBox(
hue = hue,
saturation = saturation,
currentColor = currentColor,
onHueSatChanged = { h, s ->
hue = h
saturation = s
},
modifier = Modifier
.fillMaxWidth()
.height(220.dp)
)
Spacer(Modifier.height(20.dp))
BrightnessSlider(
hue = hue,
saturation = saturation,
value = value,
onValueChanged = { value = it },
modifier = Modifier
.fillMaxWidth()
.height(24.dp)
.clip(RoundedCornerShape(12.dp))
)
Spacer(Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
ColorComparePill(
oldColor = lockedInitialColor,
newColor = currentColor,
modifier = Modifier
.width(64.dp)
.height(36.dp)
)
Column(
modifier = Modifier.weight(1.6f),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
"Hex",
color = Color.Gray,
fontSize = 12.sp,
maxLines = 1
)
Spacer(Modifier.height(4.dp))
HexInput(
color = currentColor,
onHexChanged = { updateFromColor(it) }
)
}
Row(
modifier = Modifier.weight(2.4f),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
RgbInputColumn(
label = "Red",
value = currentColor.red,
onValueChange = { r -> updateFromColor(currentColor.copy(red = r)) },
modifier = Modifier.weight(1f)
)
RgbInputColumn(
label = "Green",
value = currentColor.green,
onValueChange = { g -> updateFromColor(currentColor.copy(green = g)) },
modifier = Modifier.weight(1f)
)
RgbInputColumn(
label = "Blue",
value = currentColor.blue,
onValueChange = { b -> updateFromColor(currentColor.copy(blue = b)) },
modifier = Modifier.weight(1f)
)
}
}
Spacer(Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = onDismiss) {
Text("Cancel", color = Color.Gray)
}
Spacer(Modifier.width(8.dp))
Button(
onClick = { onColorSelected(currentColor) },
colors = ButtonDefaults.buttonColors(
containerColor = Color.White,
contentColor = Color.Black
)
) {
Text("Done")
}
}
}
}
}
}
@Composable
private fun SpectrumBox(
hue: Float,
saturation: Float,
currentColor: Color,
onHueSatChanged: (Float, Float) -> Unit,
modifier: Modifier = Modifier
) {
val rainbowColors = listOf(
Color.Red, Color.Yellow, Color.Green, Color.Cyan, Color.Blue, Color.Magenta, Color.Red
)
val touchPadding = 12.dp
Box(
modifier = modifier.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown()
val paddingPx = touchPadding.toPx()
val activeWidth = size.width.toFloat() - (paddingPx * 2)
val activeHeight = size.height.toFloat() - (paddingPx * 2)
fun update(offset: Offset) {
val relativeX = offset.x - paddingPx
val relativeY = offset.y - paddingPx
val h = (relativeX / activeWidth).coerceIn(0f, 1f) * 360f
val s = (relativeY / activeHeight).coerceIn(0f, 1f)
onHueSatChanged(h, s)
}
update(down.position)
drag(down.id) { change ->
change.consume()
update(change.position)
}
}
}
) {
Canvas(
modifier = Modifier
.fillMaxSize()
.padding(touchPadding)
.clip(RoundedCornerShape(12.dp))
) {
drawRect(
brush = Brush.horizontalGradient(rainbowColors)
)
drawRect(
brush = Brush.verticalGradient(
colors = listOf(Color.White, Color.White.copy(alpha = 0f))
)
)
}
Canvas(modifier = Modifier.fillMaxSize()) {
val paddingPx = touchPadding.toPx()
val activeWidth = size.width - (paddingPx * 2)
val activeHeight = size.height - (paddingPx * 2)
val x = paddingPx + (hue / 360f) * activeWidth
val y = paddingPx + saturation * activeHeight
val pointerRadius = 10.dp.toPx()
val strokeWidth = 2.dp.toPx()
drawCircle(
color = Color.Black.copy(alpha = 0.25f),
radius = pointerRadius + 1.dp.toPx(),
center = Offset(x, y + 1.dp.toPx())
)
drawCircle(
color = currentColor.copy(alpha = 1f),
radius = pointerRadius,
center = Offset(x, y)
)
drawCircle(
color = Color.White,
radius = pointerRadius,
center = Offset(x, y),
style = Stroke(width = strokeWidth)
)
}
}
}
@Composable
private fun BrightnessSlider(
hue: Float,
saturation: Float,
value: Float,
onValueChanged: (Float) -> Unit,
modifier: Modifier = Modifier
) {
val baseColor = remember(hue, saturation) {
Color.hsv(hue, saturation, 1f)
}
Box(
modifier = modifier.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown()
fun update(offset: Offset) {
val v = (offset.x / size.width.toFloat()).coerceIn(0f, 1f)
onValueChanged(v)
}
update(down.position)
drag(down.id) { change ->
change.consume()
update(change.position)
}
}
}
) {
Canvas(modifier = Modifier.fillMaxSize()) {
drawRect(
brush = Brush.horizontalGradient(
colors = listOf(Color.Black, baseColor)
)
)
val x = value * size.width
drawCircle(
color = Color.White,
radius = 8.dp.toPx(),
center = Offset(x, size.height / 2)
)
}
}
}
@Composable
private fun RgbInputColumn(
label: String,
value: Float,
onValueChange: (Float) -> Unit,
modifier: Modifier = Modifier
) {
val intValue = (value * 255).roundToInt()
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
) {
Text(
text = label,
color = Color.Gray,
fontSize = 11.sp,
maxLines = 1
)
Spacer(Modifier.height(4.dp))
RgbInput(value = intValue, onValueChange = onValueChange)
}
}
@Composable
private fun RgbInput(
value: Int,
onValueChange: (Float) -> Unit
) {
var text by remember(value) { mutableStateOf(value.toString()) }
LaunchedEffect(value) {
text = value.toString()
}
BasicTextField(
value = text,
onValueChange = { newText ->
if (newText.length <= 3 && newText.all { it.isDigit() }) {
val intVal = newText.toIntOrNull()
if (intVal != null) {
onValueChange(intVal.coerceIn(0, 255) / 255f)
}
}
},
textStyle = TextStyle(
color = Color.White,
textAlign = TextAlign.Center,
fontSize = 13.sp
),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.height(36.dp)
.background(Color(0xFF3E3E3E), RoundedCornerShape(8.dp))
.padding(vertical = 9.dp)
)
}
@Composable
private fun HexInput(
color: Color,
onHexChanged: (Color) -> Unit
) {
val hexValue = remember(color) {
String.format("%06X", (0xFFFFFF and color.toArgb()))
}
var text by remember(hexValue) { mutableStateOf(hexValue) }
LaunchedEffect(color) {
val currentParsed = try {
Color(("#$text").toColorInt())
} catch (_: Exception) {
null
}
if (currentParsed?.toArgb() != color.toArgb()) {
text = String.format("%06X", (0xFFFFFF and color.toArgb()))
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.height(36.dp)
.background(Color(0xFF3E3E3E), RoundedCornerShape(8.dp))
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
text = "#",
color = Color.Gray,
fontSize = 13.sp,
fontWeight = FontWeight.Bold
)
BasicTextField(
value = text,
onValueChange = { newText ->
if (newText.length <= 6) {
val uppercased = newText.uppercase()
if (uppercased.all { it.isDigit() || it in 'A'..'F' }) {
text = uppercased
if (uppercased.length == 6) {
try {
val parsedColorInt = "#$uppercased".toColorInt()
val newColor = Color(parsedColorInt)
onHexChanged(newColor)
} catch (_: Exception) {
}
}
}
}
},
textStyle = TextStyle(
color = Color.White,
textAlign = TextAlign.Start,
fontSize = 13.sp
),
singleLine = true,
cursorBrush = SolidColor(Color.White),
modifier = Modifier
.padding(start = 2.dp)
.width(50.dp)
)
}
}
@Composable
private fun ColorComparePill(
oldColor: Color,
newColor: Color,
modifier: Modifier = Modifier
) {
Canvas(modifier = modifier.clip(RoundedCornerShape(8.dp))) {
drawRect(
color = oldColor.copy(alpha = 1f),
size = androidx.compose.ui.geometry.Size(size.width / 2, size.height)
)
drawRect(
color = newColor.copy(alpha = 1f),
topLeft = Offset(size.width / 2, 0f),
size = androidx.compose.ui.geometry.Size(size.width / 2, size.height)
)
}
}
@Composable
private fun PenItem(
type: PenType,
color: Color,
isSelected: Boolean,
strokeWidth: Float,
onClick: () -> Unit,
forcedInkType: InkType? = null,
inkColor: Color? = null
) {
val scale by animateFloatAsState(
targetValue = if (isSelected) 1.15f else 0.9f, label = "scale"
)
Box(
modifier = Modifier
.width(44.dp)
.height(100.dp)
.scale(scale)
.testTag("SettingsItem_${type.name}")
.semantics { this.selected = isSelected }
.clickable(onClick = onClick),
contentAlignment = Alignment.BottomCenter
) {
PenIcon(
color = color,
inkColor = inkColor,
type = type,
isSelected = isSelected,
strokeWidth = strokeWidth,
forcedInkType = forcedInkType,
modifier = Modifier.fillMaxSize()
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun StyledPropertySlider(
value: Float,
onValueChange: (Float) -> Unit,
valueRange: ClosedFloatingPointRange<Float>, isOpacity: Boolean,
trackColor: Color,
thumbColor: Color,
activeColor: Color
) {
val displayValue = remember(value, valueRange) {
val fraction = (value - valueRange.start) / (valueRange.endInclusive - valueRange.start)
(fraction * 100).roundToInt().coerceIn(1, 100)
}
val onePercentDelta = (valueRange.endInclusive - valueRange.start) / 100f
val canDecrease = value > valueRange.start + 0.0001f
val canIncrease = value < valueRange.endInclusive - 0.0001f
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
// Minus Button
Box(
modifier = Modifier
.size(32.dp)
.testTag("Property_Minus")
.clickable(enabled = canDecrease) {
val newValue = (value - onePercentDelta).coerceAtLeast(valueRange.start)
onValueChange(newValue)
},
contentAlignment = Alignment.Center
) {
Text(
text = "",
color = if (canDecrease) Color.White else Color.White.copy(alpha = 0.3f),
fontSize = 18.sp,
fontWeight = FontWeight.Bold
)
}
Spacer(modifier = Modifier.width(4.dp))
// Custom Slider
Box(modifier = Modifier.weight(1f)) {
Slider(
value = value,
onValueChange = onValueChange,
valueRange = valueRange,
colors = SliderDefaults.colors(
thumbColor = Color.Transparent,
activeTrackColor = Color.Transparent,
inactiveTrackColor = Color.Transparent
),
modifier = Modifier.height(32.dp),
thumb = {
Surface(
shape = CircleShape,
color = thumbColor,
modifier = Modifier
.size(26.dp)
.padding(2.dp),
shadowElevation = 4.dp,
border = if (isOpacity) null else androidx.compose.foundation.BorderStroke(1.dp, Color.Gray)
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = displayValue.toString(),
color = Color.White,
fontSize = 10.sp,
fontWeight = FontWeight.Bold
)
}
}
},
track = { _ ->
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(16.dp)
) {
val trackHeight = size.height
val cornerRadius = CornerRadius(trackHeight / 2)
if (isOpacity) {
drawRoundRect(
color = Color.Gray,
size = size,
cornerRadius = cornerRadius
)
val clipPath = androidx.compose.ui.graphics.Path().apply {
addRoundRect(
androidx.compose.ui.geometry.RoundRect(
rect = androidx.compose.ui.geometry.Rect(Offset.Zero, size),
cornerRadius = cornerRadius
)
)
}
clipPath(clipPath) {
val boxSize = 12f
val columns = (size.width / boxSize).toInt() + 1
val rows = (size.height / boxSize).toInt() + 1
for (col in 0 until columns) {
for (row in 0 until rows) {
val color = if ((col + row) % 2 == 0) Color(0xFF555555) else Color(0xFF333333)
drawRect(
color = color,
topLeft = Offset(col * boxSize, row * boxSize),
size = androidx.compose.ui.geometry.Size(boxSize, boxSize)
)
}
}
drawRect(color = activeColor)
}
} else {
drawRoundRect(
color = trackColor.copy(alpha = 0.5f),
size = size,
cornerRadius = cornerRadius
)
val dotRadius = 1.5.dp.toPx()
val padding = trackHeight / 2
val availableWidth = size.width - (padding * 2)
val dotCount = 8
val spacing = availableWidth / (dotCount - 1)
for (i in 0 until dotCount) {
drawCircle(
color = Color.White.copy(alpha = 0.2f),
radius = dotRadius,
center = Offset(padding + (i * spacing), size.height / 2)
)
}
}
}
}
)
}
Spacer(modifier = Modifier.width(4.dp))
// Plus Button
Box(
modifier = Modifier
.size(32.dp)
.testTag("Property_Plus")
.clickable(enabled = canIncrease) {
val newValue = (value + onePercentDelta).coerceAtMost(valueRange.endInclusive)
onValueChange(newValue)
},
contentAlignment = Alignment.Center
) {
Text(
text = "+",
color = if (canIncrease) Color.White else Color.White.copy(alpha = 0.3f),
fontSize = 22.sp,
fontWeight = FontWeight.Normal
)
}
}
}

View file

@ -0,0 +1,168 @@
// AnnotationSettingsRepository.kt
package com.aryan.reader.pdf.data
import android.content.Context
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import com.aryan.reader.pdf.InkType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import timber.log.Timber
import androidx.core.graphics.toColorInt
import androidx.core.content.edit
@Serializable
data class ToolConfig(
val colorArgb: Int,
val thickness: Float
)
@Serializable
data class TextStyleConfig(
val colorArgb: Int = android.graphics.Color.BLACK,
val backgroundColorArgb: Int = android.graphics.Color.TRANSPARENT,
val fontSize: Float = 16f,
val isBold: Boolean = false,
val isItalic: Boolean = false,
val isUnderline: Boolean = false,
val isStrikeThrough: Boolean = false,
val fontPath: String? = null,
val fontName: String? = null
)
@Serializable
data class AnnotationToolSettings(
val selectedToolName: String = "PEN",
val lastActivePenType: String = "PEN",
val toolConfigs: Map<String, ToolConfig> = emptyMap(),
val penPaletteArgb: List<Int> = listOf(
android.graphics.Color.BLACK,
android.graphics.Color.RED,
android.graphics.Color.BLUE,
android.graphics.Color.rgb(76, 175, 80),
android.graphics.Color.WHITE
),
val highlighterPaletteArgb: List<Int> = listOf(
"#8CFF9800".toColorInt(), // Orange (Default)
"#8CFFEB3B".toColorInt(), // Yellow
"#8C81C784".toColorInt(), // Green
"#8C64B5F6".toColorInt(), // Blue
"#8CE1BEE7".toColorInt(), // Purple
),
val textStyle: TextStyleConfig = TextStyleConfig()
) {
fun getActiveTool(): InkType = try {
InkType.valueOf(selectedToolName)
} catch (_: Exception) {
InkType.PEN
}
fun getLastPenTool(): InkType = try {
InkType.valueOf(lastActivePenType)
} catch (_: Exception) {
InkType.PEN
}
fun getToolColor(type: InkType): Color {
val config = toolConfigs[type.name] ?: AnnotationSettingsRepository.getDefaultConfig(type)
return Color(config.colorArgb)
}
fun getToolThickness(type: InkType): Float {
val config = toolConfigs[type.name] ?: AnnotationSettingsRepository.getDefaultConfig(type)
return config.thickness
}
fun getPenPalette(): List<Color> = penPaletteArgb.map { Color(it) }
fun getHighlighterPalette(): List<Color> = highlighterPaletteArgb.map { Color(it) }
}
class AnnotationSettingsRepository(context: Context) {
private val prefs = context.getSharedPreferences("annotation_settings_global", Context.MODE_PRIVATE)
private val scope = CoroutineScope(Dispatchers.IO)
private val json = Json { ignoreUnknownKeys = true }
private val keySettings = "tool_settings_v4_defaults"
private val _settings = MutableStateFlow(loadSettings())
val settings = _settings.asStateFlow()
companion object {
fun getDefaultConfig(type: InkType): ToolConfig {
return when (type) {
InkType.PEN -> ToolConfig(android.graphics.Color.RED, 0.008f)
InkType.FOUNTAIN_PEN -> ToolConfig(android.graphics.Color.BLUE, 0.008f)
InkType.PENCIL -> ToolConfig(android.graphics.Color.DKGRAY, 0.008f)
InkType.HIGHLIGHTER -> ToolConfig("#8CFF9800".toColorInt(), 0.035f)
InkType.HIGHLIGHTER_ROUND -> ToolConfig("#8CFFEB3B".toColorInt(), 0.035f)
InkType.ERASER -> ToolConfig(android.graphics.Color.WHITE, 0.03f)
InkType.TEXT -> ToolConfig(android.graphics.Color.BLACK, 0.02f)
}
}
}
private fun loadSettings(): AnnotationToolSettings {
val jsonString = prefs.getString(keySettings, null)
return if (jsonString != null) {
try {
json.decodeFromString(jsonString)
} catch (e: Exception) {
Timber.e(e, "Failed to decode annotation settings")
AnnotationToolSettings()
}
} else {
AnnotationToolSettings()
}
}
private fun saveSettings(newSettings: AnnotationToolSettings) {
_settings.update { newSettings }
scope.launch {
val jsonString = json.encodeToString(newSettings)
prefs.edit { putString(keySettings, jsonString) }
}
}
fun updateSelectedTool(tool: InkType) {
var currentSettings = _settings.value.copy(selectedToolName = tool.name)
if (tool == InkType.PEN || tool == InkType.FOUNTAIN_PEN || tool == InkType.PENCIL) {
currentSettings = currentSettings.copy(lastActivePenType = tool.name)
}
saveSettings(currentSettings)
}
fun updateToolColor(tool: InkType, color: Color) {
val currentMap = _settings.value.toolConfigs.toMutableMap()
val currentConfig = currentMap[tool.name] ?: getDefaultConfig(tool)
currentMap[tool.name] = currentConfig.copy(colorArgb = color.toArgb())
saveSettings(_settings.value.copy(toolConfigs = currentMap))
}
fun updateToolThickness(tool: InkType, thickness: Float) {
val currentMap = _settings.value.toolConfigs.toMutableMap()
val currentConfig = currentMap[tool.name] ?: getDefaultConfig(tool)
currentMap[tool.name] = currentConfig.copy(thickness = thickness)
saveSettings(_settings.value.copy(toolConfigs = currentMap))
}
fun updatePenPalette(colors: List<Color>) {
saveSettings(_settings.value.copy(penPaletteArgb = colors.map { it.toArgb() }))
}
fun updateHighlighterPalette(colors: List<Color>) {
saveSettings(_settings.value.copy(highlighterPaletteArgb = colors.map { it.toArgb() }))
}
fun updateTextStyle(style: TextStyleConfig) {
saveSettings(_settings.value.copy(textStyle = style))
}
}

View file

@ -0,0 +1,120 @@
// PageLayoutRepository.kt
package com.aryan.reader.pdf.data
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import timber.log.Timber
sealed class VirtualPage {
data class PdfPage(val pdfIndex: Int) : VirtualPage()
data class BlankPage(val id: String, val width: Int, val height: Int, val wasManuallyAdded: Boolean = false) : VirtualPage()
}
class PageLayoutRepository(private val context: Context) {
private fun getFile(bookId: String): File {
val safeId = bookId.replace("/", "_")
val dir = File(context.filesDir, "page_layouts")
if (!dir.exists()) dir.mkdirs()
return File(dir, "layout_$safeId.json")
}
suspend fun saveLayout(bookId: String, pages: List<VirtualPage>) = withContext(Dispatchers.IO) {
val jsonArray = JSONArray()
pages.forEach { page ->
val obj = JSONObject()
when (page) {
is VirtualPage.PdfPage -> {
obj.put("type", "pdf")
obj.put("index", page.pdfIndex)
}
is VirtualPage.BlankPage -> {
obj.put("type", "blank")
obj.put("id", page.id)
obj.put("w", page.width)
obj.put("h", page.height)
obj.put("manual", page.wasManuallyAdded)
}
}
jsonArray.put(obj)
}
getFile(bookId).writeText(jsonArray.toString())
}
suspend fun loadLayout(bookId: String, totalPdfPages: Int): List<VirtualPage> = withContext(Dispatchers.IO) {
val file = getFile(bookId)
if (!file.exists()) {
return@withContext (0 until totalPdfPages).map { VirtualPage.PdfPage(it) }
}
try {
val json = file.readText()
val array = JSONArray(json)
val list = mutableListOf<VirtualPage>()
for (i in 0 until array.length()) {
val obj = array.getJSONObject(i)
val type = obj.optString("type", "pdf")
if (type == "pdf") {
list.add(VirtualPage.PdfPage(obj.getInt("index")))
} else {
val w = obj.optInt("w", 595)
val h = obj.optInt("h", 842)
val isManual = obj.optBoolean("manual", false)
list.add(VirtualPage.BlankPage(obj.getString("id"), w, h, isManual))
}
}
list
} catch (_: Exception) {
(0 until totalPdfPages).map { VirtualPage.PdfPage(it) }
}
}
suspend fun getLayoutOrNull(bookId: String): List<VirtualPage>? = withContext(Dispatchers.IO) {
val file = getFile(bookId)
Timber.tag("PdfExportDebug").d("PageLayoutRepo: Looking for layout at ${file.absolutePath}")
Timber.tag("PdfExportDebug").d("PageLayoutRepo: File exists: ${file.exists()}")
if (!file.exists()) {
Timber.tag("PdfExportDebug").w("PageLayoutRepo: No layout file for book $bookId")
return@withContext null
}
try {
val json = file.readText()
Timber.tag("PdfExportDebug").v("PageLayoutRepo: Layout JSON: ${json.take(300)}")
val array = JSONArray(json)
val list = mutableListOf<VirtualPage>()
for (i in 0 until array.length()) {
val obj = array.getJSONObject(i)
val type = obj.optString("type", "pdf")
if (type == "pdf") {
list.add(VirtualPage.PdfPage(obj.getInt("index")))
} else {
val w = obj.optInt("w", 595)
val h = obj.optInt("h", 842)
list.add(VirtualPage.BlankPage(obj.getString("id"), w, h))
}
}
Timber.tag("PdfExportDebug").i("PageLayoutRepo: Parsed ${list.size} virtual pages (${
list.count { it is VirtualPage.PdfPage }
} PDF, ${list.count { it is VirtualPage.BlankPage }} blank)")
list
} catch (e: Exception) {
Timber.tag("PdfExportDebug").e(e, "PageLayoutRepo: Failed to parse layout")
null
}
}
fun getLayoutFile(bookId: String): File {
val safeId = bookId.replace("/", "_")
val dir = File(context.filesDir, "page_layouts")
if (!dir.exists()) dir.mkdirs()
val file = File(dir, "layout_$safeId.json")
Timber.tag("PdfExportDebug").v("PageLayoutRepo: Layout file path: ${file.absolutePath}")
return file
}
}

View file

@ -0,0 +1,192 @@
// PdfAnnotationData.kt
package com.aryan.reader.pdf.data
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import com.aryan.reader.pdf.AnnotationType
import com.aryan.reader.pdf.InkType
import com.aryan.reader.pdf.PdfPoint
import org.json.JSONArray
import org.json.JSONObject
import java.util.Locale
data class PdfTextBox(
val id: String,
val pageIndex: Int,
val relativeBounds: Rect,
val text: String,
val color: Color,
val backgroundColor: Color,
val fontSize: Float,
val isBold: Boolean = false,
val isItalic: Boolean = false,
val isUnderline: Boolean = false,
val isStrikeThrough: Boolean = false,
val fontPath: String? = null,
val fontName: String? = null
)
data class PdfAnnotation(
val type: AnnotationType,
val inkType: InkType = InkType.PEN,
val pageIndex: Int,
val points: List<PdfPoint>,
val color: Color,
val strokeWidth: Float
)
object AnnotationSerializer {
fun toJson(annotations: Map<Int, List<PdfAnnotation>>): String {
val rootArray = JSONArray()
annotations.forEach { (_, list) ->
list.forEach { annotation ->
val obj = JSONObject()
obj.put("pageIndex", annotation.pageIndex)
obj.put("annotationType", annotation.type.name)
obj.put("inkType", annotation.inkType.name)
obj.put("color", annotation.color.toArgb())
obj.put("strokeWidth", annotation.strokeWidth.toDouble())
val pointsArray = JSONArray()
annotation.points.forEach { p ->
val pObj = JSONObject()
pObj.put("x", String.format(Locale.US, "%.5f", p.x).toDouble())
pObj.put("y", String.format(Locale.US, "%.5f", p.y).toDouble())
pObj.put("t", p.timestamp)
pointsArray.put(pObj)
}
obj.put("points", pointsArray)
rootArray.put(obj)
}
}
return rootArray.toString()
}
fun fromJson(json: String): Map<Int, List<PdfAnnotation>> {
val resultMap = mutableMapOf<Int, MutableList<PdfAnnotation>>()
if (json.isBlank()) return emptyMap()
try {
val rootArray = JSONArray(json)
for (i in 0 until rootArray.length()) {
val obj = rootArray.getJSONObject(i)
val pageIndex = obj.getInt("pageIndex")
val annTypeStr = obj.optString("annotationType", AnnotationType.INK.name)
val annType = try { AnnotationType.valueOf(annTypeStr) } catch(_: Exception) { AnnotationType.INK }
val inkTypeStr = obj.optString("inkType", "PEN")
val finalInkTypeStr = if (obj.has("inkType")) inkTypeStr else obj.optString("type", "PEN")
val inkType = try { InkType.valueOf(finalInkTypeStr) } catch(_: Exception) { InkType.PEN }
val colorInt = obj.getInt("color")
val strokeWidth = obj.getDouble("strokeWidth").toFloat()
val pointsArray = obj.getJSONArray("points")
val points = ArrayList<PdfPoint>()
for (j in 0 until pointsArray.length()) {
val pObj = pointsArray.getJSONObject(j)
points.add(
PdfPoint(
x = pObj.getDouble("x").toFloat(),
y = pObj.getDouble("y").toFloat(),
timestamp = pObj.optLong("t", 0L)
)
)
}
val annotation = PdfAnnotation(
type = annType,
inkType = inkType,
pageIndex = pageIndex,
points = points,
color = Color(colorInt),
strokeWidth = strokeWidth
)
if (!resultMap.containsKey(pageIndex)) {
resultMap[pageIndex] = mutableListOf()
}
resultMap[pageIndex]?.add(annotation)
}
} catch (e: Exception) {
e.printStackTrace()
}
return resultMap
}
}
object TextBoxSerializer {
fun toJson(textBoxes: List<PdfTextBox>): String {
val rootArray = JSONArray()
textBoxes.forEach { box ->
val obj = JSONObject()
obj.put("id", box.id)
obj.put("pageIndex", box.pageIndex)
obj.put("text", box.text)
obj.put("color", box.color.toArgb())
obj.put("backgroundColor", box.backgroundColor.toArgb())
obj.put("fontSize", box.fontSize.toDouble())
obj.put("isBold", box.isBold)
obj.put("isItalic", box.isItalic)
obj.put("isUnderline", box.isUnderline)
obj.put("isStrikeThrough", box.isStrikeThrough)
if (box.fontPath != null) {
obj.put("fontPath", box.fontPath)
}
if (box.fontName != null) {
obj.put("fontName", box.fontName)
}
val rectObj = JSONObject()
rectObj.put("left", box.relativeBounds.left.toDouble())
rectObj.put("top", box.relativeBounds.top.toDouble())
rectObj.put("right", box.relativeBounds.right.toDouble())
rectObj.put("bottom", box.relativeBounds.bottom.toDouble())
obj.put("bounds", rectObj)
rootArray.put(obj)
}
return rootArray.toString()
}
fun fromJson(json: String): List<PdfTextBox> {
val result = mutableListOf<PdfTextBox>()
if (json.isBlank()) return result
try {
val rootArray = JSONArray(json)
for (i in 0 until rootArray.length()) {
val obj = rootArray.getJSONObject(i)
val rectObj = obj.getJSONObject("bounds")
val rect = Rect(
rectObj.getDouble("left").toFloat(),
rectObj.getDouble("top").toFloat(),
rectObj.getDouble("right").toFloat(),
rectObj.getDouble("bottom").toFloat()
)
result.add(
PdfTextBox(
id = obj.getString("id"),
pageIndex = obj.getInt("pageIndex"),
relativeBounds = rect,
text = obj.optString("text", ""),
color = Color(obj.getInt("color")),
backgroundColor = Color(obj.getInt("backgroundColor")),
fontSize = obj.getDouble("fontSize").toFloat(),
isBold = obj.optBoolean("isBold", false),
isItalic = obj.optBoolean("isItalic", false),
isUnderline = obj.optBoolean("isUnderline", false),
isStrikeThrough = obj.optBoolean("isStrikeThrough", false),
fontPath = obj.optString("fontPath", null).takeIf { !it.isNullOrBlank() },
fontName = obj.optString("fontName", null).takeIf { !it.isNullOrBlank() }
)
)
}
} catch (e: Exception) {
e.printStackTrace()
}
return result
}
}

View file

@ -0,0 +1,66 @@
// PdfAnnotationRepository.kt
package com.aryan.reader.pdf.data
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
class PdfAnnotationRepository(private val context: Context) {
private fun getFile(bookId: String): File {
val safeBookId = bookId.replace("/", "_")
val dir = File(context.filesDir, "annotations")
if (!dir.exists()) dir.mkdirs()
return File(dir, "annotation_$safeBookId.json")
}
suspend fun saveAnnotations(bookId: String, annotations: Map<Int, List<PdfAnnotation>>) {
withContext(Dispatchers.IO) {
try {
Timber.tag("AnnotationSync").d("Start saving local JSON for $bookId. Count: ${annotations.size}")
if (annotations.isEmpty()) {
return@withContext
}
val json = AnnotationSerializer.toJson(annotations)
val file = getFile(bookId)
file.writeText(json)
Timber.tag("AnnotationSync").d("Finished saving local JSON for $bookId. Path: ${file.absolutePath}, Size: ${file.length()}")
} catch (e: Exception) {
Timber.tag("AnnotationSync").e(e, "Failed to save local annotations")
}
}
}
suspend fun loadAnnotations(bookId: String): Map<Int, List<PdfAnnotation>> {
return withContext(Dispatchers.IO) {
try {
val file = getFile(bookId)
if (file.exists()) {
val json = file.readText()
Timber.tag("AnnotationSync").d("Loaded local JSON for $bookId. Size: ${file.length()}")
AnnotationSerializer.fromJson(json)
} else {
Timber.tag("AnnotationSync").d("No local annotation file found for $bookId")
emptyMap()
}
} catch (e: Exception) {
Timber.tag("AnnotationSync").e(e, "Failed to load local annotations")
emptyMap()
}
}
}
fun getAnnotationFileForSync(bookId: String): File? {
val file = getFile(bookId)
val valid = file.exists() && file.length() > 0
Timber.tag("AnnotationSync").d("Checking file for sync: $bookId. Exists: ${file.exists()}, Size: ${file.length()} bytes. Valid: $valid")
return if (valid) file else null
}
}

View file

@ -0,0 +1,55 @@
package com.aryan.reader.pdf.data
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
class PdfTextBoxRepository(private val context: Context) {
private fun getFile(bookId: String): File {
val safeBookId = bookId.replace("/", "_")
val dir = File(context.filesDir, "textboxes")
if (!dir.exists()) dir.mkdirs()
return File(dir, "textboxes_$safeBookId.json")
}
suspend fun saveTextBoxes(bookId: String, textBoxes: List<PdfTextBox>) {
withContext(Dispatchers.IO) {
if (textBoxes.isEmpty()) {
val file = getFile(bookId)
if (file.exists()) file.delete()
return@withContext
}
val json = TextBoxSerializer.toJson(textBoxes)
getFile(bookId).writeText(json)
}
}
suspend fun loadTextBoxes(bookId: String): List<PdfTextBox> {
return withContext(Dispatchers.IO) {
val file = getFile(bookId)
if (file.exists()) {
TextBoxSerializer.fromJson(file.readText())
} else {
emptyList()
}
}
}
fun getFileForSync(bookId: String): File {
return getFile(bookId)
}
fun clearAll() {
val dir = File(context.filesDir, "textboxes")
if (dir.exists()) {
dir.listFiles()?.forEach { it.delete() }
}
}
fun deleteForBook(bookId: String) {
val file = getFile(bookId)
if(file.exists()) file.delete()
}
}

View file

@ -0,0 +1,143 @@
// PdfTextDatabase.kt
package com.aryan.reader.pdf.data
import android.content.Context
import androidx.paging.PagingSource
import androidx.room.Dao
import androidx.room.Database
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Fts4
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.Room
import androidx.room.RoomDatabase
import kotlinx.coroutines.flow.Flow
@Fts4(tokenizer = "unicode61")
@Entity(tableName = "pdf_search_index")
data class PdfSearchIndex(
@PrimaryKey @ColumnInfo(name = "rowid") val rowid: Int? = null,
val bookId: String,
val pageIndex: Int,
val content: String
)
data class PdfSearchMatch(
val pageIndex: Int,
@ColumnInfo(name = "snippet") val snippet: String,
@ColumnInfo(name = "content") val content: String
)
@Entity(tableName = "pdf_metadata")
data class PdfMetadata(
@PrimaryKey val bookId: String,
val totalPages: Int,
val ratiosJson: String,
val ocrLanguage: String = "LATIN"
)
@Dao
interface PdfTextDao {
@Query("""
SELECT pageIndex, snippet(pdf_search_index, '<b>', '</b>', '...', -1, 15) as snippet, content
FROM pdf_search_index
WHERE bookId = :bookId AND pdf_search_index MATCH :query
ORDER BY pageIndex ASC
""")
fun searchBookFlow(bookId: String, query: String): Flow<List<PdfSearchMatch>>
@Query("""
SELECT pageIndex, snippet(pdf_search_index, '<b>', '</b>', '...', -1, 15) as snippet, content
FROM pdf_search_index
WHERE bookId = :bookId AND pdf_search_index MATCH :query
ORDER BY pageIndex ASC
""")
fun searchBookPagingSource(bookId: String, query: String): PagingSource<Int, PdfSearchMatch>
@Query("""
SELECT pageIndex, snippet(pdf_search_index, '<b>', '</b>', '...', -1, 15) as snippet, content
FROM pdf_search_index
WHERE bookId = :bookId AND pdf_search_index MATCH :query
ORDER BY pageIndex ASC
""")
suspend fun getAllMatches(bookId: String, query: String): List<PdfSearchMatch>
@Query("""
SELECT count(*)
FROM pdf_search_index
WHERE bookId = :bookId AND pdf_search_index MATCH :query
""")
suspend fun countMatches(bookId: String, query: String): Int
@Query("""
SELECT pageIndex, content, '' as snippet
FROM pdf_search_index
WHERE bookId = :bookId AND pageIndex >= :minPageIndex AND pdf_search_index MATCH :query
ORDER BY pageIndex ASC
LIMIT 1
""")
suspend fun getNextPageWithMatch(bookId: String, query: String, minPageIndex: Int): PdfSearchMatch?
@Query("""
SELECT pageIndex, content, '' as snippet
FROM pdf_search_index
WHERE bookId = :bookId AND pageIndex <= :maxPageIndex AND pdf_search_index MATCH :query
ORDER BY pageIndex DESC
LIMIT 1
""")
suspend fun getPrevPageWithMatch(bookId: String, query: String, maxPageIndex: Int): PdfSearchMatch?
@Query("SELECT content FROM pdf_search_index WHERE bookId = :bookId AND pageIndex = :pageIndex")
suspend fun getPageText(bookId: String, pageIndex: Int): String?
@Query("SELECT pageIndex FROM pdf_search_index WHERE bookId = :bookId")
suspend fun getIndexedPageIndices(bookId: String): List<Int>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertPageText(entity: PdfSearchIndex)
@Query("DELETE FROM pdf_search_index WHERE bookId = :bookId")
suspend fun clearBookText(bookId: String)
@Query("DELETE FROM pdf_search_index")
suspend fun deleteAll()
}
@Dao
interface PdfMetaDao {
@Query("SELECT * FROM pdf_metadata WHERE bookId = :bookId")
suspend fun getMetadata(bookId: String): PdfMetadata?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertMetadata(metadata: PdfMetadata)
@Query("UPDATE pdf_metadata SET ocrLanguage = :language WHERE bookId = :bookId")
suspend fun updateLanguage(bookId: String, language: String)
}
@Database(entities = [PdfSearchIndex::class, PdfMetadata::class], version = 5, exportSchema = false)
abstract class PdfTextDatabase : RoomDatabase() {
abstract fun pdfTextDao(): PdfTextDao
abstract fun pdfMetaDao(): PdfMetaDao
companion object {
@Volatile
private var INSTANCE: PdfTextDatabase? = null
fun getDatabase(context: Context): PdfTextDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
PdfTextDatabase::class.java,
"pdf_text_cache_db"
).fallbackToDestructiveMigration(true)
.build()
INSTANCE = instance
instance
}
}
}
}

View file

@ -0,0 +1,570 @@
// PdfTextRepository.kt
package com.aryan.reader.pdf.data
import android.content.Context
import android.graphics.RectF
import timber.log.Timber
import androidx.core.graphics.createBitmap
import com.aryan.reader.pdf.OcrHelper
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import org.json.JSONArray
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.flatMap
import com.aryan.reader.SearchResult
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.graphics.Color
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
private const val TAG = "PdfSearchDiag"
sealed interface SmartSearchResult {
data class Exact(val matches: List<SearchResult>) : SmartSearchResult
data class Paged(val pagingData: Flow<PagingData<SearchResult>>, val totalPageCount: Int) : SmartSearchResult
}
class PdfTextRepository(context: Context) {
private val db = PdfTextDatabase.getDatabase(context)
private val dao = db.pdfTextDao()
private val metaDao = db.pdfMetaDao()
suspend fun getPageRatios(bookId: String): List<Float>? {
return withContext(Dispatchers.IO) {
val meta = metaDao.getMetadata(bookId)
if (meta != null && meta.ratiosJson.isNotEmpty()) {
try {
val jsonArray = JSONArray(meta.ratiosJson)
val list = ArrayList<Float>(jsonArray.length())
for (i in 0 until jsonArray.length()) {
list.add(jsonArray.getDouble(i).toFloat())
}
list
} catch (e: Exception) {
Timber.tag(TAG).e(e, "Failed to parse ratios json")
null
}
} else {
null
}
}
}
suspend fun savePageRatios(bookId: String, ratios: List<Float>) {
withContext(Dispatchers.IO) {
try {
val jsonString = JSONArray(ratios).toString()
val existing = metaDao.getMetadata(bookId)
val lang = existing?.ocrLanguage ?: "LATIN"
metaDao.insertMetadata(PdfMetadata(bookId, ratios.size, jsonString, lang))
} catch (e: Exception) {
Timber.tag(TAG).e(e, "Failed to save page ratios")
}
}
}
suspend fun getBookLanguage(bookId: String): String? {
return withContext(Dispatchers.IO) {
metaDao.getMetadata(bookId)?.ocrLanguage
}
}
suspend fun setBookLanguage(bookId: String, language: String) {
withContext(Dispatchers.IO) {
val existing = metaDao.getMetadata(bookId)
if (existing != null) {
metaDao.updateLanguage(bookId, language)
} else {
metaDao.insertMetadata(PdfMetadata(bookId, 0, "", language))
}
}
}
suspend fun getIndexedPages(bookId: String): Set<Int> {
return withContext(Dispatchers.IO) {
dao.getIndexedPageIndices(bookId).toSet()
}
}
/**
* Helper to generate a safe FTS query by stripping punctuation from tokens.
* This fixes issues where "xyz." would fail to match "xyz" in the index.
* The strict punctuation check is handled later by the Regex filter.
*/
private fun generateFtsQuery(query: String): String {
val sb = StringBuilder()
for (char in query) {
// Keep letters, digits, and underscores. Replace everything else (punctuation) with space.
if (char.isLetterOrDigit() || char == '_') {
sb.append(char)
} else {
sb.append(' ')
}
}
// Split by whitespace and create FTS prefix tokens (e.g., "content:token*")
val tokens = sb.toString().split("\\s+".toRegex()).filter { it.isNotBlank() }
// If query was only punctuation (e.g. "?"), return a token that likely matches nothing or handle gracefully.
// Returning empty string causes 'MATCH ""' which usually returns nothing.
return tokens.joinToString(" ") { "content:$it*" }
}
fun searchBookFlow(bookId: String, query: String): Flow<List<PdfSearchMatch>> {
val trimmed = query.trim()
if (trimmed.isBlank()) {
return dao.searchBookFlow(bookId, "")
}
// Use the sanitized FTS query for database retrieval
val ftsQuery = generateFtsQuery(trimmed)
// Use the strict Regex for precise filtering
val phraseRegex = createPhraseRegex(query)
Timber.tag(TAG).i("Search initiated for bookId length: ${bookId.length}")
Timber.tag(TAG).i("User Query: '$query'")
Timber.tag(TAG).i("Generated FTS Query: '$ftsQuery'")
Timber.tag(TAG).i("Generated Regex: '$phraseRegex'")
// Filter the FTS matches to ensure they satisfy the strict phrase regex
return dao.searchBookFlow(bookId, ftsQuery).map { list ->
list.filter { match ->
phraseRegex.containsMatchIn(match.content)
}
}
}
suspend fun indexPage(
bookId: String,
document: PdfDocumentKt,
pageIndex: Int,
onOcrModelDownloading: () -> Unit = {}
): Boolean {
return withContext(Dispatchers.IO) {
var text = ""
var ocrUsed = false
try {
document.openPage(pageIndex).use { page ->
page.openTextPage().use { textPage ->
val count = textPage.textPageCountChars()
if (count > 0) {
val nativeText = textPage.textPageGetText(0, count)
if (!nativeText.isNullOrBlank()) {
text = nativeText
}
}
}
}
} catch (e: Exception) {
Timber.tag(TAG).e(e, "Native extraction failed for page $pageIndex")
}
if (text.isBlank()) {
try {
document.openPage(pageIndex).use { page ->
val targetWidth = 1080
val ptrWidth = page.getPageWidthPoint()
val ptrHeight = page.getPageHeightPoint()
if (ptrWidth > 0 && ptrHeight > 0) {
val aspectRatio = ptrWidth.toFloat() / ptrHeight.toFloat()
val targetHeight = (targetWidth / aspectRatio).toInt().coerceAtLeast(1)
val bitmap = createBitmap(targetWidth, targetHeight)
page.renderPageBitmap(bitmap, 0, 0, targetWidth, targetHeight, false)
val visionText = OcrHelper.extractTextFromBitmap(bitmap, onOcrModelDownloading)
text = visionText?.text ?: ""
bitmap.recycle()
ocrUsed = true
}
}
} catch (e: Exception) {
Timber.tag(TAG).e(e, "OCR failed for page $pageIndex")
}
}
if (text.isNotEmpty()) {
val rawLength = text.length
val patterns = listOf(
Regex("(?i)file:/?/?/?\\S+"),
Regex("(?i)/data/user/\\d+/\\S+"),
Regex("(?i)/storage/emulated/\\d+/\\S+"),
Regex("(?i)\\S*com\\.aryan\\.reader\\S*")
)
patterns.forEach { pattern ->
text = text.replace(pattern, " ")
}
text = text.replace(Regex("\\s+"), " ").trim()
val cleanedLength = text.length
val snippetClean = text.take(50).replace("\n", " ")
if (cleanedLength < rawLength) {
Timber.tag(TAG).d("Page $pageIndex cleaned. Size reduced: $rawLength -> $cleanedLength. New Start: '$snippetClean'")
}
if (text.contains("file://") || text.length > 10 && text.startsWith("/")) {
Timber.tag(TAG).e("Page $pageIndex: Cleaning might have failed. Text still looks like path: $snippetClean")
} else if (text.isNotBlank()) {
Timber.tag(TAG).v("Page $pageIndex: Inserting valid text ($cleanedLength chars).")
dao.insertPageText(PdfSearchIndex(bookId = bookId, pageIndex = pageIndex, content = text))
} else {
Timber.tag(TAG).i("Page $pageIndex: Text became empty after cleaning. Skipping insertion.")
}
} else {
Timber.tag(TAG).v("Page $pageIndex: No text found (Native or OCR).")
}
ocrUsed && text.isNotEmpty()
}
}
suspend fun getOrExtractText(
bookId: String,
document: PdfDocumentKt,
pageIndex: Int,
onModelDownloading: () -> Unit = {}
): String {
return withContext(Dispatchers.IO) {
val cachedText = dao.getPageText(bookId, pageIndex)
if (!cachedText.isNullOrBlank()) {
return@withContext cachedText
}
indexPage(bookId, document, pageIndex, onModelDownloading)
dao.getPageText(bookId, pageIndex) ?: ""
}
}
suspend fun hasNativeText(document: PdfDocumentKt, pageIndex: Int): Boolean {
return withContext(Dispatchers.IO) {
try {
document.openPage(pageIndex).use { page ->
page.openTextPage().use { textPage ->
textPage.textPageCountChars() > 0
}
}
} catch (_: Exception) {
false
}
}
}
suspend fun getOcrSearchRects(
document: PdfDocumentKt,
pageIndex: Int,
query: String,
onModelDownloading: () -> Unit = {}
): List<RectF> {
return withContext(Dispatchers.IO) {
val rects = mutableListOf<RectF>()
try {
document.openPage(pageIndex).use { page ->
val targetWidth = 1080
val ptrWidth = page.getPageWidthPoint()
val ptrHeight = page.getPageHeightPoint()
if (ptrWidth <= 0 || ptrHeight <= 0) return@use
val aspectRatio = ptrWidth.toFloat() / ptrHeight.toFloat()
val targetHeight = (targetWidth / aspectRatio).toInt().coerceAtLeast(1)
val bitmap = createBitmap(targetWidth, targetHeight)
page.renderPageBitmap(bitmap, 0, 0, targetWidth, targetHeight, false)
val visionText = OcrHelper.extractTextFromBitmap(bitmap, onModelDownloading)
visionText?.textBlocks?.forEach { block ->
block.lines.forEach { line ->
line.elements.forEach { element ->
if (element.text.contains(query, ignoreCase = true)) {
element.boundingBox?.let { box ->
val normalized = RectF(
box.left.toFloat() / targetWidth,
box.top.toFloat() / targetHeight,
box.right.toFloat() / targetWidth,
box.bottom.toFloat() / targetHeight
)
rects.add(normalized)
}
}
}
}
}
bitmap.recycle()
}
} catch (e: Exception) {
Timber.tag(TAG).e(e, "Failed to get OCR rects for page $pageIndex")
}
rects
}
}
suspend fun clearBookText(bookId: String) {
withContext(Dispatchers.IO) {
dao.clearBookText(bookId)
}
}
suspend fun clearAllText() {
withContext(Dispatchers.IO) {
dao.deleteAll()
}
}
private fun isAscii(string: String): Boolean {
return string.all { it.code < 128 }
}
private fun createPhraseRegex(query: String): Regex {
val clean = query.trim().replace("\"", "")
// Split by whitespace to handle user typing multiple spaces
val tokens = clean.split("\\s+".toRegex()).filter { it.isNotBlank() }
if (tokens.isEmpty()) return Regex("(?i)${Regex.escape(query)}") // Fallback
val isAscii = isAscii(clean)
val sb = StringBuilder("(?i)") // Case insensitive flag
// If ASCII, use word boundary at start.
if (isAscii) {
sb.append("\\b")
}
// Join tokens with \s+ to match any whitespace sequence in content
val escapedTokens = tokens.map { Regex.escape(it) }
sb.append(escapedTokens.joinToString("\\s+"))
return Regex(sb.toString())
}
fun searchBookSmart(bookId: String, query: String): Flow<SmartSearchResult> = flow {
val trimmed = query.trim()
if (trimmed.isBlank()) {
return@flow
}
// Use sanitized FTS query
val ftsQuery = generateFtsQuery(trimmed)
val pageMatchCount = dao.countMatches(bookId, ftsQuery)
val phraseRegex = createPhraseRegex(query)
if (pageMatchCount > 50) {
emit(SmartSearchResult.Paged(
pagingData = getSearchResultsPaged(bookId, query, phraseRegex),
totalPageCount = pageMatchCount
))
} else {
val rawMatches = dao.getAllMatches(bookId, ftsQuery)
val fullResults = mutableListOf<SearchResult>()
rawMatches.forEach { match ->
val regexMatches = phraseRegex.findAll(match.content)
var occurrenceIndex = 0
for (regexMatch in regexMatches) {
fullResults.add(
SearchResult(
locationInSource = match.pageIndex,
locationTitle = "Page ${match.pageIndex + 1}",
snippet = generateSnippet(match.content, regexMatch.range),
query = query,
occurrenceIndexInLocation = occurrenceIndex,
chunkIndex = match.pageIndex
)
)
occurrenceIndex++
}
}
emit(SmartSearchResult.Exact(fullResults))
}
}
private fun generateSnippet(content: String, matchRange: IntRange): AnnotatedString {
val snippetContextChars = 60
val start = (matchRange.first - snippetContextChars).coerceAtLeast(0)
val end = (matchRange.last + snippetContextChars).coerceAtMost(content.length)
val rawSnippet = content.substring(start, end)
// Adjust match indices relative to snippet
val matchStartInSnippet = matchRange.first - start
val matchEndInSnippet = matchRange.last - start
return buildAnnotatedString {
if (start > 0) append("...")
// Text before match
if (matchStartInSnippet > 0) {
append(rawSnippet.substring(0, matchStartInSnippet))
}
// The Match
pushStyle(SpanStyle(fontWeight = FontWeight.Bold, color = Color.Blue))
val actualMatchLength = (matchEndInSnippet - matchStartInSnippet + 1).coerceAtMost(rawSnippet.length - matchStartInSnippet)
if (actualMatchLength > 0) {
append(rawSnippet.substring(matchStartInSnippet, matchStartInSnippet + actualMatchLength))
}
pop()
// Text after match
if (matchEndInSnippet < rawSnippet.length - 1) {
append(rawSnippet.substring(matchEndInSnippet + 1))
}
if (end < content.length) append("...")
}
}
fun getSearchResultsPaged(bookId: String, query: String, regex: Regex? = null): Flow<PagingData<SearchResult>> {
val trimmed = query.trim()
if (trimmed.isBlank()) {
return kotlinx.coroutines.flow.flowOf(PagingData.empty())
}
val ftsQuery = generateFtsQuery(trimmed)
val phraseRegex = regex ?: createPhraseRegex(query)
return Pager(
config = PagingConfig(pageSize = 20, prefetchDistance = 10, enablePlaceholders = false)
) {
dao.searchBookPagingSource(bookId, ftsQuery)
}.flow.map { pagingData ->
pagingData.flatMap { match ->
val results = mutableListOf<SearchResult>()
val regexMatches = phraseRegex.findAll(match.content)
var occurrenceIndex = 0
for (regexMatch in regexMatches) {
results.add(
SearchResult(
locationInSource = match.pageIndex,
locationTitle = "Page ${match.pageIndex + 1}",
snippet = generateSnippet(match.content, regexMatch.range),
query = query,
occurrenceIndexInLocation = occurrenceIndex,
chunkIndex = match.pageIndex
)
)
occurrenceIndex++
}
results
}
}
}
suspend fun getNextResult(bookId: String, query: String, currentResult: SearchResult?): SearchResult? {
val trimmed = query.trim()
if (trimmed.isBlank()) return null
val ftsQuery = generateFtsQuery(trimmed)
val phraseRegex = createPhraseRegex(query)
val currentPageIndex = currentResult?.chunkIndex ?: -1
val currentOccurrenceIndex = currentResult?.occurrenceIndexInLocation ?: -1
// Check current page for next occurrence
if (currentPageIndex >= 0) {
val pageText = dao.getPageText(bookId, currentPageIndex)
if (pageText != null) {
val matches = phraseRegex.findAll(pageText).toList()
if (currentOccurrenceIndex + 1 < matches.size) {
val nextMatch = matches[currentOccurrenceIndex + 1]
return currentResult!!.copy(
occurrenceIndexInLocation = currentOccurrenceIndex + 1,
snippet = generateSnippet(pageText, nextMatch.range)
)
}
}
}
// Search subsequent pages
var searchPageIndex = currentPageIndex + 1
var attempts = 0
val maxAttempts = 50 // Limit linear scan depth to prevent UI freezes on sparse results
while(attempts < maxAttempts) {
val nextPageMatch = dao.getNextPageWithMatch(bookId, ftsQuery, searchPageIndex) ?: return null
val regexMatches = phraseRegex.findAll(nextPageMatch.content).toList()
if (regexMatches.isNotEmpty()) {
val firstMatch = regexMatches.first()
return SearchResult(
locationInSource = nextPageMatch.pageIndex,
locationTitle = "Page ${nextPageMatch.pageIndex + 1}",
snippet = generateSnippet(nextPageMatch.content, firstMatch.range),
query = query,
occurrenceIndexInLocation = 0,
chunkIndex = nextPageMatch.pageIndex
)
}
searchPageIndex = nextPageMatch.pageIndex + 1
attempts++
}
return null
}
suspend fun getPrevResult(bookId: String, query: String, currentResult: SearchResult?): SearchResult? {
val trimmed = query.trim()
if (trimmed.isBlank()) return null
val ftsQuery = generateFtsQuery(trimmed)
val phraseRegex = createPhraseRegex(query)
val currentPageIndex = currentResult?.chunkIndex ?: 0
val currentOccurrenceIndex = currentResult?.occurrenceIndexInLocation ?: 0
// Check current page for previous occurrence
if (currentPageIndex >= 0 && currentOccurrenceIndex > 0) {
val pageText = dao.getPageText(bookId, currentPageIndex)
if (pageText != null) {
val matches = phraseRegex.findAll(pageText).toList()
if (currentOccurrenceIndex - 1 < matches.size) {
val prevMatch = matches[currentOccurrenceIndex - 1]
return currentResult!!.copy(
occurrenceIndexInLocation = currentOccurrenceIndex - 1,
snippet = generateSnippet(pageText, prevMatch.range)
)
}
}
}
// Search previous pages
var searchPageIndex = currentPageIndex - 1
var attempts = 0
val maxAttempts = 50
while (attempts < maxAttempts && searchPageIndex >= 0) {
val prevPageMatch = dao.getPrevPageWithMatch(bookId, ftsQuery, searchPageIndex) ?: return null
val regexMatches = phraseRegex.findAll(prevPageMatch.content).toList()
if (regexMatches.isNotEmpty()) {
val lastMatch = regexMatches.last()
return SearchResult(
locationInSource = prevPageMatch.pageIndex,
locationTitle = "Page ${prevPageMatch.pageIndex + 1}",
snippet = generateSnippet(prevPageMatch.content, lastMatch.range),
query = query,
occurrenceIndexInLocation = regexMatches.size - 1,
chunkIndex = prevPageMatch.pageIndex
)
}
searchPageIndex = prevPageMatch.pageIndex - 1
attempts++
}
return null
}
}