@june24/expo-pdf-reader 0.1.17 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,11 +2,20 @@ package expo.modules.pdfreader
2
2
 
3
3
  import android.annotation.SuppressLint
4
4
  import android.content.Context
5
- import android.graphics.*
5
+ import android.graphics.Bitmap
6
+ import android.graphics.Canvas
7
+ import android.graphics.Color
8
+ import android.graphics.Paint
9
+ import android.graphics.Path
10
+ import android.graphics.PointF
11
+ import android.graphics.RectF
12
+ import android.graphics.Typeface
13
+ import android.graphics.drawable.BitmapDrawable
6
14
  import android.graphics.pdf.PdfRenderer
7
15
  import android.os.ParcelFileDescriptor
8
- import android.text.TextPaint
9
- import android.view.*
16
+ import android.view.MotionEvent
17
+ import android.view.View
18
+ import android.view.ViewGroup
10
19
  import android.widget.FrameLayout
11
20
  import android.widget.ImageView
12
21
  import android.widget.LinearLayout
@@ -18,36 +27,47 @@ import java.io.File
18
27
  import java.net.URL
19
28
  import kotlinx.coroutines.*
20
29
 
21
- // Annotation types matching iOS
30
+ /** Annotation type string matching iOS. */
22
31
  enum class AnnotationTool { PEN, HIGHLIGHTER, TEXT, NOTE, ERASER, LINE }
23
32
 
24
- // In-memory annotation model (Android has no PDFKit annotations)
33
+ /** Single annotation (pen/highlighter/line/text/note). */
25
34
  data class Annotation(
26
- val type: String, // "pen", "highlighter", "line", "text", "note"
35
+ val type: String,
27
36
  val bounds: RectF,
28
37
  var color: Int,
29
- var strokeWidth: Float = 2f,
30
- val paths: MutableList<MutableList<PointF>> = mutableListOf(),
31
- var contents: String = " ",
32
- var fontSize: Float = 14f,
33
- var bold: Boolean = false,
34
- var italic: Boolean = false
38
+ var strokeWidth: Float,
39
+ val paths: MutableList<MutableList<PointF>>,
40
+ var contents: String,
41
+ var fontSize: Float,
42
+ var bold: Boolean,
43
+ var italic: Boolean
35
44
  )
36
45
 
37
- private data class UndoEntry(val annotation: Annotation, val pageIndex: Int)
46
+ /** Undo/redo entry. */
47
+ data class UndoEntry(val annotation: Annotation, val pageIndex: Int)
38
48
 
49
+ /** ScrollView that respects currentTool: scroll only when no tool selected. */
39
50
  @SuppressLint("ViewConstructor")
40
- class ExpoPdfReaderView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
51
+ private class ToolAwareScrollView(
52
+ context: Context,
53
+ private val isScrollEnabledProvider: () -> Boolean
54
+ ) : ScrollView(context) {
55
+ override fun onTouchEvent(ev: MotionEvent): Boolean {
56
+ if (!isScrollEnabledProvider()) return false
57
+ return super.onTouchEvent(ev)
58
+ }
41
59
 
42
- private inner class ToolAwareScrollView(ctx: Context) : ScrollView(ctx) {
43
- var scrollEnabled: Boolean = true
44
- override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
45
- if (!scrollEnabled) return false
46
- return super.onInterceptTouchEvent(ev)
47
- }
60
+ override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
61
+ if (!isScrollEnabledProvider()) return false
62
+ return super.onInterceptTouchEvent(ev)
48
63
  }
64
+ }
49
65
 
50
- private val scrollView = ToolAwareScrollView(context)
66
+ /** Android PDF viewer with full annotation support matching iOS. */
67
+ @SuppressLint("ViewConstructor")
68
+ class ExpoPdfReaderView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
69
+
70
+ private val scrollView = ToolAwareScrollView(context) { currentTool == null }
51
71
  private val container = LinearLayout(context).apply {
52
72
  orientation = LinearLayout.VERTICAL
53
73
  }
@@ -56,9 +76,11 @@ class ExpoPdfReaderView(context: Context, appContext: AppContext) : ExpoView(con
56
76
  private var fileDescriptor: ParcelFileDescriptor? = null
57
77
  private val scope = CoroutineScope(Dispatchers.Main + Job())
58
78
 
59
- private var displayMode = "continuous"
60
- private var initialPage: Int = 0
79
+ private var initialPage = 0
61
80
  private var pdfUrl: String? = null
81
+ private var renderPdfRetryCount = 0
82
+ private val maxRenderPdfRetries = 30
83
+ private var displayMode = "continuous"
62
84
 
63
85
  private val onPageChange = ViewEvent<Map<String, Any>>("onPageChange", this, null)
64
86
  private val onAnnotationChange = ViewEvent<Map<String, Any>>("onAnnotationChange", this, null)
@@ -66,313 +88,275 @@ class ExpoPdfReaderView(context: Context, appContext: AppContext) : ExpoView(con
66
88
  private val onNotePress = ViewEvent<Map<String, Any>>("onNotePress", this, null)
67
89
  private val onTextPress = ViewEvent<Map<String, Any>>("onTextPress", this, null)
68
90
 
69
- // Per-page annotations (same structure as iOS, but we store in memory)
70
- private var pageAnnotations: MutableList<MutableList<Annotation>> = mutableListOf()
71
- private var appliedAnnotationsFingerprint: String? = null
72
- private var pendingAnnotations: List<Map<String, Any>>? = null
73
-
74
- private var currentTool: AnnotationTool? = null
75
- private var strokeColor: Int = Color.RED
76
- private var strokeWidth: Float = 2f
77
- private var textContent: String = ""
78
- private var textColor: Int = Color.BLACK
79
- private var textFontSize: Float = 14f
80
- private var textBold: Boolean = false
81
- private var textItalic: Boolean = false
82
- private var noteColor: Int = Color.YELLOW
83
-
84
- private val undoStack = mutableListOf<UndoEntry>()
85
- private val redoStack = mutableListOf<UndoEntry>()
86
-
87
91
  private var pageHeights = mutableListOf<Int>()
88
92
  private var pageWidthsPdf = mutableListOf<Float>()
89
93
  private var pageHeightsPdf = mutableListOf<Float>()
90
94
  private var pageViews = mutableListOf<ImageView?>()
91
95
  private var overlayViews = mutableListOf<AnnotationOverlayView?>()
92
- private var renderGeneration = 0
93
96
  private var lastEmittedPageIndex = -1
94
97
  private var totalPageCount = 0
95
98
 
96
- // Drawing state (like iOS currentPath, activePage, startPoint, preview)
99
+ private val renderWindowBefore = 8
100
+ private val renderWindowAfter = 16
101
+
102
+ // Annotation state
103
+ private val pageAnnotations = mutableListOf<MutableList<Annotation>>()
104
+ private var currentTool: AnnotationTool? = null
105
+ private var strokeColor = Color.RED
106
+ private var strokeWidth = 2f
107
+ private var textContent = ""
108
+ private var textColor = Color.BLACK
109
+ private var textFontSize = 14f
110
+ private var textBold = false
111
+ private var textItalic = false
112
+ private var noteColor = Color.YELLOW
113
+
97
114
  private var currentPath: Path? = null
98
- private var currentPathPoints: MutableList<PointF>? = null
99
- private var activePageIndex: Int = -1
115
+ private val currentPathPoints = mutableListOf<PointF>()
116
+ private var activePageIndex = -1
100
117
  private var startPoint: PointF? = null
101
118
  private var draggingAnnotation: Annotation? = null
102
- private var draggingPageIndex: Int = -1
119
+ private var draggingPageIndex = -1
103
120
  private var dragStartBounds: RectF? = null
104
121
  private var dragStartTouch: PointF? = null
105
122
 
106
- inner class AnnotationOverlayView(context: Context, val pageIndex: Int) : View(context) {
107
- private fun scale(): Float {
108
- val w = pageWidthsPdf.getOrNull(pageIndex) ?: 1f
109
- return (width.toFloat() / w).coerceAtLeast(0.01f)
110
- }
111
- private val pathPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
123
+ private val undoStack = mutableListOf<UndoEntry>()
124
+ private val redoStack = mutableListOf<UndoEntry>()
125
+ private var appliedAnnotationsFingerprint: String? = null
126
+ private var pendingAnnotations: List<Map<String, Any>>? = null
127
+ private var renderJob: Job? = null
128
+
129
+ inner class AnnotationOverlayView(
130
+ context: Context,
131
+ val pageIndex: Int
132
+ ) : View(context) {
133
+
134
+ private val pathPaint = Paint().apply {
135
+ isAntiAlias = true
112
136
  style = Paint.Style.STROKE
113
- strokeCap = Paint.Cap.ROUND
114
137
  strokeJoin = Paint.Join.ROUND
138
+ strokeCap = Paint.Cap.ROUND
139
+ }
140
+ private val textPaint = Paint().apply {
141
+ isAntiAlias = true
142
+ textAlign = Paint.Align.LEFT
143
+ }
144
+ private val noteFillPaint = Paint().apply {
145
+ style = Paint.Style.FILL
146
+ isAntiAlias = true
147
+ }
148
+ private val noteStrokePaint = Paint().apply {
149
+ style = Paint.Style.STROKE
115
150
  isAntiAlias = true
151
+ strokeWidth = 1f
116
152
  }
117
- private val textPaint = TextPaint(Paint.ANTI_ALIAS_FLAG)
118
- private var previewPath: Path? = null
119
- private var previewPaint: Paint? = null
120
153
 
121
- override fun onDraw(canvas: Canvas) {
122
- val anns = getAnnotationsForPage(pageIndex)
123
- for (ann in anns) {
124
- drawAnnotation(canvas, ann)
125
- }
126
- // Drawing preview (current stroke)
127
- if (activePageIndex == pageIndex && currentPath != null && previewPaint != null) {
128
- canvas.drawPath(currentPath!!, previewPaint!!)
129
- }
154
+ fun scale(): Float {
155
+ val pw = pageWidthsPdf.getOrNull(pageIndex) ?: 1f
156
+ if (pw <= 0f) return 1f
157
+ return width / pw
130
158
  }
131
159
 
132
- private fun drawAnnotation(canvas: Canvas, ann: Annotation) {
160
+ override fun onDraw(canvas: Canvas) {
161
+ super.onDraw(canvas)
133
162
  val s = scale()
134
- when (ann.type) {
135
- "pen", "highlighter", "line" -> {
136
- pathPaint.color = ann.color
137
- pathPaint.strokeWidth = ann.strokeWidth * s
138
- if (ann.type == "highlighter") {
139
- pathPaint.alpha = (Color.alpha(ann.color) * 0.4f).toInt().coerceIn(0, 255)
140
- }
141
- for (pathPoints in ann.paths) {
142
- if (pathPoints.size < 2) continue
143
- val path = Path()
144
- path.moveTo(pathPoints[0].x * s, pathPoints[0].y * s)
145
- for (i in 1 until pathPoints.size) {
146
- path.lineTo(pathPoints[i].x * s, pathPoints[i].y * s)
163
+ if (s <= 0f) return
164
+
165
+ for (ann in getAnnotationsForPage(pageIndex)) {
166
+ val left = ann.bounds.left * s
167
+ val top = ann.bounds.top * s
168
+ val right = ann.bounds.right * s
169
+ val bottom = ann.bounds.bottom * s
170
+ when (ann.type) {
171
+ "pen", "highlighter", "line" -> {
172
+ pathPaint.color = ann.color
173
+ pathPaint.strokeWidth = ann.strokeWidth * s
174
+ pathPaint.alpha = if (ann.type == "highlighter") 102 else 255
175
+ for (pathPoints in ann.paths) {
176
+ if (pathPoints.size < 2) continue
177
+ val path = Path()
178
+ path.moveTo(pathPoints[0].x * s, pathPoints[0].y * s)
179
+ for (i in 1 until pathPoints.size) {
180
+ path.lineTo(pathPoints[i].x * s, pathPoints[i].y * s)
181
+ }
182
+ canvas.drawPath(path, pathPaint)
147
183
  }
148
- canvas.drawPath(path, pathPaint)
149
184
  }
150
- pathPaint.alpha = 255
151
- }
152
- "text" -> {
153
- textPaint.color = ann.color
154
- textPaint.textSize = ann.fontSize * s
155
- textPaint.typeface = Typeface.create(
156
- Typeface.DEFAULT,
157
- when {
158
- ann.bold && ann.italic -> Typeface.BOLD_ITALIC
159
- ann.bold -> Typeface.BOLD
160
- ann.italic -> Typeface.ITALIC
161
- else -> Typeface.NORMAL
185
+ "text" -> {
186
+ textPaint.color = ann.color
187
+ textPaint.textSize = ann.fontSize * s
188
+ textPaint.typeface = when {
189
+ ann.bold && ann.italic -> Typeface.create(Typeface.DEFAULT, Typeface.BOLD_ITALIC)
190
+ ann.bold -> Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
191
+ ann.italic -> Typeface.create(Typeface.DEFAULT, Typeface.ITALIC)
192
+ else -> Typeface.DEFAULT
162
193
  }
163
- )
164
- val text = ann.contents.ifEmpty { " " }
165
- val x = ann.bounds.left * s + 4
166
- val y = ann.bounds.top * s + ann.fontSize * s + 4
167
- canvas.drawText(text, x, y, textPaint)
168
- }
169
- "note" -> {
170
- pathPaint.color = ann.color
171
- pathPaint.style = Paint.Style.FILL
172
- val r = RectF(ann.bounds.left * s, ann.bounds.top * s, ann.bounds.right * s, ann.bounds.bottom * s)
173
- canvas.drawRect(r, pathPaint)
174
- pathPaint.style = Paint.Style.STROKE
175
- pathPaint.color = Color.argb(128, 0, 0, 0)
176
- pathPaint.strokeWidth = 1f
177
- canvas.drawRect(r, pathPaint)
178
- pathPaint.style = Paint.Style.STROKE
179
- textPaint.color = Color.BLACK
180
- textPaint.textSize = 10f * s
181
- canvas.drawText(ann.contents.ifEmpty { " " }.take(20), r.left + 4, r.top + 12f * s, textPaint)
194
+ val text = ann.contents.ifEmpty { " " }
195
+ canvas.drawText(text, left + 4f, top + ann.fontSize * s - 2f, textPaint)
196
+ }
197
+ "note" -> {
198
+ noteFillPaint.color = ann.color
199
+ noteStrokePaint.color = Color.BLACK
200
+ canvas.drawRect(left, top, right, bottom, noteFillPaint)
201
+ canvas.drawRect(left, top, right, bottom, noteStrokePaint)
202
+ textPaint.color = Color.BLACK
203
+ textPaint.textSize = 10f * s
204
+ textPaint.typeface = Typeface.DEFAULT
205
+ val text = ann.contents.ifEmpty { " " }
206
+ canvas.drawText(text, left + 4f, top + 12f * s, textPaint)
207
+ }
182
208
  }
183
209
  }
184
- }
185
210
 
186
- fun setPreview(path: Path?, paint: Paint?) {
187
- previewPath = path
188
- previewPaint = paint
189
- invalidate()
211
+ // Preview path for current stroke
212
+ if (activePageIndex == pageIndex && currentPath != null && currentPathPoints.isNotEmpty()) {
213
+ pathPaint.color = strokeColor
214
+ pathPaint.strokeWidth = strokeWidth * s
215
+ pathPaint.alpha = if (currentTool == AnnotationTool.HIGHLIGHTER) 102 else 255
216
+ val path = Path()
217
+ path.moveTo(currentPathPoints[0].x * s, currentPathPoints[0].y * s)
218
+ for (i in 1 until currentPathPoints.size) {
219
+ path.lineTo(currentPathPoints[i].x * s, currentPathPoints[i].y * s)
220
+ }
221
+ canvas.drawPath(path, pathPaint)
222
+ }
190
223
  }
191
224
 
225
+ @SuppressLint("ClickableViewAccessibility")
192
226
  override fun onTouchEvent(event: MotionEvent): Boolean {
193
- if (pageIndex >= pageHeights.size) return false
194
- val x = event.x
195
- val y = event.y
227
+ val s = scale()
228
+ if (s <= 0f) return false
229
+ val pdfX = event.x / s
230
+ val pdfY = event.y / s
231
+
196
232
  if (currentTool != null) {
197
- return handleToolTouch(event, x, y)
233
+ return handleToolTouch(event, pdfX, pdfY, s)
198
234
  }
199
- return handleTapAndDrag(event, x, y)
235
+ return handleTapAndDrag(event, pdfX, pdfY, s)
200
236
  }
201
237
 
202
- private fun handleToolTouch(event: MotionEvent, x: Float, y: Float): Boolean {
203
- val tool = currentTool ?: return false
204
- when (event.action) {
238
+ private fun handleToolTouch(event: MotionEvent, pdfX: Float, pdfY: Float, scale: Float): Boolean {
239
+ when (event.actionMasked) {
205
240
  MotionEvent.ACTION_DOWN -> {
241
+ val tool = currentTool ?: return false
242
+ activePageIndex = pageIndex
243
+ startPoint = PointF(pdfX, pdfY)
206
244
  when (tool) {
207
245
  AnnotationTool.PEN, AnnotationTool.HIGHLIGHTER, AnnotationTool.LINE -> {
208
- activePageIndex = pageIndex
209
- startPoint = PointF(x, y)
210
- currentPath = Path().apply { moveTo(x, y) }
211
- currentPathPoints = mutableListOf(PointF(x, y))
212
- val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
213
- style = Paint.Style.STROKE
214
- strokeCap = Paint.Cap.ROUND
215
- strokeJoin = Paint.Join.ROUND
216
- color = strokeColor
217
- strokeWidth = this@ExpoPdfReaderView.strokeWidth
218
- if (tool == AnnotationTool.HIGHLIGHTER) alpha = (0.4f * 255).toInt()
219
- }
220
- setPreview(currentPath, paint)
221
- invalidate()
246
+ currentPath = Path()
247
+ currentPathPoints.clear()
248
+ currentPathPoints.add(PointF(pdfX, pdfY))
249
+ currentPath?.moveTo(pdfX, pdfY)
222
250
  }
223
251
  AnnotationTool.TEXT, AnnotationTool.NOTE -> {
224
- val s = scale()
225
- addInstantAnnotation(pageIndex, x / s, y / s, tool)
252
+ addInstantAnnotation(pageIndex, pdfX, pdfY, tool)
226
253
  }
227
254
  AnnotationTool.ERASER -> {
228
- val s = scale()
229
- eraseAt(pageIndex, x / s, y / s)
255
+ eraseAt(pageIndex, pdfX, pdfY)
230
256
  }
231
257
  }
258
+ invalidate()
232
259
  return true
233
260
  }
234
261
  MotionEvent.ACTION_MOVE -> {
262
+ val tool = currentTool ?: return true
235
263
  when (tool) {
236
264
  AnnotationTool.PEN, AnnotationTool.HIGHLIGHTER -> {
237
- if (activePageIndex == pageIndex && currentPath != null && currentPathPoints != null) {
238
- currentPath!!.lineTo(x, y)
239
- currentPathPoints!!.add(PointF(x, y))
240
- invalidate()
241
- }
265
+ currentPathPoints.add(PointF(pdfX, pdfY))
266
+ currentPath?.lineTo(pdfX, pdfY)
242
267
  }
243
268
  AnnotationTool.LINE -> {
244
- if (activePageIndex == pageIndex && startPoint != null && currentPath != null && currentPathPoints != null) {
245
- currentPath!!.reset()
246
- currentPath!!.moveTo(startPoint!!.x, startPoint!!.y)
247
- currentPath!!.lineTo(x, y)
248
- currentPathPoints!!.clear()
249
- currentPathPoints!!.add(PointF(startPoint!!.x, startPoint!!.y))
250
- currentPathPoints!!.add(PointF(x, y))
251
- invalidate()
252
- }
253
- }
254
- AnnotationTool.ERASER -> {
255
- val s = scale()
256
- eraseAt(pageIndex, x / s, y / s)
269
+ val start = startPoint ?: return true
270
+ currentPathPoints.clear()
271
+ currentPathPoints.add(PointF(start.x, start.y))
272
+ currentPathPoints.add(PointF(pdfX, pdfY))
273
+ currentPath?.reset()
274
+ currentPath?.moveTo(start.x, start.y)
275
+ currentPath?.lineTo(pdfX, pdfY)
257
276
  }
277
+ AnnotationTool.ERASER -> eraseAt(pageIndex, pdfX, pdfY)
258
278
  else -> {}
259
279
  }
280
+ invalidate()
260
281
  return true
261
282
  }
262
283
  MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
263
- when (tool) {
284
+ when (currentTool) {
264
285
  AnnotationTool.PEN, AnnotationTool.HIGHLIGHTER, AnnotationTool.LINE -> {
265
- if (activePageIndex == pageIndex && currentPathPoints != null && currentPathPoints!!.size >= 2) {
266
- commitStroke(tool)
286
+ if (currentPathPoints.size >= 2) {
287
+ commitStroke(currentTool!!)
267
288
  }
268
- setPreview(null, null)
269
289
  currentPath = null
270
- currentPathPoints = null
271
- activePageIndex = -1
272
- startPoint = null
273
- invalidate()
290
+ currentPathPoints.clear()
274
291
  }
275
292
  else -> {}
276
293
  }
294
+ activePageIndex = -1
295
+ startPoint = null
296
+ invalidate()
277
297
  return true
278
298
  }
279
299
  }
280
300
  return false
281
301
  }
282
302
 
283
- private fun commitStroke(tool: AnnotationTool) {
284
- var pts = currentPathPoints ?: return
285
- if (tool == AnnotationTool.LINE && startPoint != null && pts.size >= 1) {
286
- pts = mutableListOf(PointF(startPoint!!.x, startPoint!!.y), pts.last())
287
- }
288
- if (pts.size < 2) return
289
- val s = scale()
290
- val ptsPdf = pts.map { PointF(it.x / s, it.y / s) }.toMutableList()
291
- val pathBounds = RectF()
292
- val path = Path().apply {
293
- moveTo(ptsPdf[0].x, ptsPdf[0].y)
294
- for (i in 1 until ptsPdf.size) lineTo(ptsPdf[i].x, ptsPdf[i].y)
295
- }
296
- path.computeBounds(pathBounds, true)
297
- pathBounds.inset(-strokeWidth / s, -strokeWidth / s)
298
- val color = if (tool == AnnotationTool.HIGHLIGHTER) Color.argb(102, Color.red(strokeColor), Color.green(strokeColor), Color.blue(strokeColor)) else strokeColor
299
- val ann = Annotation(
300
- type = when (tool) {
301
- AnnotationTool.PEN -> "pen"
302
- AnnotationTool.HIGHLIGHTER -> "highlighter"
303
- AnnotationTool.LINE -> "line"
304
- else -> "pen"
305
- },
306
- bounds = RectF(pathBounds),
307
- color = color,
308
- strokeWidth = strokeWidth / s,
309
- paths = mutableListOf(ptsPdf)
310
- )
311
- addAnnotationToPage(pageIndex, ann)
312
- }
313
-
314
- private fun handleTapAndDrag(event: MotionEvent, x: Float, y: Float): Boolean {
315
- val s = scale()
316
- val pdfX = x / s
317
- val pdfY = y / s
318
- val anns = getAnnotationsForPage(pageIndex)
319
- when (event.action) {
303
+ private fun handleTapAndDrag(event: MotionEvent, pdfX: Float, pdfY: Float, scale: Float): Boolean {
304
+ when (event.actionMasked) {
320
305
  MotionEvent.ACTION_DOWN -> {
321
- for ((idx, ann) in anns.withIndex()) {
322
- if (ann.bounds.contains(pdfX, pdfY)) {
323
- when (ann.type) {
324
- "text" -> {
325
- onTextPress(mapOf(
326
- "page" to pageIndex,
327
- "index" to idx,
328
- "bounds" to mapOf(
329
- "x" to ann.bounds.left,
330
- "y" to ann.bounds.top,
331
- "width" to ann.bounds.width(),
332
- "height" to ann.bounds.height()
333
- ),
334
- "contents" to (ann.contents)
335
- ))
336
- return true
337
- }
338
- "note" -> {
339
- onNotePress(mapOf(
340
- "page" to pageIndex,
341
- "index" to idx,
342
- "bounds" to mapOf(
343
- "x" to ann.bounds.left,
344
- "y" to ann.bounds.top,
345
- "width" to ann.bounds.width(),
346
- "height" to ann.bounds.height()
347
- ),
348
- "contents" to (ann.contents)
349
- ))
350
- return true
351
- }
352
- else -> {}
353
- }
354
- draggingAnnotation = ann
355
- draggingPageIndex = pageIndex
356
- dragStartBounds = RectF(ann.bounds)
357
- dragStartTouch = PointF(x, y)
306
+ val list = getAnnotationsForPage(pageIndex)
307
+ for (i in list.indices.reversed()) {
308
+ val ann = list[i]
309
+ if (!ann.bounds.contains(pdfX, pdfY)) continue
310
+ if (ann.type == "text") {
311
+ onTextPress(mapOf(
312
+ "page" to pageIndex,
313
+ "index" to i,
314
+ "bounds" to mapOf(
315
+ "x" to ann.bounds.left,
316
+ "y" to ann.bounds.top,
317
+ "width" to ann.bounds.width(),
318
+ "height" to ann.bounds.height()
319
+ ),
320
+ "contents" to (ann.contents.ifEmpty { " " })
321
+ ))
358
322
  return true
359
323
  }
324
+ if (ann.type == "note") {
325
+ onNotePress(mapOf(
326
+ "page" to pageIndex,
327
+ "index" to i,
328
+ "bounds" to mapOf(
329
+ "x" to ann.bounds.left,
330
+ "y" to ann.bounds.top,
331
+ "width" to ann.bounds.width(),
332
+ "height" to ann.bounds.height()
333
+ ),
334
+ "contents" to (ann.contents.ifEmpty { " " })
335
+ ))
336
+ return true
337
+ }
338
+ draggingAnnotation = ann
339
+ draggingPageIndex = pageIndex
340
+ dragStartBounds = RectF(ann.bounds)
341
+ dragStartTouch = PointF(pdfX, pdfY)
342
+ return true
360
343
  }
344
+ return false
361
345
  }
362
346
  MotionEvent.ACTION_MOVE -> {
363
- if (draggingAnnotation != null && draggingPageIndex == pageIndex && dragStartTouch != null && dragStartBounds != null) {
364
- val dx = (x - dragStartTouch!!.x) / s
365
- val dy = (y - dragStartTouch!!.y) / s
366
- draggingAnnotation!!.bounds.set(
367
- dragStartBounds!!.left + dx,
368
- dragStartBounds!!.top + dy,
369
- dragStartBounds!!.right + dx,
370
- dragStartBounds!!.bottom + dy
371
- )
372
- dragStartTouch!!.set(x, y)
373
- dragStartBounds!!.set(draggingAnnotation!!.bounds)
374
- invalidate()
375
- }
347
+ val ann = draggingAnnotation ?: return false
348
+ val start = dragStartTouch ?: return true
349
+ val startB = dragStartBounds ?: return true
350
+ val dx = pdfX - start.x
351
+ val dy = pdfY - start.y
352
+ ann.bounds.set(
353
+ startB.left + dx,
354
+ startB.top + dy,
355
+ startB.right + dx,
356
+ startB.bottom + dy
357
+ )
358
+ invalidate()
359
+ return true
376
360
  }
377
361
  MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
378
362
  if (draggingAnnotation != null) {
@@ -383,43 +367,65 @@ class ExpoPdfReaderView(context: Context, appContext: AppContext) : ExpoView(con
383
367
  draggingPageIndex = -1
384
368
  dragStartBounds = null
385
369
  dragStartTouch = null
370
+ return false
386
371
  }
387
372
  }
388
- return draggingAnnotation != null
373
+ return false
389
374
  }
390
375
  }
391
376
 
392
377
  init {
393
378
  setBackgroundColor(Color.WHITE)
394
- scrollView.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
379
+ scrollView.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
395
380
  scrollView.isFillViewport = true
396
- container.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
381
+ container.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
397
382
  scrollView.addView(container)
398
383
  addView(scrollView)
399
384
  scrollView.viewTreeObserver.addOnScrollChangedListener {
400
385
  notifyPageChange()
401
386
  }
402
387
  viewTreeObserver.addOnGlobalLayoutListener {
403
- if (width > 0 && pdfUrl != null && pdfRenderer == null) {
388
+ if (width <= 0) {
389
+ if (pdfUrl != null && pdfRenderer == null) {
390
+ loadPdfFromUrl(pdfUrl!!)
391
+ } else if (pdfRenderer != null && renderPdfRetryCount < maxRenderPdfRetries) {
392
+ renderPdfRetryCount++
393
+ postDelayed({ renderPdf() }, 50)
394
+ }
395
+ return@addOnGlobalLayoutListener
396
+ }
397
+ renderPdfRetryCount = 0
398
+ if (pdfUrl != null && pdfRenderer == null) {
404
399
  loadPdfFromUrl(pdfUrl!!)
400
+ } else if (pdfRenderer != null) {
401
+ renderPdf()
405
402
  }
406
403
  }
407
404
  }
408
405
 
409
- fun setUrl(url: String) {
410
- if (pdfUrl == url) return
411
- pdfUrl = url
412
- if (width > 0) loadPdfFromUrl(url)
406
+ override fun onSizeChanged(w: Int, h: Int, oldW: Int, oldH: Int) {
407
+ super.onSizeChanged(w, h, oldW, oldH)
408
+ if (w > 0 && pdfRenderer != null) {
409
+ if (w != oldW) {
410
+ pageHeights.clear()
411
+ }
412
+ if (pageHeights.isEmpty()) {
413
+ renderPdf()
414
+ }
415
+ }
416
+ }
417
+
418
+ fun setUrl(url: String?) {
419
+ val u = url?.takeIf { it.isNotBlank() } ?: return
420
+ if (pdfUrl == u && pdfRenderer != null) return
421
+ pdfUrl = u
422
+ loadPdfFromUrl(u)
413
423
  }
414
424
 
415
425
  fun setDisplayMode(mode: String) {
416
- displayMode = when (mode) {
417
- "single" -> "single"
418
- "continuous" -> "continuous"
419
- "twoUp" -> "twoUp"
420
- "twoUpContinuous" -> "twoUpContinuous"
421
- else -> "continuous"
422
- }
426
+ if (displayMode == mode) return
427
+ displayMode = mode
428
+ pageHeights.clear()
423
429
  pdfRenderer?.let { renderPdf() }
424
430
  }
425
431
 
@@ -429,26 +435,41 @@ class ExpoPdfReaderView(context: Context, appContext: AppContext) : ExpoView(con
429
435
  }
430
436
 
431
437
  fun setTool(tool: String?) {
432
- currentTool = when (tool?.lowercase()) {
433
- "pen" -> AnnotationTool.PEN
434
- "highlighter" -> AnnotationTool.HIGHLIGHTER
435
- "text" -> AnnotationTool.TEXT
436
- "note" -> AnnotationTool.NOTE
437
- "eraser" -> AnnotationTool.ERASER
438
- "line" -> AnnotationTool.LINE
439
- else -> null
440
- }
441
- scrollView.scrollEnabled = (currentTool == null)
442
- }
443
-
444
- fun setStrokeColor(hex: String) { strokeColor = parseHexColor(hex) }
445
- fun setStrokeWidth(width: Float) { strokeWidth = width }
446
- fun setTextContent(text: String) { textContent = text }
447
- fun setTextColor(hex: String) { textColor = parseHexColor(hex) }
448
- fun setTextFontSize(size: Float) { textFontSize = size }
449
- fun setTextBold(value: Boolean) { textBold = value }
450
- fun setTextItalic(value: Boolean) { textItalic = value }
451
- fun setNoteColor(hex: String) { noteColor = parseHexColor(hex) }
438
+ currentTool = tool?.let { runCatching { AnnotationTool.valueOf(it.uppercase()) }.getOrNull() }
439
+ // ToolAwareScrollView uses isScrollEnabledProvider { currentTool == null } — no property to set
440
+ }
441
+
442
+ fun setStrokeColor(hex: String) {
443
+ strokeColor = parseHexColor(hex)
444
+ }
445
+
446
+ fun setStrokeWidth(width: Float) {
447
+ strokeWidth = width
448
+ }
449
+
450
+ fun setTextContent(text: String) {
451
+ textContent = text
452
+ }
453
+
454
+ fun setTextColor(hex: String) {
455
+ textColor = parseHexColor(hex)
456
+ }
457
+
458
+ fun setTextFontSize(size: Float) {
459
+ textFontSize = size
460
+ }
461
+
462
+ fun setTextBold(value: Boolean) {
463
+ textBold = value
464
+ }
465
+
466
+ fun setTextItalic(value: Boolean) {
467
+ textItalic = value
468
+ }
469
+
470
+ fun setNoteColor(hex: String) {
471
+ noteColor = parseHexColor(hex)
472
+ }
452
473
 
453
474
  fun setInitialAnnotations(annotations: List<Map<String, Any>>) {
454
475
  if (pdfRenderer == null) {
@@ -458,13 +479,14 @@ class ExpoPdfReaderView(context: Context, appContext: AppContext) : ExpoView(con
458
479
  val fp = fingerprintAnnotations(annotations)
459
480
  if (appliedAnnotationsFingerprint == fp) return
460
481
  appliedAnnotationsFingerprint = fp
461
- val pageCount = pdfRenderer!!.pageCount
462
- pageAnnotations = MutableList(pageCount) { mutableListOf() }
482
+ pageAnnotations.clear()
483
+ while (pageAnnotations.size < totalPageCount) {
484
+ pageAnnotations.add(mutableListOf())
485
+ }
463
486
  for (data in annotations) {
464
487
  val pageIndex = (data["page"] as? Number)?.toInt() ?: continue
465
- if (pageIndex < 0 || pageIndex >= pageCount) continue
466
488
  val typeStr = data["type"] as? String ?: continue
467
- val b = data["bounds"] as? Map<String, Any> ?: continue
489
+ val b = data["bounds"] as? Map<*, *> ?: continue
468
490
  val x = (b["x"] as? Number)?.toFloat() ?: 0f
469
491
  val y = (b["y"] as? Number)?.toFloat() ?: 0f
470
492
  val w = (b["width"] as? Number)?.toFloat() ?: 0f
@@ -472,41 +494,36 @@ class ExpoPdfReaderView(context: Context, appContext: AppContext) : ExpoView(con
472
494
  val bounds = RectF(x, y, x + w, y + h)
473
495
  val color = (data["color"] as? String)?.let { parseHexColor(it) } ?: Color.BLACK
474
496
  val strokeW = (data["strokeWidth"] as? Number)?.toFloat() ?: 2f
475
- val contents = data["contents"] as? String ?: " "
476
- val ann = Annotation(
477
- type = typeStr,
478
- bounds = bounds,
479
- color = color,
480
- strokeWidth = strokeW,
481
- contents = contents,
482
- fontSize = (data["fontSize"] as? Number)?.toFloat() ?: textFontSize,
483
- bold = data["bold"] as? Boolean ?: false,
484
- italic = data["italic"] as? Boolean ?: false
485
- )
486
- if (typeStr == "pen" || typeStr == "highlighter" || typeStr == "line") {
487
- @Suppress("UNCHECKED_CAST")
488
- val paths = data["paths"] as? List<List<Map<String, Any>>> ?: emptyList()
489
- for (pathData in paths) {
490
- val points = pathData.map { pt ->
491
- PointF(
492
- (pt["x"] as? Number)?.toFloat() ?: 0f,
493
- (pt["y"] as? Number)?.toFloat() ?: 0f
494
- )
495
- }.toMutableList()
496
- if (points.isNotEmpty()) ann.paths.add(points)
497
+ val paths = mutableListOf<MutableList<PointF>>()
498
+ @Suppress("UNCHECKED_CAST")
499
+ (data["paths"] as? List<*>)?.forEach { pathData ->
500
+ val points = mutableListOf<PointF>()
501
+ (pathData as? List<*>)?.forEach { pt ->
502
+ val m = pt as? Map<*, *> ?: return@forEach
503
+ val px = (m["x"] as? Number)?.toFloat() ?: 0f
504
+ val py = (m["y"] as? Number)?.toFloat() ?: 0f
505
+ points.add(PointF(px, py))
497
506
  }
507
+ if (points.isNotEmpty()) paths.add(points)
508
+ }
509
+ val contents = data["contents"] as? String ?: ""
510
+ val fontSize = (data["fontSize"] as? Number)?.toFloat() ?: 14f
511
+ val bold = data["bold"] as? Boolean ?: false
512
+ val italic = data["italic"] as? Boolean ?: false
513
+ val ann = Annotation(typeStr, bounds, color, strokeW, paths, contents, fontSize, bold, italic)
514
+ if (pageIndex in 0 until pageAnnotations.size) {
515
+ pageAnnotations[pageIndex].add(ann)
498
516
  }
499
- pageAnnotations[pageIndex].add(ann)
500
517
  }
501
518
  overlayViews.forEach { it?.invalidate() }
519
+ notifyAnnotationChange()
502
520
  }
503
521
 
504
522
  fun undo() {
505
523
  val entry = undoStack.removeLastOrNull() ?: return
506
524
  redoStack.add(entry)
507
- if (entry.pageIndex in pageAnnotations.indices) {
508
- pageAnnotations[entry.pageIndex].remove(entry.annotation)
509
- }
525
+ val list = pageAnnotations.getOrNull(entry.pageIndex) ?: return
526
+ list.remove(entry.annotation)
510
527
  overlayViews.getOrNull(entry.pageIndex)?.invalidate()
511
528
  notifyAnnotationChange()
512
529
  notifyUndoRedoStateChange()
@@ -515,153 +532,29 @@ class ExpoPdfReaderView(context: Context, appContext: AppContext) : ExpoView(con
515
532
  fun redo() {
516
533
  val entry = redoStack.removeLastOrNull() ?: return
517
534
  undoStack.add(entry)
518
- if (entry.pageIndex in pageAnnotations.indices) {
519
- pageAnnotations[entry.pageIndex].add(entry.annotation)
535
+ while (pageAnnotations.size <= entry.pageIndex) {
536
+ pageAnnotations.add(mutableListOf())
520
537
  }
538
+ pageAnnotations[entry.pageIndex].add(entry.annotation)
521
539
  overlayViews.getOrNull(entry.pageIndex)?.invalidate()
522
540
  notifyAnnotationChange()
523
541
  notifyUndoRedoStateChange()
524
542
  }
525
543
 
526
- fun updateText(page: Int, index: Int, contents: String) {
527
- if (page !in pageAnnotations.indices || index !in pageAnnotations[page].indices) return
528
- val ann = pageAnnotations[page][index]
529
- if (ann.type != "text") return
530
- ann.contents = if (contents.isEmpty()) " " else contents
531
- overlayViews.getOrNull(page)?.invalidate()
532
- notifyAnnotationChange()
533
- notifyUndoRedoStateChange()
534
- }
535
-
536
544
  fun updateNote(page: Int, index: Int, contents: String) {
537
- if (page !in pageAnnotations.indices || index !in pageAnnotations[page].indices) return
538
- val ann = pageAnnotations[page][index]
539
- if (ann.type != "note") return
540
- ann.contents = if (contents.isEmpty()) " " else contents
545
+ val list = pageAnnotations.getOrNull(page) ?: return
546
+ if (index !in list.indices) return
547
+ list[index].contents = contents.ifEmpty { " " }
541
548
  overlayViews.getOrNull(page)?.invalidate()
542
549
  notifyAnnotationChange()
543
- notifyUndoRedoStateChange()
544
- }
545
-
546
- private fun parseHexColor(hex: String): Int {
547
- var s = hex.trim().removePrefix("#")
548
- if (s.length == 6) s = "FF$s"
549
- return try {
550
- Color.parseColor("#$s")
551
- } catch (_: Exception) {
552
- Color.BLACK
553
- }
554
550
  }
555
551
 
556
- private fun fingerprintAnnotations(annotations: List<Map<String, Any>>): String {
557
- return annotations.mapNotNull { data ->
558
- val page = data["page"] as? Int ?: return@mapNotNull null
559
- val type = data["type"] as? String ?: return@mapNotNull null
560
- val b = data["bounds"] as? Map<*, *> ?: return@mapNotNull null
561
- val x = (b["x"] as? Number)?.toDouble() ?: 0.0
562
- val y = (b["y"] as? Number)?.toDouble() ?: 0.0
563
- val w = (b["width"] as? Number)?.toDouble() ?: 0.0
564
- val h = (b["height"] as? Number)?.toDouble() ?: 0.0
565
- "$page|$type|$x,$y,$w,$h"
566
- }.sorted().joinToString("||")
567
- }
568
-
569
- private fun getAnnotationsForPage(pageIndex: Int): List<Annotation> {
570
- if (pageIndex !in pageAnnotations.indices) return emptyList()
571
- return pageAnnotations[pageIndex]
572
- }
573
-
574
- private fun addAnnotationToPage(pageIndex: Int, ann: Annotation) {
575
- while (pageAnnotations.size <= pageIndex) pageAnnotations.add(mutableListOf())
576
- pageAnnotations[pageIndex].add(ann)
577
- undoStack.add(UndoEntry(ann, pageIndex))
578
- redoStack.clear()
579
- overlayViews.getOrNull(pageIndex)?.invalidate()
552
+ fun updateText(page: Int, index: Int, contents: String) {
553
+ val list = pageAnnotations.getOrNull(page) ?: return
554
+ if (index !in list.indices) return
555
+ list[index].contents = contents.ifEmpty { " " }
556
+ overlayViews.getOrNull(page)?.invalidate()
580
557
  notifyAnnotationChange()
581
- notifyUndoRedoStateChange()
582
- }
583
-
584
- private fun addInstantAnnotation(pageIndex: Int, pdfX: Float, pdfY: Float, tool: AnnotationTool) {
585
- val isNote = (tool == AnnotationTool.NOTE)
586
- val bounds = if (isNote) {
587
- RectF(pdfX, pdfY, pdfX + 32, pdfY + 32)
588
- } else {
589
- RectF(pdfX, pdfY, pdfX + 180, pdfY + 40)
590
- }
591
- val ann = Annotation(
592
- type = if (isNote) "note" else "text",
593
- bounds = bounds,
594
- color = if (isNote) noteColor else textColor,
595
- contents = if (textContent.isEmpty()) " " else textContent,
596
- fontSize = textFontSize,
597
- bold = textBold,
598
- italic = textItalic
599
- )
600
- addAnnotationToPage(pageIndex, ann)
601
- }
602
-
603
- private fun eraseAt(pageIndex: Int, x: Float, y: Float) {
604
- if (pageIndex !in pageAnnotations.indices) return
605
- val toRemove = pageAnnotations[pageIndex].filter { it.bounds.contains(x, y) }
606
- for (ann in toRemove) {
607
- pageAnnotations[pageIndex].remove(ann)
608
- val idx = undoStack.indexOfFirst { it.annotation === ann }
609
- if (idx >= 0) undoStack.removeAt(idx)
610
- }
611
- if (toRemove.isNotEmpty()) {
612
- overlayViews.getOrNull(pageIndex)?.invalidate()
613
- notifyAnnotationChange()
614
- notifyUndoRedoStateChange()
615
- }
616
- }
617
-
618
- private fun notifyAnnotationChange() {
619
- val renderer = pdfRenderer ?: return
620
- val list = mutableListOf<Map<String, Any>>()
621
- for (pageIndex in 0 until renderer.pageCount) {
622
- val anns = getAnnotationsForPage(pageIndex)
623
- for ((idx, ann) in anns.withIndex()) {
624
- val map = mutableMapOf<String, Any>(
625
- "page" to pageIndex,
626
- "bounds" to mapOf(
627
- "x" to ann.bounds.left,
628
- "y" to ann.bounds.top,
629
- "width" to ann.bounds.width(),
630
- "height" to ann.bounds.height()
631
- ),
632
- "type" to ann.type,
633
- "color" to colorToHex(ann.color),
634
- "contents" to (ann.contents)
635
- )
636
- if (ann.type == "pen" || ann.type == "highlighter" || ann.type == "line") {
637
- map["paths"] = ann.paths.map { path ->
638
- path.map { p -> mapOf("x" to p.x, "y" to p.y) }
639
- }
640
- map["strokeWidth"] = ann.strokeWidth
641
- }
642
- if (ann.type == "text") {
643
- map["fontSize"] = ann.fontSize
644
- map["bold"] = ann.bold
645
- map["italic"] = ann.italic
646
- }
647
- list.add(map)
648
- }
649
- }
650
- onAnnotationChange(mapOf("annotations" to list))
651
- }
652
-
653
- private fun colorToHex(color: Int): String {
654
- val r = Color.red(color)
655
- val g = Color.green(color)
656
- val b = Color.blue(color)
657
- return String.format("#%02x%02x%02x", r, g, b)
658
- }
659
-
660
- private fun notifyUndoRedoStateChange() {
661
- onUndoRedoStateChange(mapOf(
662
- "canUndo" to undoStack.isNotEmpty(),
663
- "canRedo" to redoStack.isNotEmpty()
664
- ))
665
558
  }
666
559
 
667
560
  private fun scrollToPage(pageIndex: Int) {
@@ -677,23 +570,24 @@ class ExpoPdfReaderView(context: Context, appContext: AppContext) : ExpoView(con
677
570
  scope.launch {
678
571
  try {
679
572
  resetRenderer()
573
+ val decodedUrl = runCatching { java.net.URLDecoder.decode(url, "UTF-8") }.getOrElse { url }
680
574
  val file = withContext(Dispatchers.IO) {
681
575
  when {
682
- url.startsWith("file://") -> File(url.removePrefix("file://"))
683
- url.startsWith("/") -> File(url)
684
- else -> downloadFile(url)
576
+ decodedUrl.startsWith("file://") -> File(decodedUrl.removePrefix("file://"))
577
+ decodedUrl.startsWith("/") -> File(decodedUrl)
578
+ else -> downloadFile(decodedUrl)
685
579
  }
686
580
  }
687
581
  if (file.exists()) {
688
582
  fileDescriptor = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
689
583
  pdfRenderer = PdfRenderer(fileDescriptor!!)
690
584
  totalPageCount = pdfRenderer!!.pageCount
691
- undoStack.clear()
692
- redoStack.clear()
693
- appliedAnnotationsFingerprint = null
694
- notifyUndoRedoStateChange()
695
585
  renderPdf()
696
- pendingAnnotations?.let { setInitialAnnotations(it); pendingAnnotations = null }
586
+ post { renderPdf() }
587
+ pendingAnnotations?.let { ann ->
588
+ pendingAnnotations = null
589
+ setInitialAnnotations(ann)
590
+ }
697
591
  }
698
592
  } catch (e: Exception) {
699
593
  android.util.Log.e("ExpoPdfReader", "Error loading PDF", e)
@@ -707,34 +601,72 @@ class ExpoPdfReaderView(context: Context, appContext: AppContext) : ExpoView(con
707
601
  fileDescriptor?.close()
708
602
  fileDescriptor = null
709
603
  pageHeights.clear()
604
+ pageWidthsPdf.clear()
605
+ pageHeightsPdf.clear()
710
606
  pageViews.clear()
711
607
  overlayViews.clear()
712
- pageAnnotations.clear()
713
608
  container.removeAllViews()
609
+ pageAnnotations.clear()
610
+ undoStack.clear()
611
+ redoStack.clear()
612
+ appliedAnnotationsFingerprint = null
613
+ pendingAnnotations = null
614
+ currentPath = null
615
+ currentPathPoints.clear()
616
+ activePageIndex = -1
617
+ startPoint = null
618
+ draggingAnnotation = null
619
+ draggingPageIndex = -1
620
+ dragStartBounds = null
621
+ dragStartTouch = null
622
+ renderJob?.cancel()
714
623
  }
715
624
 
716
625
  private fun renderPdf() {
717
626
  val renderer = pdfRenderer ?: return
718
- if (width <= 0) return
719
- renderGeneration++
720
- computePageHeights(renderer)
721
- ensurePlaceholders(renderer)
722
- updateVisiblePages()
723
- setInitialPage(initialPage.coerceIn(0, (renderer.pageCount - 1).coerceAtLeast(0)))
724
- }
725
-
726
- private fun computePageHeights(renderer: PdfRenderer) {
727
- pageHeights.clear()
728
- pageWidthsPdf.clear()
729
- pageHeightsPdf.clear()
730
- val w = width.coerceAtLeast(1)
731
- for (i in 0 until renderer.pageCount) {
732
- val page = renderer.openPage(i)
733
- pageWidthsPdf.add(page.width.toFloat())
734
- pageHeightsPdf.add(page.height.toFloat())
735
- val h = (w.toFloat() * page.height / page.width).toInt()
736
- pageHeights.add(h)
737
- page.close()
627
+ if (width <= 0) {
628
+ if (renderPdfRetryCount < maxRenderPdfRetries) {
629
+ renderPdfRetryCount++
630
+ post { renderPdf() }
631
+ }
632
+ return
633
+ }
634
+ renderPdfRetryCount = 0
635
+ if (pageHeights.isNotEmpty()) {
636
+ updateVisiblePages()
637
+ return
638
+ }
639
+ renderJob?.cancel()
640
+ renderJob = scope.launch {
641
+ val w = width.coerceAtLeast(1)
642
+ val result = withContext(Dispatchers.Default) {
643
+ (0 until renderer.pageCount).map { i ->
644
+ val page = renderer.openPage(i)
645
+ try {
646
+ Triple(
647
+ i,
648
+ (w.toFloat() * page.height / page.width).toInt(),
649
+ Pair(page.width.toFloat(), page.height.toFloat())
650
+ )
651
+ } finally {
652
+ page.close()
653
+ }
654
+ }
655
+ }
656
+ if (!isActive) return@launch
657
+ withContext(Dispatchers.Main) {
658
+ pageHeights.clear()
659
+ pageWidthsPdf.clear()
660
+ pageHeightsPdf.clear()
661
+ for ((_, h, pdfSize) in result) {
662
+ pageHeights.add(h)
663
+ pageWidthsPdf.add(pdfSize.first)
664
+ pageHeightsPdf.add(pdfSize.second)
665
+ }
666
+ ensurePlaceholders(renderer)
667
+ scrollToPage(initialPage.coerceIn(0, (renderer.pageCount - 1).coerceAtLeast(0)))
668
+ updateVisiblePages()
669
+ }
738
670
  }
739
671
  }
740
672
 
@@ -743,7 +675,9 @@ class ExpoPdfReaderView(context: Context, appContext: AppContext) : ExpoView(con
743
675
  val pageCount = renderer.pageCount
744
676
  pageViews = MutableList(pageCount) { null }
745
677
  overlayViews = MutableList(pageCount) { null }
746
- while (pageAnnotations.size < pageCount) pageAnnotations.add(mutableListOf())
678
+ while (pageAnnotations.size < pageCount) {
679
+ pageAnnotations.add(mutableListOf())
680
+ }
747
681
  for (i in 0 until pageCount) {
748
682
  val frame = FrameLayout(context).apply {
749
683
  layoutParams = LinearLayout.LayoutParams(
@@ -764,25 +698,45 @@ class ExpoPdfReaderView(context: Context, appContext: AppContext) : ExpoView(con
764
698
  }
765
699
  }
766
700
 
701
+ private fun currentPageFromScroll(): Int {
702
+ if (pageHeights.isEmpty()) return 0
703
+ val scrollY = scrollView.scrollY
704
+ var offset = 0
705
+ for (i in pageHeights.indices) {
706
+ if (scrollY < offset + pageHeights[i] / 2) return i
707
+ offset += pageHeights[i] + 16
708
+ }
709
+ return (pageHeights.size - 1).coerceAtLeast(0)
710
+ }
711
+
767
712
  private fun updateVisiblePages() {
768
713
  val renderer = pdfRenderer ?: return
769
- val scrollY = scrollView.scrollY
770
- val viewportHeight = height
771
- var currentOffset = 0
772
- for (i in 0 until renderer.pageCount) {
773
- val pageH = pageHeights[i]
774
- if (currentOffset + pageH > scrollY - viewportHeight && currentOffset < scrollY + viewportHeight * 2) {
775
- renderPageToView(i)
776
- }
777
- currentOffset += pageH + 16
714
+ val currentPage = currentPageFromScroll()
715
+ val from = (currentPage - renderWindowBefore).coerceAtLeast(0)
716
+ val to = (currentPage + renderWindowAfter).coerceAtMost(renderer.pageCount - 1)
717
+
718
+ for (i in pageViews.indices) {
719
+ if (i in from..to) continue
720
+ val iv = pageViews.getOrNull(i) ?: continue
721
+ val frame = container.getChildAt(i) as? FrameLayout ?: continue
722
+ val bmp = (iv.drawable as? BitmapDrawable)?.bitmap
723
+ iv.setImageBitmap(null)
724
+ bmp?.recycle()
725
+ frame.removeView(iv)
726
+ pageViews[i] = null
778
727
  }
728
+
729
+ val order = (from..to).sortedBy { kotlin.math.abs(it - currentPage) }
730
+ for (i in order) {
731
+ renderPageToView(i)
732
+ }
733
+ container.post { container.invalidate() }
779
734
  }
780
735
 
781
736
  private fun renderPageToView(pageIndex: Int) {
782
737
  val renderer = pdfRenderer ?: return
783
- if (pageIndex >= container.childCount) return
784
738
  val frame = container.getChildAt(pageIndex) as? FrameLayout ?: return
785
- if (frame.childCount > 1) return // already has image + overlay
739
+ // Frame always has overlay; only skip if we already have an ImageView for this page
786
740
  if (pageViews.getOrNull(pageIndex) != null) return
787
741
  val page = renderer.openPage(pageIndex)
788
742
  val w = width.coerceAtLeast(1)
@@ -798,11 +752,11 @@ class ExpoPdfReaderView(context: Context, appContext: AppContext) : ExpoView(con
798
752
  FrameLayout.LayoutParams.MATCH_PARENT
799
753
  )
800
754
  }
801
- val overlay = overlayViews[pageIndex]
802
755
  frame.addView(imageView, 0)
803
756
  pageViews[pageIndex] = imageView
804
- overlay?.bringToFront()
805
- overlay?.invalidate()
757
+ overlayViews.getOrNull(pageIndex)?.bringToFront()
758
+ frame.requestLayout()
759
+ frame.invalidate()
806
760
  }
807
761
 
808
762
  private fun notifyPageChange() {
@@ -828,25 +782,161 @@ class ExpoPdfReaderView(context: Context, appContext: AppContext) : ExpoView(con
828
782
  updateVisiblePages()
829
783
  }
830
784
 
831
- override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
832
- if (currentTool != null) {
833
- val frame = findPageFrameAt(ev.rawX, ev.rawY)
834
- if (frame != null) return false
785
+ private fun getAnnotationsForPage(pageIndex: Int): List<Annotation> {
786
+ return pageAnnotations.getOrNull(pageIndex).orEmpty()
787
+ }
788
+
789
+ private fun addAnnotationToPage(pageIndex: Int, ann: Annotation) {
790
+ while (pageAnnotations.size <= pageIndex) {
791
+ pageAnnotations.add(mutableListOf())
792
+ }
793
+ pageAnnotations[pageIndex].add(ann)
794
+ overlayViews.getOrNull(pageIndex)?.invalidate()
795
+ }
796
+
797
+ private fun addInstantAnnotation(pageIndex: Int, pdfX: Float, pdfY: Float, tool: AnnotationTool) {
798
+ val isNote = (tool == AnnotationTool.NOTE)
799
+ val rect = if (isNote) {
800
+ RectF(pdfX, pdfY, pdfX + 32, pdfY + 32)
801
+ } else {
802
+ RectF(pdfX, pdfY, pdfX + 180, pdfY + 40)
803
+ }
804
+ val typeStr = if (isNote) "note" else "text"
805
+ val contents = if (textContent.isEmpty()) " " else textContent
806
+ val ann = Annotation(
807
+ type = typeStr,
808
+ bounds = rect,
809
+ color = if (isNote) noteColor else textColor,
810
+ strokeWidth = 0f,
811
+ paths = mutableListOf(),
812
+ contents = contents,
813
+ fontSize = textFontSize,
814
+ bold = textBold,
815
+ italic = textItalic
816
+ )
817
+ addAnnotationToPage(pageIndex, ann)
818
+ undoStack.add(UndoEntry(ann, pageIndex))
819
+ redoStack.clear()
820
+ notifyAnnotationChange()
821
+ notifyUndoRedoStateChange()
822
+ }
823
+
824
+ private fun eraseAt(pageIndex: Int, pdfX: Float, pdfY: Float) {
825
+ val list = pageAnnotations.getOrNull(pageIndex) ?: return
826
+ var erased = false
827
+ val toRemove = list.filter { it.bounds.contains(pdfX, pdfY) }
828
+ for (ann in toRemove) {
829
+ list.remove(ann)
830
+ undoStack.removeAll { it.annotation == ann }
831
+ erased = true
832
+ }
833
+ if (erased) {
834
+ overlayViews.getOrNull(pageIndex)?.invalidate()
835
+ notifyAnnotationChange()
836
+ notifyUndoRedoStateChange()
837
+ }
838
+ }
839
+
840
+ private fun commitStroke(tool: AnnotationTool) {
841
+ if (currentPathPoints.size < 2) return
842
+ val minX = currentPathPoints.minOf { it.x } - strokeWidth
843
+ val minY = currentPathPoints.minOf { it.y } - strokeWidth
844
+ val maxX = currentPathPoints.maxOf { it.x } + strokeWidth
845
+ val maxY = currentPathPoints.maxOf { it.y } + strokeWidth
846
+ val bounds = RectF(minX, minY, maxX, maxY)
847
+ val paths = mutableListOf<MutableList<PointF>>()
848
+ val points = currentPathPoints.map { PointF(it.x, it.y) }.toMutableList()
849
+ paths.add(points)
850
+ val typeStr = when (tool) {
851
+ AnnotationTool.PEN -> "pen"
852
+ AnnotationTool.HIGHLIGHTER -> "highlighter"
853
+ AnnotationTool.LINE -> "line"
854
+ else -> "pen"
855
+ }
856
+ val color = if (tool == AnnotationTool.HIGHLIGHTER) {
857
+ Color.argb(102, Color.red(strokeColor), Color.green(strokeColor), Color.blue(strokeColor))
858
+ } else {
859
+ strokeColor
860
+ }
861
+ val ann = Annotation(typeStr, bounds, color, strokeWidth, paths, "", 0f, false, false)
862
+ addAnnotationToPage(activePageIndex, ann)
863
+ undoStack.add(UndoEntry(ann, activePageIndex))
864
+ redoStack.clear()
865
+ notifyAnnotationChange()
866
+ notifyUndoRedoStateChange()
867
+ }
868
+
869
+ private fun notifyAnnotationChange() {
870
+ val list = mutableListOf<Map<String, Any>>()
871
+ for (pageIndex in pageAnnotations.indices) {
872
+ for (ann in getAnnotationsForPage(pageIndex)) {
873
+ list.add(mapOf(
874
+ "page" to pageIndex,
875
+ "bounds" to mapOf(
876
+ "x" to ann.bounds.left,
877
+ "y" to ann.bounds.top,
878
+ "width" to ann.bounds.width(),
879
+ "height" to ann.bounds.height()
880
+ ),
881
+ "type" to ann.type,
882
+ "color" to colorToHex(ann.color),
883
+ "contents" to ann.contents,
884
+ "paths" to ann.paths.map { path ->
885
+ path.map { p -> mapOf("x" to p.x, "y" to p.y) }
886
+ },
887
+ "strokeWidth" to ann.strokeWidth,
888
+ "fontSize" to ann.fontSize,
889
+ "bold" to ann.bold,
890
+ "italic" to ann.italic
891
+ ))
892
+ }
835
893
  }
836
- return false
894
+ onAnnotationChange(mapOf("annotations" to list))
837
895
  }
838
896
 
839
- private fun findPageFrameAt(rawX: Float, rawY: Float): View? {
840
- val loc = IntArray(2)
841
- for (i in 0 until container.childCount) {
842
- val child = container.getChildAt(i)
843
- child.getLocationOnScreen(loc)
844
- if (rawX >= loc[0] && rawX < loc[0] + child.width &&
845
- rawY >= loc[1] && rawY < loc[1] + child.height) {
846
- return child
897
+ private fun notifyUndoRedoStateChange() {
898
+ onUndoRedoStateChange(mapOf(
899
+ "canUndo" to undoStack.isNotEmpty(),
900
+ "canRedo" to redoStack.isNotEmpty()
901
+ ))
902
+ }
903
+
904
+ private fun parseHexColor(hex: String): Int {
905
+ var s = hex.trim().uppercase()
906
+ if (s.startsWith("#")) s = s.removePrefix("#")
907
+ val v = s.toLongOrNull(16) ?: 0L
908
+ val r = ((v shr 16) and 0xFF).toInt()
909
+ val g = ((v shr 8) and 0xFF).toInt()
910
+ val b = (v and 0xFF).toInt()
911
+ return Color.rgb(r, g, b)
912
+ }
913
+
914
+ private fun fingerprintAnnotations(annotations: List<Map<String, Any>>): String {
915
+ val items = annotations.mapNotNull { data ->
916
+ val pageIndex = (data["page"] as? Number)?.toInt() ?: return@mapNotNull null
917
+ val typeStr = data["type"] as? String ?: return@mapNotNull null
918
+ val b = data["bounds"] as? Map<*, *> ?: return@mapNotNull null
919
+ val x = (b["x"] as? Number)?.toDouble() ?: 0.0
920
+ val y = (b["y"] as? Number)?.toDouble() ?: 0.0
921
+ val w = (b["width"] as? Number)?.toDouble() ?: 0.0
922
+ val h = (b["height"] as? Number)?.toDouble() ?: 0.0
923
+ val color = data["color"] as? String ?: ""
924
+ val strokeW = data["strokeWidth"] as? Double ?: 0.0
925
+ val contents = data["contents"] as? String ?: ""
926
+ var ptsCount = 0
927
+ (data["paths"] as? List<*>)?.forEach { pathData ->
928
+ (pathData as? List<*>)?.forEach { ptsCount++ }
847
929
  }
930
+ "$pageIndex|$typeStr|$x,$y,$w,$h|$color|$strokeW|$contents|pts:$ptsCount"
848
931
  }
849
- return null
932
+ return items.sorted().joinToString("||")
933
+ }
934
+
935
+ private fun colorToHex(color: Int): String {
936
+ val r = Color.red(color)
937
+ val g = Color.green(color)
938
+ val b = Color.blue(color)
939
+ return String.format("#%02x%02x%02x", r, g, b)
850
940
  }
851
941
 
852
942
  private suspend fun downloadFile(urlStr: String): File = withContext(Dispatchers.IO) {