Initial commit
This commit is contained in:
commit
6072b2ba29
844 changed files with 220532 additions and 0 deletions
275
app/src/main/java/com/aryan/reader/pdf/AnnotationDock.kt
Normal file
275
app/src/main/java/com/aryan/reader/pdf/AnnotationDock.kt
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
211
app/src/main/java/com/aryan/reader/pdf/MagnifierComposable.kt
Normal file
211
app/src/main/java/com/aryan/reader/pdf/MagnifierComposable.kt
Normal 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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
34
app/src/main/java/com/aryan/reader/pdf/OcrModels.kt
Normal file
34
app/src/main/java/com/aryan/reader/pdf/OcrModels.kt
Normal 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?
|
||||
)
|
||||
73
app/src/main/java/com/aryan/reader/pdf/PdfCoverGenerator.kt
Normal file
73
app/src/main/java/com/aryan/reader/pdf/PdfCoverGenerator.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
993
app/src/main/java/com/aryan/reader/pdf/PdfExporter.kt
Normal file
993
app/src/main/java/com/aryan/reader/pdf/PdfExporter.kt
Normal 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
|
||||
}
|
||||
}
|
||||
277
app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt
Normal file
277
app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt
Normal 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)
|
||||
}
|
||||
4867
app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt
Normal file
4867
app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt
Normal file
File diff suppressed because it is too large
Load diff
377
app/src/main/java/com/aryan/reader/pdf/PdfTextBox.kt
Normal file
377
app/src/main/java/com/aryan/reader/pdf/PdfTextBox.kt
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
1828
app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt
Normal file
1828
app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt
Normal file
File diff suppressed because it is too large
Load diff
6139
app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt
Normal file
6139
app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt
Normal file
File diff suppressed because it is too large
Load diff
553
app/src/main/java/com/aryan/reader/pdf/PenIcons.kt
Normal file
553
app/src/main/java/com/aryan/reader/pdf/PenIcons.kt
Normal 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)
|
||||
}
|
||||
1385
app/src/main/java/com/aryan/reader/pdf/RichTextSystem.kt
Normal file
1385
app/src/main/java/com/aryan/reader/pdf/RichTextSystem.kt
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
}
|
||||
1553
app/src/main/java/com/aryan/reader/pdf/TextAnnotationDock.kt
Normal file
1553
app/src/main/java/com/aryan/reader/pdf/TextAnnotationDock.kt
Normal file
File diff suppressed because it is too large
Load diff
989
app/src/main/java/com/aryan/reader/pdf/ToolSettingsPopup.kt
Normal file
989
app/src/main/java/com/aryan/reader/pdf/ToolSettingsPopup.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
192
app/src/main/java/com/aryan/reader/pdf/data/PdfAnnotationData.kt
Normal file
192
app/src/main/java/com/aryan/reader/pdf/data/PdfAnnotationData.kt
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
143
app/src/main/java/com/aryan/reader/pdf/data/PdfTextDatabase.kt
Normal file
143
app/src/main/java/com/aryan/reader/pdf/data/PdfTextDatabase.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
570
app/src/main/java/com/aryan/reader/pdf/data/PdfTextRepository.kt
Normal file
570
app/src/main/java/com/aryan/reader/pdf/data/PdfTextRepository.kt
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue