@june24/expo-pdf-reader 0.1.17 → 0.1.19

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,60 @@ 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 && pageHeights.isEmpty()) {
409
+ renderPdf()
410
+ }
411
+ }
412
+
413
+ fun setUrl(url: String?) {
414
+ val u = url?.takeIf { it.isNotBlank() } ?: return
415
+ if (pdfUrl == u && pdfRenderer != null) return
416
+ pdfUrl = u
417
+ loadPdfFromUrl(u)
413
418
  }
414
419
 
415
420
  fun setDisplayMode(mode: String) {
416
- displayMode = when (mode) {
417
- "single" -> "single"
418
- "continuous" -> "continuous"
419
- "twoUp" -> "twoUp"
420
- "twoUpContinuous" -> "twoUpContinuous"
421
- else -> "continuous"
422
- }
421
+ if (displayMode == mode) return
422
+ displayMode = mode
423
+ pageHeights.clear()
423
424
  pdfRenderer?.let { renderPdf() }
424
425
  }
425
426
 
@@ -429,26 +430,41 @@ class ExpoPdfReaderView(context: Context, appContext: AppContext) : ExpoView(con
429
430
  }
430
431
 
431
432
  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) }
433
+ currentTool = tool?.let { runCatching { AnnotationTool.valueOf(it.uppercase()) }.getOrNull() }
434
+ // ToolAwareScrollView uses isScrollEnabledProvider { currentTool == null } — no property to set
435
+ }
436
+
437
+ fun setStrokeColor(hex: String) {
438
+ strokeColor = parseHexColor(hex)
439
+ }
440
+
441
+ fun setStrokeWidth(width: Float) {
442
+ strokeWidth = width
443
+ }
444
+
445
+ fun setTextContent(text: String) {
446
+ textContent = text
447
+ }
448
+
449
+ fun setTextColor(hex: String) {
450
+ textColor = parseHexColor(hex)
451
+ }
452
+
453
+ fun setTextFontSize(size: Float) {
454
+ textFontSize = size
455
+ }
456
+
457
+ fun setTextBold(value: Boolean) {
458
+ textBold = value
459
+ }
460
+
461
+ fun setTextItalic(value: Boolean) {
462
+ textItalic = value
463
+ }
464
+
465
+ fun setNoteColor(hex: String) {
466
+ noteColor = parseHexColor(hex)
467
+ }
452
468
 
453
469
  fun setInitialAnnotations(annotations: List<Map<String, Any>>) {
454
470
  if (pdfRenderer == null) {
@@ -458,13 +474,14 @@ class ExpoPdfReaderView(context: Context, appContext: AppContext) : ExpoView(con
458
474
  val fp = fingerprintAnnotations(annotations)
459
475
  if (appliedAnnotationsFingerprint == fp) return
460
476
  appliedAnnotationsFingerprint = fp
461
- val pageCount = pdfRenderer!!.pageCount
462
- pageAnnotations = MutableList(pageCount) { mutableListOf() }
477
+ pageAnnotations.clear()
478
+ while (pageAnnotations.size < totalPageCount) {
479
+ pageAnnotations.add(mutableListOf())
480
+ }
463
481
  for (data in annotations) {
464
482
  val pageIndex = (data["page"] as? Number)?.toInt() ?: continue
465
- if (pageIndex < 0 || pageIndex >= pageCount) continue
466
483
  val typeStr = data["type"] as? String ?: continue
467
- val b = data["bounds"] as? Map<String, Any> ?: continue
484
+ val b = data["bounds"] as? Map<*, *> ?: continue
468
485
  val x = (b["x"] as? Number)?.toFloat() ?: 0f
469
486
  val y = (b["y"] as? Number)?.toFloat() ?: 0f
470
487
  val w = (b["width"] as? Number)?.toFloat() ?: 0f
@@ -472,41 +489,36 @@ class ExpoPdfReaderView(context: Context, appContext: AppContext) : ExpoView(con
472
489
  val bounds = RectF(x, y, x + w, y + h)
473
490
  val color = (data["color"] as? String)?.let { parseHexColor(it) } ?: Color.BLACK
474
491
  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)
492
+ val paths = mutableListOf<MutableList<PointF>>()
493
+ @Suppress("UNCHECKED_CAST")
494
+ (data["paths"] as? List<*>)?.forEach { pathData ->
495
+ val points = mutableListOf<PointF>()
496
+ (pathData as? List<*>)?.forEach { pt ->
497
+ val m = pt as? Map<*, *> ?: return@forEach
498
+ val px = (m["x"] as? Number)?.toFloat() ?: 0f
499
+ val py = (m["y"] as? Number)?.toFloat() ?: 0f
500
+ points.add(PointF(px, py))
497
501
  }
502
+ if (points.isNotEmpty()) paths.add(points)
503
+ }
504
+ val contents = data["contents"] as? String ?: ""
505
+ val fontSize = (data["fontSize"] as? Number)?.toFloat() ?: 14f
506
+ val bold = data["bold"] as? Boolean ?: false
507
+ val italic = data["italic"] as? Boolean ?: false
508
+ val ann = Annotation(typeStr, bounds, color, strokeW, paths, contents, fontSize, bold, italic)
509
+ if (pageIndex in 0 until pageAnnotations.size) {
510
+ pageAnnotations[pageIndex].add(ann)
498
511
  }
499
- pageAnnotations[pageIndex].add(ann)
500
512
  }
501
513
  overlayViews.forEach { it?.invalidate() }
514
+ notifyAnnotationChange()
502
515
  }
503
516
 
504
517
  fun undo() {
505
518
  val entry = undoStack.removeLastOrNull() ?: return
506
519
  redoStack.add(entry)
507
- if (entry.pageIndex in pageAnnotations.indices) {
508
- pageAnnotations[entry.pageIndex].remove(entry.annotation)
509
- }
520
+ val list = pageAnnotations.getOrNull(entry.pageIndex) ?: return
521
+ list.remove(entry.annotation)
510
522
  overlayViews.getOrNull(entry.pageIndex)?.invalidate()
511
523
  notifyAnnotationChange()
512
524
  notifyUndoRedoStateChange()
@@ -515,153 +527,29 @@ class ExpoPdfReaderView(context: Context, appContext: AppContext) : ExpoView(con
515
527
  fun redo() {
516
528
  val entry = redoStack.removeLastOrNull() ?: return
517
529
  undoStack.add(entry)
518
- if (entry.pageIndex in pageAnnotations.indices) {
519
- pageAnnotations[entry.pageIndex].add(entry.annotation)
530
+ while (pageAnnotations.size <= entry.pageIndex) {
531
+ pageAnnotations.add(mutableListOf())
520
532
  }
533
+ pageAnnotations[entry.pageIndex].add(entry.annotation)
521
534
  overlayViews.getOrNull(entry.pageIndex)?.invalidate()
522
535
  notifyAnnotationChange()
523
536
  notifyUndoRedoStateChange()
524
537
  }
525
538
 
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
539
  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
540
+ val list = pageAnnotations.getOrNull(page) ?: return
541
+ if (index !in list.indices) return
542
+ list[index].contents = contents.ifEmpty { " " }
541
543
  overlayViews.getOrNull(page)?.invalidate()
542
544
  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
- }
555
-
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
545
  }
568
546
 
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()
547
+ fun updateText(page: Int, index: Int, contents: String) {
548
+ val list = pageAnnotations.getOrNull(page) ?: return
549
+ if (index !in list.indices) return
550
+ list[index].contents = contents.ifEmpty { " " }
551
+ overlayViews.getOrNull(page)?.invalidate()
580
552
  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
553
  }
666
554
 
667
555
  private fun scrollToPage(pageIndex: Int) {
@@ -677,23 +565,24 @@ class ExpoPdfReaderView(context: Context, appContext: AppContext) : ExpoView(con
677
565
  scope.launch {
678
566
  try {
679
567
  resetRenderer()
568
+ val decodedUrl = runCatching { java.net.URLDecoder.decode(url, "UTF-8") }.getOrElse { url }
680
569
  val file = withContext(Dispatchers.IO) {
681
570
  when {
682
- url.startsWith("file://") -> File(url.removePrefix("file://"))
683
- url.startsWith("/") -> File(url)
684
- else -> downloadFile(url)
571
+ decodedUrl.startsWith("file://") -> File(decodedUrl.removePrefix("file://"))
572
+ decodedUrl.startsWith("/") -> File(decodedUrl)
573
+ else -> downloadFile(decodedUrl)
685
574
  }
686
575
  }
687
576
  if (file.exists()) {
688
577
  fileDescriptor = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
689
578
  pdfRenderer = PdfRenderer(fileDescriptor!!)
690
579
  totalPageCount = pdfRenderer!!.pageCount
691
- undoStack.clear()
692
- redoStack.clear()
693
- appliedAnnotationsFingerprint = null
694
- notifyUndoRedoStateChange()
695
580
  renderPdf()
696
- pendingAnnotations?.let { setInitialAnnotations(it); pendingAnnotations = null }
581
+ post { renderPdf() }
582
+ pendingAnnotations?.let { ann ->
583
+ pendingAnnotations = null
584
+ setInitialAnnotations(ann)
585
+ }
697
586
  }
698
587
  } catch (e: Exception) {
699
588
  android.util.Log.e("ExpoPdfReader", "Error loading PDF", e)
@@ -707,34 +596,70 @@ class ExpoPdfReaderView(context: Context, appContext: AppContext) : ExpoView(con
707
596
  fileDescriptor?.close()
708
597
  fileDescriptor = null
709
598
  pageHeights.clear()
599
+ pageWidthsPdf.clear()
600
+ pageHeightsPdf.clear()
710
601
  pageViews.clear()
711
602
  overlayViews.clear()
712
- pageAnnotations.clear()
713
603
  container.removeAllViews()
604
+ pageAnnotations.clear()
605
+ undoStack.clear()
606
+ redoStack.clear()
607
+ appliedAnnotationsFingerprint = null
608
+ pendingAnnotations = null
609
+ currentPath = null
610
+ currentPathPoints.clear()
611
+ activePageIndex = -1
612
+ startPoint = null
613
+ draggingAnnotation = null
614
+ draggingPageIndex = -1
615
+ dragStartBounds = null
616
+ dragStartTouch = null
714
617
  }
715
618
 
716
619
  private fun renderPdf() {
717
620
  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()
621
+ if (width <= 0) {
622
+ if (renderPdfRetryCount < maxRenderPdfRetries) {
623
+ renderPdfRetryCount++
624
+ post { renderPdf() }
625
+ }
626
+ return
627
+ }
628
+ renderPdfRetryCount = 0
629
+ if (pageHeights.isNotEmpty()) {
630
+ updateVisiblePages()
631
+ return
632
+ }
633
+ scope.launch {
634
+ val w = width.coerceAtLeast(1)
635
+ val result = withContext(Dispatchers.Default) {
636
+ (0 until renderer.pageCount).map { i ->
637
+ val page = renderer.openPage(i)
638
+ try {
639
+ Triple(
640
+ i,
641
+ (w.toFloat() * page.height / page.width).toInt(),
642
+ Pair(page.width.toFloat(), page.height.toFloat())
643
+ )
644
+ } finally {
645
+ page.close()
646
+ }
647
+ }
648
+ }
649
+ if (!isActive) return@launch
650
+ withContext(Dispatchers.Main) {
651
+ pageHeights.clear()
652
+ pageWidthsPdf.clear()
653
+ pageHeightsPdf.clear()
654
+ for ((_, h, pdfSize) in result) {
655
+ pageHeights.add(h)
656
+ pageWidthsPdf.add(pdfSize.first)
657
+ pageHeightsPdf.add(pdfSize.second)
658
+ }
659
+ ensurePlaceholders(renderer)
660
+ scrollToPage(initialPage.coerceIn(0, (renderer.pageCount - 1).coerceAtLeast(0)))
661
+ updateVisiblePages()
662
+ }
738
663
  }
739
664
  }
740
665
 
@@ -743,7 +668,9 @@ class ExpoPdfReaderView(context: Context, appContext: AppContext) : ExpoView(con
743
668
  val pageCount = renderer.pageCount
744
669
  pageViews = MutableList(pageCount) { null }
745
670
  overlayViews = MutableList(pageCount) { null }
746
- while (pageAnnotations.size < pageCount) pageAnnotations.add(mutableListOf())
671
+ while (pageAnnotations.size < pageCount) {
672
+ pageAnnotations.add(mutableListOf())
673
+ }
747
674
  for (i in 0 until pageCount) {
748
675
  val frame = FrameLayout(context).apply {
749
676
  layoutParams = LinearLayout.LayoutParams(
@@ -764,25 +691,45 @@ class ExpoPdfReaderView(context: Context, appContext: AppContext) : ExpoView(con
764
691
  }
765
692
  }
766
693
 
694
+ private fun currentPageFromScroll(): Int {
695
+ if (pageHeights.isEmpty()) return 0
696
+ val scrollY = scrollView.scrollY
697
+ var offset = 0
698
+ for (i in pageHeights.indices) {
699
+ if (scrollY < offset + pageHeights[i] / 2) return i
700
+ offset += pageHeights[i] + 16
701
+ }
702
+ return (pageHeights.size - 1).coerceAtLeast(0)
703
+ }
704
+
767
705
  private fun updateVisiblePages() {
768
706
  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
707
+ val currentPage = currentPageFromScroll()
708
+ val from = (currentPage - renderWindowBefore).coerceAtLeast(0)
709
+ val to = (currentPage + renderWindowAfter).coerceAtMost(renderer.pageCount - 1)
710
+
711
+ for (i in pageViews.indices) {
712
+ if (i in from..to) continue
713
+ val iv = pageViews.getOrNull(i) ?: continue
714
+ val frame = container.getChildAt(i) as? FrameLayout ?: continue
715
+ val bmp = (iv.drawable as? BitmapDrawable)?.bitmap
716
+ iv.setImageBitmap(null)
717
+ bmp?.recycle()
718
+ frame.removeView(iv)
719
+ pageViews[i] = null
778
720
  }
721
+
722
+ val order = (from..to).sortedBy { kotlin.math.abs(it - currentPage) }
723
+ for (i in order) {
724
+ renderPageToView(i)
725
+ }
726
+ container.post { container.invalidate() }
779
727
  }
780
728
 
781
729
  private fun renderPageToView(pageIndex: Int) {
782
730
  val renderer = pdfRenderer ?: return
783
- if (pageIndex >= container.childCount) return
784
731
  val frame = container.getChildAt(pageIndex) as? FrameLayout ?: return
785
- if (frame.childCount > 1) return // already has image + overlay
732
+ // Frame always has overlay; only skip if we already have an ImageView for this page
786
733
  if (pageViews.getOrNull(pageIndex) != null) return
787
734
  val page = renderer.openPage(pageIndex)
788
735
  val w = width.coerceAtLeast(1)
@@ -798,11 +745,11 @@ class ExpoPdfReaderView(context: Context, appContext: AppContext) : ExpoView(con
798
745
  FrameLayout.LayoutParams.MATCH_PARENT
799
746
  )
800
747
  }
801
- val overlay = overlayViews[pageIndex]
802
748
  frame.addView(imageView, 0)
803
749
  pageViews[pageIndex] = imageView
804
- overlay?.bringToFront()
805
- overlay?.invalidate()
750
+ overlayViews.getOrNull(pageIndex)?.bringToFront()
751
+ frame.requestLayout()
752
+ frame.invalidate()
806
753
  }
807
754
 
808
755
  private fun notifyPageChange() {
@@ -828,25 +775,161 @@ class ExpoPdfReaderView(context: Context, appContext: AppContext) : ExpoView(con
828
775
  updateVisiblePages()
829
776
  }
830
777
 
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
778
+ private fun getAnnotationsForPage(pageIndex: Int): List<Annotation> {
779
+ return pageAnnotations.getOrNull(pageIndex).orEmpty()
780
+ }
781
+
782
+ private fun addAnnotationToPage(pageIndex: Int, ann: Annotation) {
783
+ while (pageAnnotations.size <= pageIndex) {
784
+ pageAnnotations.add(mutableListOf())
785
+ }
786
+ pageAnnotations[pageIndex].add(ann)
787
+ overlayViews.getOrNull(pageIndex)?.invalidate()
788
+ }
789
+
790
+ private fun addInstantAnnotation(pageIndex: Int, pdfX: Float, pdfY: Float, tool: AnnotationTool) {
791
+ val isNote = (tool == AnnotationTool.NOTE)
792
+ val rect = if (isNote) {
793
+ RectF(pdfX, pdfY, pdfX + 32, pdfY + 32)
794
+ } else {
795
+ RectF(pdfX, pdfY, pdfX + 180, pdfY + 40)
796
+ }
797
+ val typeStr = if (isNote) "note" else "text"
798
+ val contents = if (textContent.isEmpty()) " " else textContent
799
+ val ann = Annotation(
800
+ type = typeStr,
801
+ bounds = rect,
802
+ color = if (isNote) noteColor else textColor,
803
+ strokeWidth = 0f,
804
+ paths = mutableListOf(),
805
+ contents = contents,
806
+ fontSize = textFontSize,
807
+ bold = textBold,
808
+ italic = textItalic
809
+ )
810
+ addAnnotationToPage(pageIndex, ann)
811
+ undoStack.add(UndoEntry(ann, pageIndex))
812
+ redoStack.clear()
813
+ notifyAnnotationChange()
814
+ notifyUndoRedoStateChange()
815
+ }
816
+
817
+ private fun eraseAt(pageIndex: Int, pdfX: Float, pdfY: Float) {
818
+ val list = pageAnnotations.getOrNull(pageIndex) ?: return
819
+ var erased = false
820
+ val toRemove = list.filter { it.bounds.contains(pdfX, pdfY) }
821
+ for (ann in toRemove) {
822
+ list.remove(ann)
823
+ undoStack.removeAll { it.annotation == ann }
824
+ erased = true
825
+ }
826
+ if (erased) {
827
+ overlayViews.getOrNull(pageIndex)?.invalidate()
828
+ notifyAnnotationChange()
829
+ notifyUndoRedoStateChange()
830
+ }
831
+ }
832
+
833
+ private fun commitStroke(tool: AnnotationTool) {
834
+ if (currentPathPoints.size < 2) return
835
+ val minX = currentPathPoints.minOf { it.x } - strokeWidth
836
+ val minY = currentPathPoints.minOf { it.y } - strokeWidth
837
+ val maxX = currentPathPoints.maxOf { it.x } + strokeWidth
838
+ val maxY = currentPathPoints.maxOf { it.y } + strokeWidth
839
+ val bounds = RectF(minX, minY, maxX, maxY)
840
+ val paths = mutableListOf<MutableList<PointF>>()
841
+ val points = currentPathPoints.map { PointF(it.x, it.y) }.toMutableList()
842
+ paths.add(points)
843
+ val typeStr = when (tool) {
844
+ AnnotationTool.PEN -> "pen"
845
+ AnnotationTool.HIGHLIGHTER -> "highlighter"
846
+ AnnotationTool.LINE -> "line"
847
+ else -> "pen"
848
+ }
849
+ val color = if (tool == AnnotationTool.HIGHLIGHTER) {
850
+ Color.argb(102, Color.red(strokeColor), Color.green(strokeColor), Color.blue(strokeColor))
851
+ } else {
852
+ strokeColor
853
+ }
854
+ val ann = Annotation(typeStr, bounds, color, strokeWidth, paths, "", 0f, false, false)
855
+ addAnnotationToPage(activePageIndex, ann)
856
+ undoStack.add(UndoEntry(ann, activePageIndex))
857
+ redoStack.clear()
858
+ notifyAnnotationChange()
859
+ notifyUndoRedoStateChange()
860
+ }
861
+
862
+ private fun notifyAnnotationChange() {
863
+ val list = mutableListOf<Map<String, Any>>()
864
+ for (pageIndex in pageAnnotations.indices) {
865
+ for (ann in getAnnotationsForPage(pageIndex)) {
866
+ list.add(mapOf(
867
+ "page" to pageIndex,
868
+ "bounds" to mapOf(
869
+ "x" to ann.bounds.left,
870
+ "y" to ann.bounds.top,
871
+ "width" to ann.bounds.width(),
872
+ "height" to ann.bounds.height()
873
+ ),
874
+ "type" to ann.type,
875
+ "color" to colorToHex(ann.color),
876
+ "contents" to ann.contents,
877
+ "paths" to ann.paths.map { path ->
878
+ path.map { p -> mapOf("x" to p.x, "y" to p.y) }
879
+ },
880
+ "strokeWidth" to ann.strokeWidth,
881
+ "fontSize" to ann.fontSize,
882
+ "bold" to ann.bold,
883
+ "italic" to ann.italic
884
+ ))
885
+ }
835
886
  }
836
- return false
887
+ onAnnotationChange(mapOf("annotations" to list))
837
888
  }
838
889
 
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
890
+ private fun notifyUndoRedoStateChange() {
891
+ onUndoRedoStateChange(mapOf(
892
+ "canUndo" to undoStack.isNotEmpty(),
893
+ "canRedo" to redoStack.isNotEmpty()
894
+ ))
895
+ }
896
+
897
+ private fun parseHexColor(hex: String): Int {
898
+ var s = hex.trim().uppercase()
899
+ if (s.startsWith("#")) s = s.removePrefix("#")
900
+ val v = s.toLongOrNull(16) ?: 0L
901
+ val r = ((v shr 16) and 0xFF).toInt()
902
+ val g = ((v shr 8) and 0xFF).toInt()
903
+ val b = (v and 0xFF).toInt()
904
+ return Color.rgb(r, g, b)
905
+ }
906
+
907
+ private fun fingerprintAnnotations(annotations: List<Map<String, Any>>): String {
908
+ val items = annotations.mapNotNull { data ->
909
+ val pageIndex = (data["page"] as? Number)?.toInt() ?: return@mapNotNull null
910
+ val typeStr = data["type"] as? String ?: return@mapNotNull null
911
+ val b = data["bounds"] as? Map<*, *> ?: return@mapNotNull null
912
+ val x = (b["x"] as? Number)?.toDouble() ?: 0.0
913
+ val y = (b["y"] as? Number)?.toDouble() ?: 0.0
914
+ val w = (b["width"] as? Number)?.toDouble() ?: 0.0
915
+ val h = (b["height"] as? Number)?.toDouble() ?: 0.0
916
+ val color = data["color"] as? String ?: ""
917
+ val strokeW = data["strokeWidth"] as? Double ?: 0.0
918
+ val contents = data["contents"] as? String ?: ""
919
+ var ptsCount = 0
920
+ (data["paths"] as? List<*>)?.forEach { pathData ->
921
+ (pathData as? List<*>)?.forEach { ptsCount++ }
847
922
  }
923
+ "$pageIndex|$typeStr|$x,$y,$w,$h|$color|$strokeW|$contents|pts:$ptsCount"
848
924
  }
849
- return null
925
+ return items.sorted().joinToString("||")
926
+ }
927
+
928
+ private fun colorToHex(color: Int): String {
929
+ val r = Color.red(color)
930
+ val g = Color.green(color)
931
+ val b = Color.blue(color)
932
+ return String.format("#%02x%02x%02x", r, g, b)
850
933
  }
851
934
 
852
935
  private suspend fun downloadFile(urlStr: String): File = withContext(Dispatchers.IO) {