@june24/expo-pdf-reader 0.1.0

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.
@@ -0,0 +1,934 @@
1
+ package expo.modules.pdfreader
2
+
3
+ import android.content.Context
4
+ import android.graphics.*
5
+ import android.graphics.pdf.PdfDocument
6
+ import android.graphics.pdf.PdfRenderer
7
+ import android.net.Uri
8
+ import android.os.ParcelFileDescriptor
9
+ import android.view.MotionEvent
10
+ import android.view.View
11
+ import android.widget.FrameLayout
12
+ import android.widget.ImageView
13
+ import android.widget.ScrollView
14
+ import android.widget.LinearLayout
15
+ import android.view.ViewTreeObserver
16
+ import android.view.ViewGroup
17
+ import expo.modules.kotlin.AppContext
18
+ import expo.modules.kotlin.views.ExpoView
19
+ import expo.modules.kotlin.viewevent.EventDispatcher
20
+ import java.io.File
21
+ import java.io.FileOutputStream
22
+ import java.net.URL
23
+ import kotlinx.coroutines.*
24
+
25
+ class ExpoPdfReaderView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
26
+ private val scrollView = ScrollView(context)
27
+ private val container = FrameLayout(context)
28
+ private val imageView = ImageView(context)
29
+ private val drawingView = DrawingView(context)
30
+
31
+ private var pdfRenderer: PdfRenderer? = null
32
+ private var currentPage: PdfRenderer.Page? = null
33
+ private var fileDescriptor: ParcelFileDescriptor? = null
34
+ private val scope = CoroutineScope(Dispatchers.Main)
35
+
36
+ private var currentTool = "none"
37
+ private var currentColor = Color.BLACK
38
+ private var currentFontSize = 16.0
39
+ private var currentText = "Text"
40
+ private var currentPdfFile: File? = null
41
+ private var currentStrokeWidth: Double = 10.0
42
+ private var displayMode = "continuous" // 'single' | 'continuous' | 'twoUp' | 'twoUpContinuous'
43
+ private var initialPage: Int = 0
44
+ private var minZoom: Float = 1.0f
45
+ private var maxZoom: Float = 4.0f
46
+ private var currentZoom: Float = 1.0f
47
+
48
+ private val onAnnotationChange by EventDispatcher()
49
+ private val onPageChange by EventDispatcher()
50
+ private val onScroll by EventDispatcher()
51
+
52
+ init {
53
+ // Setup ScrollView
54
+ scrollView.layoutParams = FrameLayout.LayoutParams(
55
+ FrameLayout.LayoutParams.MATCH_PARENT,
56
+ FrameLayout.LayoutParams.MATCH_PARENT
57
+ )
58
+ scrollView.isFillViewport = true
59
+
60
+ // Setup container
61
+ container.layoutParams = FrameLayout.LayoutParams(
62
+ FrameLayout.LayoutParams.MATCH_PARENT,
63
+ FrameLayout.LayoutParams.WRAP_CONTENT
64
+ )
65
+
66
+ // Setup Image View
67
+ imageView.layoutParams = FrameLayout.LayoutParams(
68
+ FrameLayout.LayoutParams.MATCH_PARENT,
69
+ FrameLayout.LayoutParams.WRAP_CONTENT
70
+ )
71
+ imageView.adjustViewBounds = true
72
+ imageView.scaleType = ImageView.ScaleType.FIT_START
73
+
74
+ // Setup Drawing View (Overlay)
75
+ drawingView.layoutParams = FrameLayout.LayoutParams(
76
+ FrameLayout.LayoutParams.MATCH_PARENT,
77
+ FrameLayout.LayoutParams.MATCH_PARENT
78
+ )
79
+ drawingView.setBackgroundColor(Color.TRANSPARENT)
80
+ drawingView.visibility = View.GONE // Hidden by default
81
+
82
+ // Set callback
83
+ drawingView.onDrawingChanged = {
84
+ onAnnotationChange(mapOf("annotations" to getAnnotations()))
85
+ }
86
+
87
+ // Setup scroll listener
88
+ scrollView.viewTreeObserver.addOnScrollChangedListener {
89
+ val scrollX = scrollView.scrollX
90
+ val scrollY = scrollView.scrollY
91
+ val contentWidth = scrollView.getChildAt(0)?.width ?: 0
92
+ val contentHeight = scrollView.getChildAt(0)?.height ?: 0
93
+ val layoutWidth = scrollView.width
94
+ val layoutHeight = scrollView.height
95
+
96
+ onScroll(mapOf(
97
+ "x" to scrollX.toDouble(),
98
+ "y" to scrollY.toDouble(),
99
+ "contentWidth" to contentWidth.toDouble(),
100
+ "contentHeight" to contentHeight.toDouble(),
101
+ "layoutWidth" to layoutWidth.toDouble(),
102
+ "layoutHeight" to layoutHeight.toDouble()
103
+ ))
104
+ }
105
+
106
+ container.addView(imageView)
107
+ container.addView(drawingView)
108
+ container.scaleX = currentZoom
109
+ container.scaleY = currentZoom
110
+ scrollView.addView(container)
111
+ addView(scrollView)
112
+ }
113
+
114
+ fun setDisplayMode(mode: String) {
115
+ displayMode = mode
116
+ // Re-render PDF with new display mode
117
+ currentPdfFile?.let { file ->
118
+ renderPdf(file)
119
+ }
120
+ }
121
+
122
+ fun setInitialPage(page: Int) {
123
+ initialPage = if (page >= 0) page else 0
124
+ // If PDF already loaded, jump immediately
125
+ if (pdfRenderer != null) {
126
+ goToPage(initialPage)
127
+ }
128
+ }
129
+
130
+ fun setMinZoom(value: Double) {
131
+ val v = value.toFloat()
132
+ minZoom = if (v > 0f) v else 0.5f
133
+ if (currentZoom < minZoom) {
134
+ applyZoom(minZoom)
135
+ }
136
+ }
137
+
138
+ fun setMaxZoom(value: Double) {
139
+ val v = value.toFloat()
140
+ maxZoom = if (v > minZoom) v else minZoom
141
+ if (currentZoom > maxZoom) {
142
+ applyZoom(maxZoom)
143
+ }
144
+ }
145
+
146
+ private fun applyZoom(factor: Float) {
147
+ currentZoom = factor
148
+ container.scaleX = factor
149
+ container.scaleY = factor
150
+ }
151
+
152
+ fun zoomIn(): Boolean {
153
+ val newZoom = (currentZoom * 1.25f).coerceAtMost(maxZoom)
154
+ if (newZoom == currentZoom) return false
155
+ applyZoom(newZoom)
156
+ return true
157
+ }
158
+
159
+ fun zoomOut(): Boolean {
160
+ val newZoom = (currentZoom / 1.25f).coerceAtLeast(minZoom)
161
+ if (newZoom == currentZoom) return false
162
+ applyZoom(newZoom)
163
+ return true
164
+ }
165
+
166
+ fun setUrl(url: String) {
167
+ scope.launch(Dispatchers.IO) {
168
+ try {
169
+ val file = if (url.startsWith("http")) {
170
+ downloadFile(url)
171
+ } else {
172
+ val uri = Uri.parse(url)
173
+ File(uri.path ?: url)
174
+ }
175
+
176
+ if (file.exists()) {
177
+ currentPdfFile = file
178
+ withContext(Dispatchers.Main) {
179
+ renderPdf(file)
180
+ // After render, go to initialPage (or 0)
181
+ if (pdfRenderer != null) {
182
+ val target = if (initialPage >= 0 && initialPage < pdfRenderer!!.pageCount) {
183
+ initialPage
184
+ } else {
185
+ 0
186
+ }
187
+ goToPage(target)
188
+ }
189
+ }
190
+ }
191
+ } catch (e: Exception) {
192
+ e.printStackTrace()
193
+ }
194
+ }
195
+ }
196
+
197
+ fun setAnnotationTool(tool: String) {
198
+ currentTool = tool
199
+ drawingView.setTool(tool)
200
+
201
+ if (tool == "none") {
202
+ if (drawingView.hasAnnotations()) {
203
+ drawingView.visibility = View.VISIBLE
204
+ } else {
205
+ drawingView.visibility = View.GONE
206
+ }
207
+ } else {
208
+ drawingView.visibility = View.VISIBLE
209
+ drawingView.bringToFront()
210
+ }
211
+ }
212
+
213
+ fun setAnnotationColor(colorHex: String?) {
214
+ if (colorHex != null) {
215
+ try {
216
+ currentColor = Color.parseColor(colorHex)
217
+ drawingView.setColor(currentColor)
218
+ } catch (e: Exception) {
219
+ // Invalid color
220
+ }
221
+ }
222
+ }
223
+
224
+ fun setAnnotationFontSize(size: Double) {
225
+ currentFontSize = size
226
+ drawingView.setFontSize(size.toFloat())
227
+ }
228
+
229
+ fun setAnnotationText(text: String) {
230
+ currentText = text
231
+ drawingView.setTextToDraw(text)
232
+ }
233
+
234
+ fun setAnnotationStrokeWidth(width: Double) {
235
+ currentStrokeWidth = width
236
+ drawingView.setBaseStrokeWidth(width.toFloat())
237
+ }
238
+
239
+ fun getAnnotations(): List<AnnotationData> {
240
+ return drawingView.getAnnotations()
241
+ }
242
+
243
+ fun setAnnotations(annotations: List<AnnotationData>) {
244
+ drawingView.setAnnotations(annotations)
245
+ if (annotations.isNotEmpty()) {
246
+ drawingView.visibility = View.VISIBLE
247
+ }
248
+ }
249
+
250
+ fun undo(): Boolean {
251
+ return drawingView.undo()
252
+ }
253
+
254
+ fun redo(): Boolean {
255
+ return drawingView.redo()
256
+ }
257
+
258
+ fun renderThumbnail(pageIndex: Int, targetWidth: Int): String {
259
+ if (targetWidth <= 0) {
260
+ throw IllegalArgumentException("targetWidth must be > 0")
261
+ }
262
+
263
+ if (pdfRenderer == null) {
264
+ if (currentPdfFile == null || !currentPdfFile!!.exists()) {
265
+ throw IllegalStateException("PDF not loaded")
266
+ }
267
+ fileDescriptor = ParcelFileDescriptor.open(currentPdfFile, ParcelFileDescriptor.MODE_READ_ONLY)
268
+ pdfRenderer = PdfRenderer(fileDescriptor!!)
269
+ }
270
+
271
+ if (pageIndex < 0 || pageIndex >= pdfRenderer!!.pageCount) {
272
+ throw IllegalArgumentException("Invalid page index")
273
+ }
274
+
275
+ val page = pdfRenderer!!.openPage(pageIndex)
276
+ try {
277
+ val scale = targetWidth.toFloat() / page.width.toFloat()
278
+ val targetHeight = (page.height * scale).toInt().coerceAtLeast(1)
279
+
280
+ val bitmap = Bitmap.createBitmap(
281
+ targetWidth,
282
+ targetHeight,
283
+ Bitmap.Config.ARGB_8888
284
+ )
285
+
286
+ page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
287
+
288
+ val fileName = "thumb_${pageIndex}_${System.currentTimeMillis()}.png"
289
+ val file = File(context.cacheDir, fileName)
290
+ val output = FileOutputStream(file)
291
+ try {
292
+ bitmap.compress(Bitmap.CompressFormat.PNG, 90, output)
293
+ } finally {
294
+ output.close()
295
+ }
296
+
297
+ return Uri.fromFile(file).toString()
298
+ } finally {
299
+ page.close()
300
+ }
301
+ }
302
+
303
+ fun searchText(text: String): List<SearchResultData> {
304
+ android.util.Log.w("ExpoPdfReader", "Search is not supported on Android with native PdfRenderer.")
305
+ return emptyList()
306
+ }
307
+
308
+ fun goToPage(pageIndex: Int) {
309
+ if (pdfRenderer != null && pageIndex >= 0 && pageIndex < pdfRenderer!!.pageCount) {
310
+ try {
311
+ currentPage?.close()
312
+ currentPage = pdfRenderer!!.openPage(pageIndex)
313
+
314
+ val bitmap = Bitmap.createBitmap(
315
+ currentPage!!.width,
316
+ currentPage!!.height,
317
+ Bitmap.Config.ARGB_8888
318
+ )
319
+
320
+ currentPage!!.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
321
+ imageView.setImageBitmap(bitmap)
322
+
323
+ // Notify page change
324
+ onPageChange(mapOf(
325
+ "page" to pageIndex,
326
+ "total" to pdfRenderer!!.pageCount
327
+ ))
328
+
329
+ } catch (e: Exception) {
330
+ e.printStackTrace()
331
+ }
332
+ }
333
+ }
334
+
335
+ fun savePdf(): String {
336
+ if (currentPdfFile == null || !currentPdfFile!!.exists()) {
337
+ throw Exception("No PDF loaded")
338
+ }
339
+
340
+ val document = PdfDocument()
341
+ val descriptor = ParcelFileDescriptor.open(currentPdfFile, ParcelFileDescriptor.MODE_READ_ONLY)
342
+ val renderer = PdfRenderer(descriptor)
343
+
344
+ if (renderer.pageCount > 0) {
345
+ val page = renderer.openPage(0)
346
+ val pageInfo = PdfDocument.PageInfo.Builder(page.width, page.height, 1).create()
347
+ val newPage = document.startPage(pageInfo)
348
+ val canvas = newPage.canvas
349
+
350
+ val bitmap = Bitmap.createBitmap(page.width, page.height, Bitmap.Config.ARGB_8888)
351
+ page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
352
+ canvas.drawBitmap(bitmap, 0f, 0f, null)
353
+
354
+ val viewWidth = imageView.width.toFloat()
355
+ val viewHeight = imageView.height.toFloat()
356
+ val pdfWidth = page.width.toFloat()
357
+ val pdfHeight = page.height.toFloat()
358
+
359
+ val scale: Float
360
+ val dx: Float
361
+ val dy: Float
362
+
363
+ if (pdfWidth / pdfHeight > viewWidth / viewHeight) {
364
+ scale = viewWidth / pdfWidth
365
+ dx = 0f
366
+ dy = (viewHeight - pdfHeight * scale) * 0.5f
367
+ } else {
368
+ scale = viewHeight / pdfHeight
369
+ dx = (viewWidth - pdfWidth * scale) * 0.5f
370
+ dy = 0f
371
+ }
372
+
373
+ canvas.save()
374
+ canvas.scale(1/scale, 1/scale)
375
+ canvas.translate(-dx, -dy)
376
+ drawingView.drawPaths(canvas)
377
+ canvas.restore()
378
+
379
+ document.finishPage(newPage)
380
+ page.close()
381
+ }
382
+
383
+ renderer.close()
384
+ descriptor.close()
385
+
386
+ val fileName = "saved_pdf_${System.currentTimeMillis()}.pdf"
387
+ val file = File(context.cacheDir, fileName)
388
+ val outputStream = FileOutputStream(file)
389
+ document.writeTo(outputStream)
390
+ document.close()
391
+ outputStream.close()
392
+
393
+ return Uri.fromFile(file).toString()
394
+ }
395
+
396
+ private fun downloadFile(urlStr: String): File {
397
+ val url = URL(urlStr)
398
+ val connection = url.openConnection()
399
+ connection.connect()
400
+ val input = connection.getInputStream()
401
+ val file = File(context.cacheDir, "temp_${System.currentTimeMillis()}.pdf")
402
+ val output = FileOutputStream(file)
403
+ input.copyTo(output)
404
+ output.close()
405
+ input.close()
406
+ return file
407
+ }
408
+
409
+ private fun renderPdf(file: File) {
410
+ try {
411
+ fileDescriptor?.close()
412
+ fileDescriptor = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
413
+ pdfRenderer?.close()
414
+ pdfRenderer = PdfRenderer(fileDescriptor!!)
415
+
416
+ // Clear existing views
417
+ container.removeAllViews()
418
+
419
+ when (displayMode) {
420
+ "single" -> {
421
+ // Single page mode - show only current page
422
+ if (pdfRenderer!!.pageCount > 0) {
423
+ currentPage?.close()
424
+ currentPage = pdfRenderer!!.openPage(0)
425
+
426
+ val bitmap = Bitmap.createBitmap(
427
+ currentPage!!.width,
428
+ currentPage!!.height,
429
+ Bitmap.Config.ARGB_8888
430
+ )
431
+
432
+ currentPage!!.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
433
+
434
+ val singleImageView = ImageView(context)
435
+ singleImageView.layoutParams = FrameLayout.LayoutParams(
436
+ FrameLayout.LayoutParams.MATCH_PARENT,
437
+ FrameLayout.LayoutParams.WRAP_CONTENT
438
+ )
439
+ singleImageView.adjustViewBounds = true
440
+ singleImageView.scaleType = ImageView.ScaleType.FIT_START
441
+ singleImageView.setImageBitmap(bitmap)
442
+
443
+ container.addView(singleImageView)
444
+ container.addView(drawingView)
445
+ }
446
+ }
447
+ "continuous" -> {
448
+ // Continuous mode - show all pages vertically
449
+ val verticalContainer = LinearLayout(context)
450
+ verticalContainer.orientation = LinearLayout.VERTICAL
451
+ verticalContainer.layoutParams = FrameLayout.LayoutParams(
452
+ FrameLayout.LayoutParams.MATCH_PARENT,
453
+ FrameLayout.LayoutParams.WRAP_CONTENT
454
+ )
455
+
456
+ for (i in 0 until pdfRenderer!!.pageCount) {
457
+ val page = pdfRenderer!!.openPage(i)
458
+ val bitmap = Bitmap.createBitmap(
459
+ page.width,
460
+ page.height,
461
+ Bitmap.Config.ARGB_8888
462
+ )
463
+ page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
464
+
465
+ val pageImageView = ImageView(context)
466
+ pageImageView.layoutParams = LinearLayout.LayoutParams(
467
+ LinearLayout.LayoutParams.MATCH_PARENT,
468
+ LinearLayout.LayoutParams.WRAP_CONTENT
469
+ )
470
+ pageImageView.adjustViewBounds = true
471
+ pageImageView.scaleType = ImageView.ScaleType.FIT_START
472
+ pageImageView.setImageBitmap(bitmap)
473
+
474
+ verticalContainer.addView(pageImageView)
475
+ page.close()
476
+ }
477
+
478
+ container.addView(verticalContainer)
479
+ container.addView(drawingView)
480
+ }
481
+ "twoUp", "twoUpContinuous" -> {
482
+ // Two-up mode - show two pages side by side
483
+ val verticalContainer = LinearLayout(context)
484
+ verticalContainer.orientation = LinearLayout.VERTICAL
485
+ verticalContainer.layoutParams = FrameLayout.LayoutParams(
486
+ FrameLayout.LayoutParams.MATCH_PARENT,
487
+ FrameLayout.LayoutParams.WRAP_CONTENT
488
+ )
489
+
490
+ var i = 0
491
+ while (i < pdfRenderer!!.pageCount) {
492
+ val rowContainer = LinearLayout(context)
493
+ rowContainer.orientation = LinearLayout.HORIZONTAL
494
+ rowContainer.layoutParams = LinearLayout.LayoutParams(
495
+ LinearLayout.LayoutParams.MATCH_PARENT,
496
+ LinearLayout.LayoutParams.WRAP_CONTENT
497
+ )
498
+
499
+ // First page
500
+ val page1 = pdfRenderer!!.openPage(i)
501
+ val bitmap1 = Bitmap.createBitmap(
502
+ page1.width,
503
+ page1.height,
504
+ Bitmap.Config.ARGB_8888
505
+ )
506
+ page1.render(bitmap1, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
507
+
508
+ val pageImageView1 = ImageView(context)
509
+ pageImageView1.layoutParams = LinearLayout.LayoutParams(
510
+ 0,
511
+ LinearLayout.LayoutParams.WRAP_CONTENT,
512
+ 1.0f
513
+ )
514
+ pageImageView1.adjustViewBounds = true
515
+ pageImageView1.scaleType = ImageView.ScaleType.FIT_START
516
+ pageImageView1.setImageBitmap(bitmap1)
517
+ rowContainer.addView(pageImageView1)
518
+ page1.close()
519
+
520
+ // Second page (if exists)
521
+ if (i + 1 < pdfRenderer!!.pageCount) {
522
+ val page2 = pdfRenderer!!.openPage(i + 1)
523
+ val bitmap2 = Bitmap.createBitmap(
524
+ page2.width,
525
+ page2.height,
526
+ Bitmap.Config.ARGB_8888
527
+ )
528
+ page2.render(bitmap2, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
529
+
530
+ val pageImageView2 = ImageView(context)
531
+ pageImageView2.layoutParams = LinearLayout.LayoutParams(
532
+ 0,
533
+ LinearLayout.LayoutParams.WRAP_CONTENT,
534
+ 1.0f
535
+ )
536
+ pageImageView2.adjustViewBounds = true
537
+ pageImageView2.scaleType = ImageView.ScaleType.FIT_START
538
+ pageImageView2.setImageBitmap(bitmap2)
539
+ rowContainer.addView(pageImageView2)
540
+ page2.close()
541
+ }
542
+
543
+ verticalContainer.addView(rowContainer)
544
+ i += 2
545
+ }
546
+
547
+ container.addView(verticalContainer)
548
+ container.addView(drawingView)
549
+ }
550
+ else -> {
551
+ // Default to continuous
552
+ displayMode = "continuous"
553
+ renderPdf(file)
554
+ return
555
+ }
556
+ }
557
+
558
+ // Re-apply zoom after layout rebuild
559
+ applyZoom(currentZoom)
560
+
561
+ // Set current page for single mode
562
+ if (displayMode == "single" && pdfRenderer!!.pageCount > 0) {
563
+ currentPage?.close()
564
+ currentPage = pdfRenderer!!.openPage(0)
565
+ }
566
+
567
+ } catch (e: Exception) {
568
+ e.printStackTrace()
569
+ }
570
+ }
571
+
572
+ override fun onDetachedFromWindow() {
573
+ super.onDetachedFromWindow()
574
+ currentPage?.close()
575
+ pdfRenderer?.close()
576
+ fileDescriptor?.close()
577
+ scope.cancel()
578
+ }
579
+
580
+ // Inner class for handling drawing
581
+ inner class DrawingView(context: Context) : View(context) {
582
+ private var drawPath = Path()
583
+ private var drawPaint = Paint()
584
+ private var textPaint = Paint()
585
+
586
+ // Callback
587
+ var onDrawingChanged: (() -> Unit)? = null
588
+
589
+ // Store raw data for serialization
590
+ data class AnnotationItem(
591
+ val path: Path?,
592
+ val paint: Paint,
593
+ val type: String,
594
+ val color: Int,
595
+ val points: MutableList<PointF>,
596
+ val text: String = "",
597
+ val fontSize: Float = 16f,
598
+ val x: Float = 0f,
599
+ val y: Float = 0f
600
+ )
601
+
602
+ private val items = ArrayList<AnnotationItem>()
603
+ private val undoStack = ArrayList<AnnotationItem>() // History for undo
604
+ private val redoStack = ArrayList<AnnotationItem>() // History for redo
605
+ private var currentPoints = ArrayList<PointF>()
606
+ private var currentTool = "none"
607
+ private var currentTextToDraw = "Text"
608
+ private var currentFontSize = 16f
609
+ private var baseStrokeWidth = 10f
610
+
611
+ init {
612
+ setupPaint()
613
+ }
614
+
615
+ fun hasAnnotations(): Boolean {
616
+ return items.isNotEmpty()
617
+ }
618
+
619
+ private fun setupPaint() {
620
+ drawPaint.color = currentColor
621
+ drawPaint.isAntiAlias = true
622
+ drawPaint.strokeWidth = baseStrokeWidth
623
+ drawPaint.style = Paint.Style.STROKE
624
+ drawPaint.strokeJoin = Paint.Join.ROUND
625
+ drawPaint.strokeCap = Paint.Cap.ROUND
626
+
627
+ textPaint.color = currentColor
628
+ textPaint.isAntiAlias = true
629
+ textPaint.textSize = currentFontSize
630
+ textPaint.style = Paint.Style.FILL
631
+ }
632
+
633
+ fun setTool(tool: String) {
634
+ currentTool = tool
635
+ if (tool == "highlighter") {
636
+ drawPaint.alpha = 80
637
+ drawPaint.strokeWidth = baseStrokeWidth * 3f
638
+ } else if (tool == "eraser") {
639
+ // Eraser doesn't draw, it erases
640
+ } else {
641
+ drawPaint.alpha = 255
642
+ drawPaint.strokeWidth = baseStrokeWidth
643
+ }
644
+ }
645
+
646
+ fun setColor(color: Int) {
647
+ drawPaint.color = color
648
+ textPaint.color = color
649
+ if (currentTool == "highlighter") {
650
+ drawPaint.alpha = 80
651
+ }
652
+ }
653
+
654
+ fun setFontSize(size: Float) {
655
+ currentFontSize = size
656
+ textPaint.textSize = size
657
+ }
658
+
659
+ fun setBaseStrokeWidth(width: Float) {
660
+ baseStrokeWidth = if (width > 0f) width else 1f
661
+ // Apply to current tool
662
+ if (currentTool == "highlighter") {
663
+ drawPaint.strokeWidth = baseStrokeWidth * 3f
664
+ } else {
665
+ drawPaint.strokeWidth = baseStrokeWidth
666
+ }
667
+ }
668
+
669
+ fun setTextToDraw(text: String) {
670
+ currentTextToDraw = text
671
+ }
672
+
673
+ fun drawPaths(canvas: Canvas) {
674
+ for (item in items) {
675
+ if (item.type == "text") {
676
+ canvas.drawText(item.text, item.x, item.y, item.paint)
677
+ } else if (item.path != null) {
678
+ canvas.drawPath(item.path, item.paint)
679
+ }
680
+ }
681
+ }
682
+
683
+ fun getAnnotations(): List<AnnotationData> {
684
+ return items.map { item ->
685
+ val annotation = AnnotationData()
686
+ annotation.type = item.type
687
+ annotation.color = String.format("#%06X", (0xFFFFFF and item.color))
688
+
689
+ if (item.type == "text") {
690
+ annotation.text = item.text
691
+ annotation.fontSize = item.fontSize.toDouble()
692
+ annotation.x = item.x.toDouble()
693
+ annotation.y = item.y.toDouble()
694
+ } else {
695
+ annotation.width = if (item.type == "highlighter") (baseStrokeWidth * 3f).toDouble() else baseStrokeWidth.toDouble()
696
+ val pointsList = ArrayList<Map<String, Double>>()
697
+ for (pt in item.points) {
698
+ pointsList.add(mapOf("x" to pt.x.toDouble(), "y" to pt.y.toDouble()))
699
+ }
700
+ annotation.points = listOf(pointsList)
701
+ }
702
+ annotation
703
+ }
704
+ }
705
+
706
+ fun undo(): Boolean {
707
+ if (items.isEmpty()) return false
708
+
709
+ val lastItem = items.removeAt(items.size - 1)
710
+ undoStack.add(lastItem)
711
+ invalidate()
712
+ onDrawingChanged?.invoke()
713
+ return true
714
+ }
715
+
716
+ fun redo(): Boolean {
717
+ if (undoStack.isEmpty()) return false
718
+
719
+ val item = undoStack.removeAt(undoStack.size - 1)
720
+ items.add(item)
721
+ invalidate()
722
+ onDrawingChanged?.invoke()
723
+ return true
724
+ }
725
+
726
+ fun setAnnotations(annotations: List<AnnotationData>) {
727
+ items.clear()
728
+ undoStack.clear()
729
+ redoStack.clear()
730
+ for (ann in annotations) {
731
+ if (ann.type == "text") {
732
+ val paint = Paint(textPaint)
733
+ try {
734
+ paint.color = Color.parseColor(ann.color)
735
+ } catch (e: Exception) {
736
+ paint.color = Color.BLACK
737
+ }
738
+ paint.textSize = ann.fontSize.toFloat()
739
+
740
+ items.add(AnnotationItem(
741
+ null,
742
+ paint,
743
+ "text",
744
+ paint.color,
745
+ ArrayList(),
746
+ ann.text,
747
+ ann.fontSize.toFloat(),
748
+ ann.x.toFloat(),
749
+ ann.y.toFloat()
750
+ ))
751
+ } else {
752
+ val path = Path()
753
+ val points = ArrayList<PointF>()
754
+
755
+ if (ann.points.isNotEmpty()) {
756
+ val stroke = ann.points[0]
757
+ if (stroke.isNotEmpty()) {
758
+ val start = stroke[0]
759
+ path.moveTo(start["x"]!!.toFloat(), start["y"]!!.toFloat())
760
+ points.add(PointF(start["x"]!!.toFloat(), start["y"]!!.toFloat()))
761
+
762
+ for (i in 1 until stroke.size) {
763
+ val pt = stroke[i]
764
+ path.lineTo(pt["x"]!!.toFloat(), pt["y"]!!.toFloat())
765
+ points.add(PointF(pt["x"]!!.toFloat(), pt["y"]!!.toFloat()))
766
+ }
767
+ }
768
+ }
769
+
770
+ val paint = Paint()
771
+ paint.isAntiAlias = true
772
+ paint.style = Paint.Style.STROKE
773
+ paint.strokeJoin = Paint.Join.ROUND
774
+ paint.strokeCap = Paint.Cap.ROUND
775
+
776
+ try {
777
+ paint.color = Color.parseColor(ann.color)
778
+ } catch (e: Exception) {
779
+ paint.color = Color.BLACK
780
+ }
781
+
782
+ if (ann.type == "highlighter") {
783
+ paint.alpha = 80
784
+ paint.strokeWidth = 30f
785
+ } else {
786
+ paint.alpha = 255
787
+ paint.strokeWidth = 10f
788
+ }
789
+
790
+ val colorInt = paint.color
791
+ items.add(AnnotationItem(path, paint, ann.type, colorInt, points))
792
+ }
793
+ }
794
+ invalidate()
795
+ }
796
+
797
+ override fun onDraw(canvas: Canvas) {
798
+ super.onDraw(canvas)
799
+ for (item in items) {
800
+ if (item.type == "text") {
801
+ canvas.drawText(item.text, item.x, item.y, item.paint)
802
+ } else if (item.path != null) {
803
+ canvas.drawPath(item.path, item.paint)
804
+ }
805
+ }
806
+ if (currentTool != "none" && currentTool != "text") {
807
+ canvas.drawPath(drawPath, drawPaint)
808
+ }
809
+ }
810
+
811
+ private fun findAnnotationAt(x: Float, y: Float, tolerance: Float = 20f): AnnotationItem? {
812
+ // Check items in reverse order (most recent first)
813
+ for (i in items.size - 1 downTo 0) {
814
+ val item = items[i]
815
+
816
+ if (item.type == "text") {
817
+ // Check if point is near text
818
+ val textBounds = Rect()
819
+ item.paint.getTextBounds(item.text, 0, item.text.length, textBounds)
820
+ val textWidth = item.paint.measureText(item.text)
821
+ val textHeight = textBounds.height().toFloat()
822
+
823
+ if (x >= item.x - tolerance && x <= item.x + textWidth + tolerance &&
824
+ y >= item.y - textHeight - tolerance && y <= item.y + tolerance) {
825
+ return item
826
+ }
827
+ } else if (item.path != null) {
828
+ // Check if point is on or near the path
829
+ val region = Region()
830
+ val rect = RectF()
831
+ item.path.computeBounds(rect, true)
832
+ rect.inset(-tolerance, -tolerance)
833
+
834
+ region.setPath(item.path, Region(rect.left.toInt(), rect.top.toInt(), rect.right.toInt(), rect.bottom.toInt()))
835
+ if (region.contains(x.toInt(), y.toInt())) {
836
+ return item
837
+ }
838
+
839
+ // Also check if point is near any point in the path
840
+ for (pt in item.points) {
841
+ val dx = pt.x - x
842
+ val dy = pt.y - y
843
+ if (dx * dx + dy * dy <= tolerance * tolerance) {
844
+ return item
845
+ }
846
+ }
847
+ }
848
+ }
849
+ return null
850
+ }
851
+
852
+ override fun onTouchEvent(event: MotionEvent): Boolean {
853
+ if (currentTool == "none") return false
854
+
855
+ val touchX = event.x
856
+ val touchY = event.y
857
+
858
+ if (currentTool == "eraser") {
859
+ when (event.action) {
860
+ MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
861
+ val item = findAnnotationAt(touchX, touchY)
862
+ if (item != null) {
863
+ items.remove(item)
864
+ undoStack.add(item)
865
+ redoStack.clear()
866
+ invalidate()
867
+ onDrawingChanged?.invoke()
868
+ }
869
+ }
870
+ }
871
+ return true
872
+ }
873
+
874
+ if (currentTool == "text") {
875
+ if (event.action == MotionEvent.ACTION_UP) {
876
+ val paint = Paint(textPaint)
877
+ val newItem = AnnotationItem(
878
+ null,
879
+ paint,
880
+ "text",
881
+ paint.color,
882
+ ArrayList(),
883
+ currentTextToDraw,
884
+ currentFontSize,
885
+ touchX,
886
+ touchY
887
+ )
888
+ items.add(newItem)
889
+
890
+ // Clear redo stack when new action is performed
891
+ redoStack.clear()
892
+
893
+ invalidate()
894
+ onDrawingChanged?.invoke()
895
+ }
896
+ return true
897
+ }
898
+
899
+ when (event.action) {
900
+ MotionEvent.ACTION_DOWN -> {
901
+ drawPath.moveTo(touchX, touchY)
902
+ currentPoints.clear()
903
+ currentPoints.add(PointF(touchX, touchY))
904
+ }
905
+ MotionEvent.ACTION_MOVE -> {
906
+ drawPath.lineTo(touchX, touchY)
907
+ currentPoints.add(PointF(touchX, touchY))
908
+ }
909
+ MotionEvent.ACTION_UP -> {
910
+ drawPath.lineTo(touchX, touchY)
911
+ currentPoints.add(PointF(touchX, touchY))
912
+
913
+ val newPaint = Paint(drawPaint)
914
+ val newPath = Path(drawPath)
915
+ val newPoints = ArrayList(currentPoints)
916
+
917
+ val newItem = AnnotationItem(newPath, newPaint, currentTool, drawPaint.color, newPoints)
918
+ items.add(newItem)
919
+
920
+ // Clear redo stack when new action is performed
921
+ redoStack.clear()
922
+
923
+ drawPath.reset()
924
+
925
+ // Trigger callback
926
+ onDrawingChanged?.invoke()
927
+ }
928
+ else -> return false
929
+ }
930
+ invalidate()
931
+ return true
932
+ }
933
+ }
934
+ }