@june24/expo-pdf-reader 0.1.26 → 0.1.27

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.
@@ -1,1765 +1,1881 @@
1
- package expo.modules.pdfreader
2
-
3
- import android.content.Context
4
- import android.graphics.*
5
- import android.graphics.pdf.PdfRenderer
6
- import android.os.ParcelFileDescriptor
7
- import android.text.Layout
8
- import android.text.StaticLayout
9
- import android.text.TextPaint
10
- import android.util.Log
11
- import android.view.*
12
- import android.widget.*
13
- import androidx.recyclerview.widget.LinearLayoutManager
14
- import androidx.recyclerview.widget.PagerSnapHelper
15
- import androidx.recyclerview.widget.RecyclerView
16
- import expo.modules.kotlin.AppContext
17
- import expo.modules.kotlin.viewevent.EventDispatcher
18
- import expo.modules.kotlin.views.ExpoView
19
- import kotlinx.coroutines.*
20
- import java.io.File
21
- import java.io.FileOutputStream
22
- import java.net.URL
23
-
24
- // ─────────────────────────────────────────────────────────────────────────────
25
- // Data classes
26
- // ─────────────────────────────────────────────────────────────────────────────
27
-
28
- data class AnnPath(val points: MutableList<PointF> = mutableListOf())
29
-
30
- /**
31
- * Bounds stored in PDF page coordinate space:
32
- * origin = bottom-left of the page, y-axis points UP.
33
- *
34
- * RectF.left = left edge (PDF x)
35
- * RectF.top = lower y in PDF space (screen-bottom side, smaller PDF-y value)
36
- * RectF.right = right edge (PDF x)
37
- * RectF.bottom = upper y in PDF space (screen-top side, larger PDF-y value)
38
- *
39
- * Matches iOS PDFKit: CGRect(x, y=lowerPdfY, width, height).
40
- */
41
- data class PdfAnnotation(
42
- val type: String, // "pen" | "highlighter" | "line" | "text" | "note"
43
- val pageIndex: Int,
44
- var bounds: RectF,
45
- val paths: MutableList<AnnPath> = mutableListOf(),
46
- var color: String = "#FF0000",
47
- var strokeWidth: Float = 2f,
48
- var contents: String = "",
49
- var fontSize: Float = 14f,
50
- var bold: Boolean = false,
51
- var italic: Boolean = false
52
- )
53
-
54
- data class UndoEntry(val annotation: PdfAnnotation)
55
-
56
- // ─────────────────────────────────────────────────────────────────────────────
57
- // Main view
58
- // ─────────────────────────────────────────────────────────────────────────────
59
-
60
- class ExpoPdfReaderView(
61
- context: Context,
62
- appContext: AppContext
63
- ) : ExpoView(context, appContext) {
64
-
65
- // ── Expo Events (ViewEventDelegate — Expo Modules-ын зөв механизм) ────────
66
- val onReady by EventDispatcher()
67
- val onPageChange by EventDispatcher()
68
- val onAnnotationChange by EventDispatcher()
69
- val onUndoRedoStateChange by EventDispatcher()
70
- val onNotePress by EventDispatcher()
71
- val onTextPress by EventDispatcher()
72
-
73
- // ── Coroutines ───────────────────────────────────────────────────────────
74
- private val pdfDispatcher = Dispatchers.IO.limitedParallelism(1)
75
- private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
76
- private var renderJob: Job? = null
77
-
78
- // ── PDF state ────────────────────────────────────────────────────────────
79
- private var fileDescriptor: ParcelFileDescriptor? = null
80
- private var renderer: PdfRenderer? = null
81
- private var pagePdfW = intArrayOf() // PDF points per page
82
- private var pagePdfH = intArrayOf()
83
- var totalPages = 0
84
- private set
85
-
86
- // ── Display state ────────────────────────────────────────────────────────
87
- private var displayMode = "continuous"
88
- private var pendingUrl: String? = null
89
- private var isLayoutReady = false
90
- var currentPageIndex = 0
91
- private set
92
-
93
- // ── Lazy / chunked rendering ──────────────────────────────────────────────
94
- private var renderedUpTo = -1 // last rendered page index (absolute)
95
- private var windowStart = 0 // pageEntries[0]-н absolute page index
96
- private var chunkLoading = false // chunk дуусах хүртэл дахин trigger хийхгүй
97
- private var currentViewWidth = 0 // chunk load-д дахин хэрэглэнэ
98
- private var loadingFooter: View? = null
99
-
100
- companion object {
101
- private const val PAGE_CHUNK = 10 // нэг удаа render хийх хуудасны тоо
102
- private const val MAX_RENDERED = PAGE_CHUNK * 2 // санах ойд байлгах дээд хязгаар (20 хуудас)
103
- }
104
-
105
- // ── Annotation state ─────────────────────────────────────────────────────
106
- val annotationMap = mutableMapOf<Int, MutableList<PdfAnnotation>>()
107
- var activeTool: String? = null
108
- var strokeColorVal = "#FF0000"
109
- var strokeWidthVal = 2f
110
- var textContentVal = ""
111
- var textColorVal = "#000000"
112
- var textFontSizeVal = 14f
113
- var textBoldVal = false
114
- var textItalicVal = false
115
- var noteColorVal = "#FFFF00"
116
-
117
- val undoStack = ArrayDeque<UndoEntry>()
118
- val redoStack = ArrayDeque<UndoEntry>()
119
- private var appliedFingerprint: String? = null
120
-
121
- // ── Page view entries (continuous/twoUp modes) ───────────────────────────
122
- // frame: container-аас устгахад шууд reference, bmpHeight: layout болохоос өмнө scroll тооцооны тулд
123
- data class PageEntry(val frame: FrameLayout, val canvasView: AnnotationCanvasView, val bmpHeight: Int)
124
- val pageEntries = mutableListOf<PageEntry>()
125
-
126
- // ── Single-page pager (single mode) ──────────────────────────────────────
127
- private lateinit var pager: RecyclerView
128
- private var singleAdapter: SinglePageAdapter? = null
129
- private val singlePageCanvases = mutableMapOf<Int, AnnotationCanvasView>()
130
-
131
- // ── Zoom ─────────────────────────────────────────────────────────────────
132
- private var minZoom = 1.0f
133
- private var maxZoom = 5.0f
134
- private var currentZoom = 1.0f
135
- private val scaleDetector = ScaleGestureDetector(
136
- context,
137
- object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
138
- override fun onScale(d: ScaleGestureDetector): Boolean {
139
- if (activeTool != null) return true
140
- applyZoom((currentZoom * d.scaleFactor).coerceIn(minZoom, maxZoom))
141
- return true
142
- }
143
- }
144
- )
145
-
146
- // ── UI ───────────────────────────────────────────────────────────────────
147
- val scrollView = ScrollView(context).apply {
148
- layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
149
- isFillViewport = true
150
- }
151
- private val container = LinearLayout(context).apply {
152
- orientation = LinearLayout.VERTICAL
153
- layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
154
- setBackgroundColor(Color.parseColor("#E8E8E8"))
155
- }
156
-
157
- init {
158
- setBackgroundColor(Color.parseColor("#E8E8E8"))
159
- scrollView.addView(container)
160
- addView(scrollView)
161
- scrollView.setOnScrollChangeListener { _, _, scrollY, _, _ ->
162
- detectCurrentPage(scrollY)
163
- maybeTriggerChunkLoad(scrollY)
164
- }
165
-
166
- // Single-page pager: ZoomRecyclerView + PagerSnapHelper (vertical)
167
- pager = ZoomRecyclerView(context).apply {
168
- layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
169
- layoutManager = LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
170
- PagerSnapHelper().attachToRecyclerView(this)
171
- setBackgroundColor(Color.parseColor("#E8E8E8"))
172
- visibility = View.GONE
173
- addOnScrollListener(object : RecyclerView.OnScrollListener() {
174
- override fun onScrollStateChanged(rv: RecyclerView, newState: Int) {
175
- if (newState == RecyclerView.SCROLL_STATE_IDLE) {
176
- val lm = rv.layoutManager as? LinearLayoutManager ?: return
177
- val pos = lm.findFirstCompletelyVisibleItemPosition()
178
- .takeIf { it >= 0 } ?: return
179
- if (pos != currentPageIndex) {
180
- currentPageIndex = pos
181
- // Хуудас солигдоход zoom-г reset хийнэ
182
- (pager as? ZoomRecyclerView)?.resetZoom()
183
- onPageChange(mapOf("currentPage" to pos, "totalPage" to totalPages))
184
- }
185
- }
186
- }
187
- })
188
- }
189
- addView(pager)
190
- }
191
-
192
- override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
193
- super.onLayout(changed, l, t, r, b)
194
- if (width > 0 && !isLayoutReady) {
195
- isLayoutReady = true
196
- pendingUrl?.let {
197
- pendingUrl = null
198
- startLoad(it)
199
- }
200
- }
201
- // Note: width өөрчлөгдөхөд triggerRender дуудахгүй —
202
- // forced layout (forceLayoutScrollView) нь pages-г шууд харагдуулдаг
203
- }
204
-
205
- override fun onDetachedFromWindow() {
206
- super.onDetachedFromWindow()
207
- renderJob?.cancel()
208
- scope.cancel()
209
- safeCloseRenderer()
210
- }
211
-
212
- override fun onTouchEvent(event: MotionEvent): Boolean {
213
- scaleDetector.onTouchEvent(event)
214
- return super.onTouchEvent(event)
215
- }
216
-
217
- // ─────────────────────────────────────────────────────────────────────────
218
- // Props
219
- // ─────────────────────────────────────────────────────────────────────────
220
-
221
- fun setUrl(url: String?) {
222
- if (url.isNullOrBlank()) return
223
- pendingUrl = url
224
- if (isLayoutReady) {
225
- startLoad(url)
226
- } else {
227
- // Layout ирээгүй байж болно — дараагийн frame-д шалгана
228
- post { tryStartLoad() }
229
- }
230
- }
231
-
232
- private fun tryStartLoad() {
233
- val url = pendingUrl ?: return // onLayout аль хэдийн startLoad дуудсан бол pendingUrl = null → exit
234
- if (width > 0) {
235
- isLayoutReady = true
236
- pendingUrl = null // давхар дуудлагаас сэргийлнэ
237
- startLoad(url)
238
- } else {
239
- scope.launch {
240
- delay(50)
241
- tryStartLoad()
242
- }
243
- }
244
- }
245
-
246
- fun setDisplayMode(mode: String?) {
247
- val newMode = mode?.lowercase() ?: "continuous"
248
- if (newMode == displayMode) return
249
- displayMode = newMode
250
- if (renderer != null && isLayoutReady) triggerRender()
251
- }
252
-
253
- fun setInitialPage(page: Int) {
254
- if (page < 0 || page >= totalPages) return
255
- currentPageIndex = page
256
- if (displayMode == "single") {
257
- post { scrollToPage(page, smooth = false) }
258
- return
259
- }
260
- val windowEnd = windowStart + pageEntries.size - 1
261
- if (page in windowStart..windowEnd) {
262
- // Хуудас render хийгдсэн window дотор байна зүгээр scroll хийнэ
263
- post { scrollToPage(page, smooth = false) }
264
- } else {
265
- // Хуудас render хийгдээгүй → window-г тухайн хуудас руу шилжүүлнэ
266
- jumpToPage(page)
267
- }
268
- }
269
-
270
- fun setMinZoom(v: Double) { minZoom = v.toFloat() }
271
- fun setMaxZoom(v: Double) { maxZoom = v.toFloat() }
272
-
273
- fun setTool(tool: String?) {
274
- activeTool = tool
275
- if (displayMode == "single") {
276
- pager.requestDisallowInterceptTouchEvent(tool != null)
277
- } else {
278
- scrollView.requestDisallowInterceptTouchEvent(tool != null)
279
- }
280
- }
281
-
282
- fun setStrokeColor(hex: String) { strokeColorVal = hex }
283
- fun setStrokeWidth(w: Double) { strokeWidthVal = w.toFloat() }
284
- fun setTextContent(t: String) { textContentVal = t }
285
- fun setTextColor(hex: String) { textColorVal = hex }
286
- fun setTextFontSize(size: Double) { textFontSizeVal = size.toFloat() }
287
- fun setTextBold(v: Boolean) { textBoldVal = v }
288
- fun setTextItalic(v: Boolean) { textItalicVal = v }
289
- fun setNoteColor(hex: String) { noteColorVal = hex }
290
-
291
- // ─────────────────────────────────────────────────────────────────────────
292
- // Commands
293
- // ─────────────────────────────────────────────────────────────────────────
294
-
295
- fun undo() {
296
- val e = undoStack.removeLastOrNull() ?: return
297
- redoStack.addLast(e)
298
- annotationMap[e.annotation.pageIndex]?.remove(e.annotation)
299
- invalidateCanvas(e.annotation.pageIndex)
300
- notifyAnnotationChange()
301
- notifyUndoRedoState()
302
- }
303
-
304
- fun redo() {
305
- val e = redoStack.removeLastOrNull() ?: return
306
- undoStack.addLast(e)
307
- annotationMap.getOrPut(e.annotation.pageIndex) { mutableListOf() }.add(e.annotation)
308
- invalidateCanvas(e.annotation.pageIndex)
309
- notifyAnnotationChange()
310
- notifyUndoRedoState()
311
- }
312
-
313
- fun setAnnotations(data: List<Map<String, Any?>>) {
314
- val fp = buildFingerprint(data)
315
- if (fp == appliedFingerprint) return
316
- appliedFingerprint = fp
317
- annotationMap.clear()
318
-
319
- for (item in data) {
320
- val pageIndex = (item["page"] as? Number)?.toInt() ?: continue
321
- val typeStr = item["type"] as? String ?: continue
322
- val b = item["bounds"] as? Map<*, *> ?: continue
323
- val bX = (b["x"] as? Number)?.toFloat() ?: 0f
324
- val bY = (b["y"] as? Number)?.toFloat() ?: 0f
325
- val bW = (b["width"] as? Number)?.toFloat() ?: 0f
326
- val bH = (b["height"] as? Number)?.toFloat() ?: 0f
327
-
328
- val bounds = RectF(bX, bY, bX + bW, bY + bH)
329
- val ann = PdfAnnotation(
330
- type = typeStr, pageIndex = pageIndex, bounds = bounds,
331
- color = item["color"] as? String ?: strokeColorVal,
332
- strokeWidth = (item["strokeWidth"] as? Number)?.toFloat() ?: strokeWidthVal,
333
- contents = item["contents"] as? String ?: "",
334
- fontSize = (item["fontSize"] as? Number)?.toFloat() ?: textFontSizeVal,
335
- bold = item["bold"] as? Boolean ?: false,
336
- italic = item["italic"] as? Boolean ?: false
337
- )
338
-
339
- (item["paths"] as? List<*>)?.forEach { pathData ->
340
- val pts = (pathData as? List<*>)?.mapNotNull { pt ->
341
- val m = pt as? Map<*, *> ?: return@mapNotNull null
342
- PointF(
343
- (m["x"] as? Number)?.toFloat() ?: 0f,
344
- (m["y"] as? Number)?.toFloat() ?: 0f
345
- )
346
- }
347
- if (!pts.isNullOrEmpty()) ann.paths.add(AnnPath(pts.toMutableList()))
348
- }
349
-
350
- annotationMap.getOrPut(pageIndex) { mutableListOf() }.add(ann)
351
- }
352
- if (displayMode == "single") {
353
- singlePageCanvases.values.forEach { it.invalidate() }
354
- } else {
355
- pageEntries.forEach { it.canvasView.invalidate() }
356
- }
357
- }
358
-
359
- fun updateText(pageIndex: Int, index: Int, contents: String) {
360
- val anns = annotationMap[pageIndex]?.filter { it.type == "text" } ?: return
361
- if (index >= anns.size) return
362
- val ann = anns[index]
363
- ann.contents = contents.ifBlank { " " }
364
- autoSizeTextAnnotation(ann)
365
- invalidateCanvas(pageIndex)
366
- notifyAnnotationChange()
367
- }
368
-
369
- /**
370
- * Recalculate the annotation's bounds so the text fits without clipping.
371
- * Width is capped at 70% of the view width; height grows with line count.
372
- */
373
- private fun autoSizeTextAnnotation(ann: PdfAnnotation) {
374
- val text = ann.contents.trim().ifEmpty { return }
375
- val pageW = pagePdfW.getOrElse(ann.pageIndex) { 0 }
376
- if (pageW <= 0 || width <= 0) return
377
- val s = width.toFloat() / pageW.toFloat()
378
- val tp = android.text.TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
379
- textSize = ann.fontSize * s
380
- typeface = when {
381
- ann.bold && ann.italic -> Typeface.create(Typeface.DEFAULT, Typeface.BOLD_ITALIC)
382
- ann.bold -> Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
383
- ann.italic -> Typeface.create(Typeface.DEFAULT, Typeface.ITALIC)
384
- else -> Typeface.DEFAULT
385
- }
386
- }
387
- val pad = 16f // screen px total padding (8px each side)
388
- val maxW = (width * 0.7f - pad).toInt().coerceAtLeast(80)
389
- val layout = StaticLayout.Builder
390
- .obtain(text, 0, text.length, tp, maxW)
391
- .setAlignment(android.text.Layout.Alignment.ALIGN_NORMAL)
392
- .build()
393
- val newW = (layout.width + pad) / s
394
- val newH = (layout.height + pad) / s
395
- // Keep top-left anchor fixed
396
- ann.bounds = RectF(ann.bounds.left, ann.bounds.top, ann.bounds.left + newW, ann.bounds.top + newH)
397
- }
398
-
399
- fun updateNote(pageIndex: Int, index: Int, contents: String) {
400
- val anns = annotationMap[pageIndex]?.filter { it.type == "note" } ?: return
401
- if (index >= anns.size) return
402
- anns[index].contents = contents.ifBlank { " " }
403
- invalidateCanvas(pageIndex)
404
- notifyAnnotationChange()
405
- }
406
-
407
- // ─────────────────────────────────────────────────────────────────────────
408
- // Internal helpers (used by inner AnnotationCanvasView)
409
- // ─────────────────────────────────────────────────────────────────────────
410
-
411
- fun addAnnotationAndCommit(ann: PdfAnnotation) {
412
- annotationMap.getOrPut(ann.pageIndex) { mutableListOf() }.add(ann)
413
- undoStack.addLast(UndoEntry(ann))
414
- redoStack.clear()
415
- invalidateCanvas(ann.pageIndex)
416
- notifyAnnotationChange()
417
- notifyUndoRedoState()
418
- }
419
-
420
- fun invalidateCanvas(pageIndex: Int) {
421
- if (displayMode == "single") {
422
- singlePageCanvases[pageIndex]?.invalidate()
423
- } else {
424
- // continuous mode: pageEntries[0] = page windowStart relative index
425
- pageEntries.getOrNull(pageIndex - windowStart)?.canvasView?.invalidate()
426
- }
427
- }
428
-
429
- fun notifyAnnotationChange() {
430
- val all = mutableListOf<Map<String, Any>>()
431
- for ((pageIndex, anns) in annotationMap) {
432
- for (ann in anns) {
433
- val m = mutableMapOf<String, Any>(
434
- "type" to ann.type,
435
- "page" to pageIndex,
436
- "bounds" to mapOf(
437
- "x" to ann.bounds.left.toDouble(),
438
- "y" to ann.bounds.top.toDouble(),
439
- "width" to ann.bounds.width().toDouble(),
440
- "height" to ann.bounds.height().toDouble()
441
- ),
442
- "color" to ann.color,
443
- "strokeWidth" to ann.strokeWidth.toDouble(),
444
- "contents" to ann.contents
445
- )
446
- if (ann.type == "text") {
447
- m["fontSize"] = ann.fontSize.toDouble()
448
- m["bold"] = ann.bold
449
- m["italic"] = ann.italic
450
- }
451
- if (ann.paths.isNotEmpty()) {
452
- m["paths"] = ann.paths.map { p ->
453
- p.points.map { pt ->
454
- mapOf("x" to pt.x.toDouble(), "y" to pt.y.toDouble())
455
- }
456
- }
457
- }
458
- all.add(m)
459
- }
460
- }
461
- onAnnotationChange(mapOf("annotations" to all))
462
- }
463
-
464
- fun notifyUndoRedoState() {
465
- onUndoRedoStateChange(
466
- mapOf("canUndo" to undoStack.isNotEmpty(), "canRedo" to redoStack.isNotEmpty())
467
- )
468
- }
469
-
470
- // ─────────────────────────────────────────────────────────────────────────
471
- // Zoom
472
- // ─────────────────────────────────────────────────────────────────────────
473
-
474
- private fun applyZoom(newZoom: Float) {
475
- currentZoom = newZoom
476
- container.pivotX = 0f
477
- container.pivotY = 0f
478
- container.scaleX = newZoom
479
- container.scaleY = newZoom
480
- }
481
-
482
- // ─────────────────────────────────────────────────────────────────────────
483
- // Scroll / page detection
484
- // ─────────────────────────────────────────────────────────────────────────
485
-
486
- /** twoupcontinuous: нэг мөр = хос entry (хуудас дараалсан), эсвэл эцсийн ганц entry. */
487
- private fun twoupStrideAt(entryIdx: Int): Int {
488
- if (displayMode != "twoupcontinuous") return 1
489
- if (entryIdx >= pageEntries.size) return 0
490
- if (entryIdx + 1 < pageEntries.size) {
491
- val a = pageEntries[entryIdx].canvasView.pageIndex
492
- val b = pageEntries[entryIdx + 1].canvasView.pageIndex
493
- if (b == a + 1) return 2
494
- }
495
- return 1
496
- }
497
-
498
- /** scrollView доторх pageIndex хуудасны мөрийн дээд ирмэгийн Y (continuous / twoupcontinuous). */
499
- private fun scrollYForScrollModePage(pageIndex: Int): Int {
500
- if (pageEntries.isEmpty()) return 0
501
- val lastAbsPage = windowStart + pageEntries.size - 1
502
- val clamped = pageIndex.coerceIn(windowStart, lastAbsPage.coerceAtLeast(windowStart))
503
- if (displayMode != "twoupcontinuous") {
504
- val rel = (clamped - windowStart).coerceIn(0, maxOf(0, pageEntries.size - 1))
505
- var y = 0
506
- for (i in 0 until rel) {
507
- y += (pageEntries[i].bmpHeight.takeIf { it > 0 } ?: 0) + 12
508
- }
509
- return y
510
- }
511
- var y = 0
512
- var idx = 0
513
- while (idx < pageEntries.size) {
514
- val stride = twoupStrideAt(idx)
515
- val rowH = pageEntries[idx].bmpHeight.takeIf { it > 0 } ?: 0
516
- val p0 = pageEntries[idx].canvasView.pageIndex
517
- val p1 = if (stride == 2) pageEntries[idx + 1].canvasView.pageIndex else p0
518
- if (clamped in p0..p1) return y
519
- y += rowH + 12
520
- idx += stride
521
- }
522
- return y
523
- }
524
-
525
- private fun detectCurrentPage(scrollY: Int) {
526
- if (pageEntries.isEmpty()) return
527
- val viewH = scrollView.height.takeIf { it > 0 } ?: height
528
- val centerY = scrollY + viewH / 2
529
- if (displayMode == "twoupcontinuous") {
530
- var accY = 0
531
- var idx = 0
532
- while (idx < pageEntries.size) {
533
- val stride = twoupStrideAt(idx)
534
- val rowH = pageEntries[idx].bmpHeight.takeIf { it > 0 } ?: 0
535
- val nextY = accY + rowH + 12
536
- val p0 = pageEntries[idx].canvasView.pageIndex
537
- val p1 = if (stride == 2) pageEntries[idx + 1].canvasView.pageIndex else p0
538
- if (centerY <= nextY || idx + stride >= pageEntries.size) {
539
- val vx = scrollView.width.takeIf { it > 0 } ?: width
540
- val absPage = if (stride == 2 && p1 > p0 && vx > 0) {
541
- val midPix = vx / 2
542
- val centerX = scrollView.scrollX + midPix
543
- if (centerX < midPix) p0 else p1
544
- } else p0
545
- if (currentPageIndex != absPage) {
546
- currentPageIndex = absPage
547
- onPageChange(mapOf("currentPage" to absPage, "totalPage" to totalPages))
548
- }
549
- break
550
- }
551
- accY = nextY
552
- idx += stride
553
- }
554
- return
555
- }
556
- // continuous entry бүр нэг босоо мөр
557
- var accY = 0
558
- for (i in pageEntries.indices) {
559
- val h = pageEntries[i].bmpHeight.takeIf { it > 0 } ?: continue
560
- accY += h + 12
561
- if (centerY <= accY || i == pageEntries.size - 1) {
562
- val absolutePage = windowStart + i
563
- if (currentPageIndex != absolutePage) {
564
- currentPageIndex = absolutePage
565
- onPageChange(mapOf("currentPage" to absolutePage, "totalPage" to totalPages))
566
- }
567
- break
568
- }
569
- }
570
- }
571
-
572
- private fun scrollToPage(pageIndex: Int, smooth: Boolean = true) {
573
- if (displayMode == "single") {
574
- if (smooth) pager.smoothScrollToPosition(pageIndex)
575
- else pager.scrollToPosition(pageIndex)
576
- currentPageIndex = pageIndex
577
- onPageChange(mapOf("currentPage" to pageIndex, "totalPage" to totalPages))
578
- return
579
- }
580
- if (pageEntries.isEmpty()) return
581
- val y = scrollYForScrollModePage(pageIndex)
582
- if (smooth) scrollView.smoothScrollTo(0, y) else scrollView.scrollTo(0, y)
583
- currentPageIndex = pageIndex
584
- onPageChange(mapOf("currentPage" to pageIndex, "totalPage" to totalPages))
585
- }
586
-
587
- // ─────────────────────────────────────────────────────────────────────────
588
- // Load & Render
589
- // ─────────────────────────────────────────────────────────────────────────
590
-
591
- private fun startLoad(url: String) {
592
- Log.d("ExpoPdfReader", "startLoad: $url")
593
- renderJob?.cancel()
594
- renderJob = scope.launch {
595
- try {
596
- withContext(pdfDispatcher) { safeCloseRenderer() }
597
- val file = withContext(pdfDispatcher) { resolveFile(url) }
598
- withContext(pdfDispatcher) {
599
- fileDescriptor = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
600
- renderer = PdfRenderer(fileDescriptor!!)
601
- val r = renderer!!
602
- totalPages = r.pageCount
603
- // Хэмжээг энд бүгдийг нь уншихгүй — renderPageBitmap нээх бүрт бөглөнө (анхны ачаалал хурдан).
604
- pagePdfW = IntArray(totalPages) { 0 }
605
- pagePdfH = IntArray(totalPages) { 0 }
606
- }
607
- // annotationMap is intentionally NOT cleared here.
608
- // setAnnotations() may have already populated it before startLoad() runs
609
- // (Expo prop ordering: url first, then initialAnnotations).
610
- // Canvases created in renderDocument() read annotationMap on their first onDraw().
611
- undoStack.clear(); redoStack.clear()
612
- appliedFingerprint = null; currentPageIndex = 0
613
- renderDocument()
614
- // After canvases are created, re-invalidate all of them so initialAnnotations appear.
615
- withContext(Dispatchers.Main) {
616
- if (displayMode == "single") {
617
- singlePageCanvases.values.forEach { it.invalidate() }
618
- } else {
619
- pageEntries.forEach { it.canvasView.invalidate() }
620
- }
621
- }
622
- } catch (e: CancellationException) { throw e }
623
- catch (e: Exception) { Log.e("ExpoPdfReader", "Load error", e) }
624
- }
625
- }
626
-
627
- private fun triggerRender() {
628
- renderJob?.cancel()
629
- renderJob = scope.launch {
630
- try { renderDocument() }
631
- catch (e: CancellationException) { throw e }
632
- catch (e: Exception) { Log.e("ExpoPdfReader", "Render error", e) }
633
- }
634
- }
635
-
636
- private suspend fun renderDocument() {
637
- val pdf = renderer ?: return
638
- val pageCount = withContext(pdfDispatcher) { pdf.pageCount }
639
- if (pageCount <= 0) return
640
- val viewWidth = width.takeIf { it > 0 } ?: resources.displayMetrics.widthPixels
641
- if (viewWidth <= 0) return
642
-
643
- // Chunk state reset
644
- currentViewWidth = viewWidth
645
- renderedUpTo = -1
646
- windowStart = 0
647
- chunkLoading = false
648
-
649
- withContext(Dispatchers.Main) {
650
- container.removeAllViews()
651
- pageEntries.clear()
652
- loadingFooter = null
653
- }
654
-
655
- when (displayMode) {
656
- "single" -> renderSingleMode()
657
- "twoup" -> renderAllPages(pdf, pageCount, viewWidth)
658
- else -> renderScrollModeAround(
659
- pdf,
660
- pageCount,
661
- viewWidth,
662
- currentPageIndex.coerceIn(0, pageCount - 1)
663
- )
664
- }
665
-
666
- // onReady: Main thread дээр шууд дуудна
667
- withContext(Dispatchers.Main) {
668
- val density = resources.displayMetrics.density
669
- Log.d("ExpoPdfReader", "onReady firing: totalPages=$pageCount")
670
- onReady(
671
- mapOf(
672
- "totalPages" to pageCount,
673
- "width" to (this@ExpoPdfReaderView.width / density).toDouble(),
674
- "height" to (this@ExpoPdfReaderView.height / density).toDouble()
675
- )
676
- )
677
- }
678
- }
679
-
680
- // single mode: RecyclerView + PagerSnapHelper — нэг хуудас бүрэн харагдана
681
- private suspend fun renderSingleMode() {
682
- withContext(Dispatchers.Main) {
683
- singlePageCanvases.clear()
684
- scrollView.visibility = View.GONE
685
- pager.visibility = View.VISIBLE
686
- singleAdapter = SinglePageAdapter()
687
- pager.adapter = singleAdapter
688
- pager.scrollToPosition(currentPageIndex)
689
- // React Native Fabric blocks child requestLayout() calls, so the RecyclerView
690
- // never binds its items until the next Fabric layout pass (e.g. thumbnail panel
691
- // opening/closing). Force a manual measure+layout to make items bind immediately.
692
- forcePagerLayout()
693
- }
694
- }
695
-
696
- private fun forcePagerLayout() {
697
- val w = width.takeIf { it > 0 } ?: return
698
- val h = height.takeIf { it > 0 } ?: return
699
- pager.measure(
700
- MeasureSpec.makeMeasureSpec(w, MeasureSpec.EXACTLY),
701
- MeasureSpec.makeMeasureSpec(h, MeasureSpec.EXACTLY)
702
- )
703
- pager.layout(0, 0, w, h)
704
- }
705
-
706
- // twoup — бүх хуудсыг render хийнэ
707
- private suspend fun renderAllPages(pdf: PdfRenderer, pageCount: Int, viewWidth: Int) {
708
- withContext(Dispatchers.Main) {
709
- scrollView.visibility = View.VISIBLE
710
- pager.visibility = View.GONE
711
- }
712
- renderTwoUpRange(pdf, pageCount, viewWidth, 0, pageCount)
713
- withContext(Dispatchers.Main) { renderedUpTo = pageCount - 1 }
714
- }
715
-
716
- /**
717
- * continuous / twoupcontinuous — [anchorPage]-ийн эргэн тойронд MAX_RENDERED хүртэлх цонх.
718
- * Горим солиход одоогийн хуудас хадгалагдана.
719
- */
720
- private suspend fun renderScrollModeAround(
721
- pdf: PdfRenderer,
722
- pageCount: Int,
723
- viewWidth: Int,
724
- anchorPage: Int
725
- ) {
726
- withContext(Dispatchers.Main) {
727
- scrollView.visibility = View.VISIBLE
728
- pager.visibility = View.GONE
729
- }
730
- val target = anchorPage.coerceIn(0, pageCount - 1)
731
- val newStart = maxOf(0, target - PAGE_CHUNK / 2)
732
- val newEnd = minOf(pageCount, newStart + MAX_RENDERED)
733
- withContext(Dispatchers.Main) {
734
- windowStart = newStart
735
- renderedUpTo = newStart - 1
736
- }
737
- if (displayMode == "twoupcontinuous") {
738
- renderTwoUpRange(pdf, pageCount, viewWidth, newStart, newEnd)
739
- withContext(Dispatchers.Main) {
740
- val rawUpTo = if (newEnd % 2 == 0) newEnd - 1 else newEnd - 2
741
- renderedUpTo = rawUpTo.coerceIn(0, pageCount - 1)
742
- if (renderedUpTo < pageCount - 1) showLoadingFooter()
743
- forceLayoutScrollView()
744
- post { scrollToPage(target, smooth = false) }
745
- }
746
- } else {
747
- for (i in newStart until newEnd) {
748
- val bmp = renderPageBitmap(pdf, i, viewWidth)
749
- val idx = i
750
- withContext(Dispatchers.Main) {
751
- addPageRow(
752
- bmp, idx,
753
- pagePdfW.getOrElse(idx) { 1 }.toFloat(),
754
- pagePdfH.getOrElse(idx) { 1 }.toFloat()
755
- )
756
- }
757
- }
758
- withContext(Dispatchers.Main) {
759
- renderedUpTo = (newEnd - 1).coerceIn(0, pageCount - 1)
760
- if (newEnd < pageCount) showLoadingFooter()
761
- forceLayoutScrollView()
762
- post { scrollToPage(target, smooth = false) }
763
- }
764
- }
765
- }
766
-
767
- // twoUp row-г render хийх helper (start..end page range, pairs)
768
- private suspend fun renderTwoUpRange(
769
- pdf: PdfRenderer, pageCount: Int, viewWidth: Int, startPage: Int, endPage: Int
770
- ) {
771
- val halfWidth = viewWidth / 2
772
- var i = startPage
773
- while (i < endPage) {
774
- val leftBmp = renderPageBitmap(pdf, i, halfWidth)
775
- val rightBmp = if (i + 1 < minOf(endPage, pageCount))
776
- renderPageBitmap(pdf, i + 1, halfWidth) else null
777
- val li = i; val ri = i + 1
778
- withContext(Dispatchers.Main) {
779
- // twoUp row-ын өндрийг зүүн хуудасны bitmap-аас авна (хоёр хуудас ижил scale-тай)
780
- val rowH = leftBmp.height
781
- val row = LinearLayout(context).apply {
782
- orientation = LinearLayout.HORIZONTAL
783
- layoutParams = LinearLayout.LayoutParams(viewWidth, rowH)
784
- .apply { setMargins(0, 6, 0, 6) }
785
- }
786
- row.addView(buildPageFrame(leftBmp, li,
787
- pagePdfW.getOrElse(li) { 1 }.toFloat(), pagePdfH.getOrElse(li) { 1 }.toFloat(), 1f))
788
- if (rightBmp != null) {
789
- row.addView(buildPageFrame(rightBmp, ri,
790
- pagePdfW.getOrElse(ri) { 1 }.toFloat(), pagePdfH.getOrElse(ri) { 1 }.toFloat(), 1f))
791
- } else {
792
- row.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(0, rowH, 1f) })
793
- }
794
- container.addView(row)
795
- }
796
- i += 2
797
- }
798
- }
799
-
800
- // ── Lazy chunk helpers ────────────────────────────────────────────────────
801
-
802
- private fun maybeTriggerChunkLoad(scrollY: Int) {
803
- if (chunkLoading) return
804
- if (displayMode !in listOf("continuous", "twoupcontinuous")) return
805
-
806
- val visibleBottom = scrollY + scrollView.height
807
-
808
- // Доош scroll: дараагийн chunk ачаална
809
- if (renderedUpTo < totalPages - 1) {
810
- val forwardTrigger = container.height - scrollView.height * 2
811
- if (visibleBottom >= forwardTrigger) {
812
- chunkLoading = true
813
- scope.launch {
814
- try { loadNextChunk() }
815
- catch (e: CancellationException) { throw e }
816
- catch (e: Exception) { Log.e("ExpoPdfReader", "Chunk load error", e) }
817
- finally { chunkLoading = false }
818
- }
819
- return
820
- }
821
- }
822
-
823
- // Дээш scroll: өмнөх chunk (continuous = нэг багана; twoupcontinuous = хос мөр prepend).
824
- if (windowStart > 0 && scrollY <= scrollView.height * 2) {
825
- chunkLoading = true
826
- scope.launch {
827
- try { loadPrevChunk() }
828
- catch (e: CancellationException) { throw e }
829
- catch (e: Exception) { Log.e("ExpoPdfReader", "Prev chunk load error", e) }
830
- finally { chunkLoading = false }
831
- }
832
- }
833
- }
834
-
835
- private suspend fun loadNextChunk() {
836
- val pdf = renderer ?: return
837
- val start = renderedUpTo + 1
838
- if (start >= totalPages) return
839
- val end = minOf(start + PAGE_CHUNK, totalPages)
840
- val viewWidth = currentViewWidth
841
-
842
- withContext(Dispatchers.Main) { removeLoadingFooter() }
843
-
844
- if (displayMode == "twoupcontinuous") {
845
- renderTwoUpRange(pdf, totalPages, viewWidth, start, end)
846
- withContext(Dispatchers.Main) {
847
- renderedUpTo = if (end % 2 == 0) end - 1 else end - 2
848
- if (renderedUpTo < totalPages - 1) showLoadingFooter()
849
- forceLayoutScrollView()
850
- }
851
- } else {
852
- for (i in start until end) {
853
- val bmp = renderPageBitmap(pdf, i, viewWidth)
854
- val idx = i
855
- withContext(Dispatchers.Main) {
856
- addPageRow(bmp, idx, pagePdfW.getOrElse(idx) { 1 }.toFloat(),
857
- pagePdfH.getOrElse(idx) { 1 }.toFloat())
858
- }
859
- }
860
- withContext(Dispatchers.Main) {
861
- renderedUpTo = end - 1
862
- // Sliding window: MAX_RENDERED-с илүү болвол дээд хэсгийг устга
863
- val excess = pageEntries.size - MAX_RENDERED
864
- if (excess > 0) {
865
- val removed = pruneTopPages(excess)
866
- scrollView.scrollBy(0, -removed)
867
- }
868
- if (end < totalPages) showLoadingFooter()
869
- forceLayoutScrollView()
870
- }
871
- }
872
- }
873
-
874
- /**
875
- * Дээд хэсгийн [count] хуудсыг устгаж, bitmap санах ойг чөлөөлнэ.
876
- * @return устгагдсан хуудсуудын нийт өндөр (px) scroll тохируулахад ашиглана
877
- */
878
- private fun pruneTopPages(count: Int): Int {
879
- var removedHeight = 0
880
- repeat(count) {
881
- val entry = pageEntries.removeFirstOrNull() ?: return@repeat
882
- removedHeight += entry.bmpHeight + 12 // +12 нь margin (дээш 6 + доош 6)
883
- // Bitmap-г чөлөөлнэ
884
- (entry.frame.getChildAt(0) as? ImageView)?.setImageBitmap(null)
885
- container.removeView(entry.frame)
886
- }
887
- windowStart += count
888
- return removedHeight
889
- }
890
-
891
- /**
892
- * Өмнөх [PAGE_CHUNK] хуудсыг дээр нэмж, доод хэсгийг цэвэрлэнэ.
893
- */
894
- private suspend fun loadPrevChunk() {
895
- val pdf = renderer ?: return
896
- if (displayMode == "twoupcontinuous") {
897
- loadPrevChunkTwoup(pdf)
898
- return
899
- }
900
- val prevStart = maxOf(0, windowStart - PAGE_CHUNK)
901
- val prevEnd = windowStart
902
- if (prevStart >= prevEnd) return
903
- val viewWidth = currentViewWidth
904
-
905
- data class PageBmp(val bmp: Bitmap, val idx: Int, val pdfW: Float, val pdfH: Float)
906
- val pages = mutableListOf<PageBmp>()
907
- for (i in prevStart until prevEnd) {
908
- val bmp = renderPageBitmap(pdf, i, viewWidth)
909
- pages.add(PageBmp(bmp, i,
910
- pagePdfW.getOrElse(i) { 1 }.toFloat(),
911
- pagePdfH.getOrElse(i) { 1 }.toFloat()))
912
- }
913
-
914
- withContext(Dispatchers.Main) {
915
- var insertedHeight = 0
916
- // Reverse order-оор insert хийж эцэст нь зөв дараалал гарна
917
- // (бүгд index 0-д insert хийгддэг тул)
918
- for (page in pages.reversed()) {
919
- prependPageFrame(page.bmp, page.idx, page.pdfW, page.pdfH)
920
- insertedHeight += page.bmp.height + 12
921
- }
922
- windowStart = prevStart
923
- renderedUpTo = maxOf(renderedUpTo, windowStart + pageEntries.size - 1)
924
-
925
- // Доод хэсгийг цэвэрлэнэ
926
- val excess = pageEntries.size - MAX_RENDERED
927
- if (excess > 0) pruneBottomPages(excess)
928
-
929
- // Дээр нэмсэн өндрийг scroll-д нэмж харагдах хэсгийг хэвээр үлдээнэ
930
- scrollView.scrollBy(0, insertedHeight)
931
- forceLayoutScrollView()
932
- }
933
- }
934
-
935
- /** twoupcontinuous: өмнөх chunk-ийг хос мөрөөр дээр нэмнэ. */
936
- private suspend fun loadPrevChunkTwoup(pdf: PdfRenderer) {
937
- val prevStart = maxOf(0, windowStart - PAGE_CHUNK)
938
- val prevEnd = windowStart
939
- if (prevStart >= prevEnd) return
940
- val viewWidth = currentViewWidth
941
- val halfW = viewWidth / 2
942
-
943
- data class RowBmps(val leftIdx: Int, val leftBmp: Bitmap, val rightBmp: Bitmap?)
944
- val rows = mutableListOf<RowBmps>()
945
- var i = prevStart
946
- while (i < prevEnd) {
947
- val leftBmp = renderPageBitmap(pdf, i, halfW)
948
- val rightBmp = if (i + 1 < minOf(prevEnd, totalPages))
949
- renderPageBitmap(pdf, i + 1, halfW) else null
950
- rows.add(RowBmps(i, leftBmp, rightBmp))
951
- i += 2
952
- }
953
-
954
- withContext(Dispatchers.Main) {
955
- var insertedHeight = 0
956
- for (rb in rows.asReversed()) {
957
- prependTwoupRowToContainer(
958
- rb.leftBmp, rb.leftIdx,
959
- rb.rightBmp,
960
- if (rb.rightBmp != null) rb.leftIdx + 1 else null,
961
- viewWidth
962
- )
963
- insertedHeight += rb.leftBmp.height + 12
964
- }
965
- windowStart = prevStart
966
- renderedUpTo = windowStart + pageEntries.size - 1
967
-
968
- while (pageEntries.size > MAX_RENDERED) {
969
- pruneBottomTwoupOneRow()
970
- }
971
-
972
- scrollView.scrollBy(0, insertedHeight)
973
- forceLayoutScrollView()
974
- }
975
- }
976
-
977
- /** twoup мөрийг container + pageEntries-ийн эхэнд оруулна (дээд chunk). */
978
- private fun prependTwoupRowToContainer(
979
- leftBmp: Bitmap, li: Int,
980
- rightBmp: Bitmap?, ri: Int?,
981
- viewWidth: Int
982
- ) {
983
- val rowH = leftBmp.height
984
- val row = LinearLayout(context).apply {
985
- orientation = LinearLayout.HORIZONTAL
986
- layoutParams = LinearLayout.LayoutParams(viewWidth, rowH)
987
- .apply { setMargins(0, 6, 0, 6) }
988
- }
989
- val (leftFrame, leftEntry) = buildPageFrameDetached(
990
- leftBmp, li,
991
- pagePdfW.getOrElse(li) { 1 }.toFloat(),
992
- pagePdfH.getOrElse(li) { 1 }.toFloat(),
993
- 1f
994
- )
995
- row.addView(leftFrame)
996
- if (rightBmp != null && ri != null) {
997
- val (rightFrame, rightEntry) = buildPageFrameDetached(
998
- rightBmp, ri,
999
- pagePdfW.getOrElse(ri) { 1 }.toFloat(),
1000
- pagePdfH.getOrElse(ri) { 1 }.toFloat(),
1001
- 1f
1002
- )
1003
- row.addView(rightFrame)
1004
- pageEntries.add(0, rightEntry)
1005
- pageEntries.add(0, leftEntry)
1006
- } else {
1007
- row.addView(View(context).apply {
1008
- layoutParams = LinearLayout.LayoutParams(0, rowH, 1f)
1009
- })
1010
- pageEntries.add(0, leftEntry)
1011
- }
1012
- container.addView(row, 0)
1013
- }
1014
-
1015
- /** twoupcontinuous: доод талын нэг мөрийг (эсвэл сүүлийн entry) устгана. */
1016
- private fun pruneBottomTwoupOneRow() {
1017
- val last = pageEntries.lastOrNull() ?: return
1018
- val row = last.frame.parent as? LinearLayout
1019
- if (row != null && row.parent === container && row.childCount > 0) {
1020
- val removeList = pageEntries.filter { it.frame.parent === row }
1021
- for (e in removeList) {
1022
- (e.frame.getChildAt(0) as? ImageView)?.setImageBitmap(null)
1023
- pageEntries.remove(e)
1024
- }
1025
- container.removeView(row)
1026
- } else {
1027
- val e = pageEntries.removeLastOrNull() ?: return
1028
- (e.frame.getChildAt(0) as? ImageView)?.setImageBitmap(null)
1029
- container.removeView(e.frame)
1030
- }
1031
- renderedUpTo = windowStart + pageEntries.size - 1
1032
- }
1033
-
1034
- /**
1035
- * Дээр (index 0) шинэ хуудас оруулна. windowStart-г дуудагч тал шинэчилнэ.
1036
- */
1037
- private fun prependPageFrame(bmp: Bitmap, pageIdx: Int, pdfW: Float, pdfH: Float) {
1038
- val bmpW = bmp.width
1039
- val bmpH = bmp.height
1040
- val frame = FrameLayout(context).apply {
1041
- layoutParams = LinearLayout.LayoutParams(bmpW, bmpH).apply { setMargins(0, 6, 0, 6) }
1042
- }
1043
- val iv = ImageView(context).apply {
1044
- layoutParams = FrameLayout.LayoutParams(bmpW, bmpH)
1045
- scaleType = ImageView.ScaleType.FIT_XY
1046
- setImageBitmap(bmp)
1047
- }
1048
- val canvasView = AnnotationCanvasView(context, pageIdx, pdfW, pdfH).apply {
1049
- layoutParams = FrameLayout.LayoutParams(bmpW, bmpH)
1050
- translationZ = 1f
1051
- }
1052
- frame.addView(iv)
1053
- frame.addView(canvasView)
1054
- container.addView(frame, 0)
1055
- pageEntries.add(0, PageEntry(frame, canvasView, bmpH))
1056
- }
1057
-
1058
- /**
1059
- * Доод хэсгийн [count] хуудсыг устгаж bitmap санах ойг чөлөөлнэ.
1060
- * renderedUpTo-г буулгаж дараагийн доош scroll-д re-render хийгдэхийг зөвшөөрнэ.
1061
- */
1062
- private fun pruneBottomPages(count: Int) {
1063
- repeat(count) {
1064
- val entry = pageEntries.removeLastOrNull() ?: return@repeat
1065
- (entry.frame.getChildAt(0) as? ImageView)?.setImageBitmap(null)
1066
- container.removeView(entry.frame)
1067
- }
1068
- renderedUpTo = windowStart + pageEntries.size - 1
1069
- }
1070
-
1071
- /**
1072
- * Render хийгдээгүй хуудас руу үсрэх.
1073
- * Бүх одоогийн view-г цэвэрлэж, targetPage-ийн эргэн тойрны хуудсуудыг render хийнэ.
1074
- */
1075
- private fun jumpToPage(targetPage: Int) {
1076
- val pdf = renderer ?: return
1077
- val viewWidth = currentViewWidth.takeIf { it > 0 } ?: width.takeIf { it > 0 } ?: return
1078
- renderJob?.cancel()
1079
- renderJob = scope.launch {
1080
- try {
1081
- // Шинэ window: targetPage-ийн хагас chunk өмнөөс эхэлнэ
1082
- val newStart = maxOf(0, targetPage - PAGE_CHUNK / 2)
1083
- val newEnd = minOf(totalPages, newStart + MAX_RENDERED)
1084
-
1085
- withContext(Dispatchers.Main) {
1086
- container.removeAllViews()
1087
- pageEntries.clear()
1088
- loadingFooter = null
1089
- windowStart = newStart
1090
- renderedUpTo = newStart - 1
1091
- chunkLoading = false
1092
- }
1093
-
1094
- if (displayMode == "twoupcontinuous") {
1095
- renderTwoUpRange(pdf, totalPages, viewWidth, newStart, newEnd)
1096
- withContext(Dispatchers.Main) {
1097
- val rawUpTo = if (newEnd % 2 == 0) newEnd - 1 else newEnd - 2
1098
- renderedUpTo = rawUpTo.coerceIn(0, totalPages - 1)
1099
- if (renderedUpTo < totalPages - 1) showLoadingFooter()
1100
- forceLayoutScrollView()
1101
- post { scrollToPage(targetPage, smooth = false) }
1102
- }
1103
- } else {
1104
- for (i in newStart until newEnd) {
1105
- val bmp = renderPageBitmap(pdf, i, viewWidth)
1106
- val idx = i
1107
- withContext(Dispatchers.Main) {
1108
- addPageRow(
1109
- bmp, idx,
1110
- pagePdfW.getOrElse(idx) { 1 }.toFloat(),
1111
- pagePdfH.getOrElse(idx) { 1 }.toFloat()
1112
- )
1113
- }
1114
- }
1115
- withContext(Dispatchers.Main) {
1116
- renderedUpTo = newEnd - 1
1117
- if (newEnd < totalPages) showLoadingFooter()
1118
- forceLayoutScrollView()
1119
- post { scrollToPage(targetPage, smooth = false) }
1120
- }
1121
- }
1122
- } catch (e: CancellationException) { throw e }
1123
- catch (e: Exception) { Log.e("ExpoPdfReader", "JumpToPage error", e) }
1124
- }
1125
- }
1126
-
1127
- /**
1128
- * React Native Fabric нь child view-н requestLayout()-г таслах тул
1129
- * addView() дараа layout pass автоматаар ажиллахгүй.
1130
- * Энэ функц scrollView-г шууд measure + layout хийж pages-г харагдуулна.
1131
- */
1132
- private fun forceLayoutScrollView() {
1133
- val w = width.takeIf { it > 0 } ?: return
1134
- val h = height.takeIf { it > 0 } ?: return
1135
- scrollView.measure(
1136
- MeasureSpec.makeMeasureSpec(w, MeasureSpec.EXACTLY),
1137
- MeasureSpec.makeMeasureSpec(h, MeasureSpec.EXACTLY)
1138
- )
1139
- scrollView.layout(0, 0, w, h)
1140
- }
1141
-
1142
- private fun showLoadingFooter() {
1143
- if (loadingFooter != null) return
1144
- val footer = ProgressBar(context, null, android.R.attr.progressBarStyleSmall).apply {
1145
- layoutParams = LinearLayout.LayoutParams(
1146
- LinearLayout.LayoutParams.MATCH_PARENT, 120
1147
- ).apply { setMargins(0, 16, 0, 16) }
1148
- isIndeterminate = true
1149
- }
1150
- container.addView(footer)
1151
- loadingFooter = footer
1152
- }
1153
-
1154
- private fun removeLoadingFooter() {
1155
- loadingFooter?.let { container.removeView(it) }
1156
- loadingFooter = null
1157
- }
1158
-
1159
- private fun addPageRow(bmp: Bitmap, pageIdx: Int, pdfW: Float, pdfH: Float) =
1160
- container.addView(buildPageFrame(bmp, pageIdx, pdfW, pdfH))
1161
-
1162
- private fun buildPageFrameDetached(
1163
- bmp: Bitmap, pageIdx: Int, pdfW: Float, pdfH: Float, weight: Float = 0f
1164
- ): Pair<FrameLayout, PageEntry> {
1165
- val bmpW = bmp.width
1166
- val bmpH = bmp.height
1167
- val frame = FrameLayout(context).apply {
1168
- layoutParams = if (weight > 0f)
1169
- LinearLayout.LayoutParams(0, bmpH, weight)
1170
- else
1171
- LinearLayout.LayoutParams(bmpW, bmpH)
1172
- .apply { setMargins(0, 6, 0, 6) }
1173
- }
1174
- val iv = ImageView(context).apply {
1175
- layoutParams = FrameLayout.LayoutParams(bmpW, bmpH)
1176
- scaleType = ImageView.ScaleType.FIT_XY
1177
- setImageBitmap(bmp)
1178
- }
1179
- val canvasView = AnnotationCanvasView(context, pageIdx, pdfW, pdfH).apply {
1180
- layoutParams = FrameLayout.LayoutParams(bmpW, bmpH)
1181
- translationZ = 1f
1182
- }
1183
- frame.addView(iv)
1184
- frame.addView(canvasView)
1185
- return frame to PageEntry(frame, canvasView, bmpH)
1186
- }
1187
-
1188
- private fun buildPageFrame(bmp: Bitmap, pageIdx: Int, pdfW: Float, pdfH: Float, weight: Float = 0f): FrameLayout {
1189
- val (frame, entry) = buildPageFrameDetached(bmp, pageIdx, pdfW, pdfH, weight)
1190
- pageEntries.add(entry)
1191
- return frame
1192
- }
1193
-
1194
- private suspend fun renderPageBitmap(pdf: PdfRenderer, index: Int, targetWidth: Int): Bitmap =
1195
- withContext(pdfDispatcher) {
1196
- pdf.openPage(index).use { page ->
1197
- if (index < pagePdfW.size) {
1198
- pagePdfW[index] = page.width
1199
- pagePdfH[index] = page.height
1200
- }
1201
- val s = targetWidth.toFloat() / page.width.toFloat()
1202
- val h = (page.height * s).toInt().coerceAtLeast(1)
1203
- val bmp = Bitmap.createBitmap(targetWidth, h, Bitmap.Config.ARGB_8888)
1204
- bmp.eraseColor(Color.WHITE)
1205
- page.render(bmp, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
1206
- bmp
1207
- }
1208
- }
1209
-
1210
- private fun safeCloseRenderer() {
1211
- try { renderer?.close(); fileDescriptor?.close() }
1212
- catch (_: Exception) { }
1213
- finally { renderer = null; fileDescriptor = null }
1214
- }
1215
-
1216
- private suspend fun resolveFile(url: String): File = withContext(pdfDispatcher) {
1217
- when {
1218
- url.startsWith("file://") -> File(url.removePrefix("file://"))
1219
- url.startsWith("/") -> File(url)
1220
- else -> {
1221
- val f = File.createTempFile("pdf_", ".pdf", context.cacheDir)
1222
- URL(url).openStream().use { inp -> FileOutputStream(f).use { inp.copyTo(it) } }
1223
- f
1224
- }
1225
- }
1226
- }
1227
-
1228
- private fun buildFingerprint(data: List<Map<String, Any?>>): String =
1229
- data.mapNotNull { item ->
1230
- val p = (item["page"] as? Number)?.toInt() ?: return@mapNotNull null
1231
- val t = item["type"] as? String ?: return@mapNotNull null
1232
- val b = item["bounds"] as? Map<*, *> ?: return@mapNotNull null
1233
- "$p|$t|${b["x"]},${b["y"]},${b["width"]},${b["height"]}"
1234
- }.sorted().joinToString("||")
1235
-
1236
- // ─────────────────────────────────────────────────────────────────────────
1237
- // SinglePageAdapter — RecyclerView adapter for "single" display mode
1238
- // • vertical PagerSnapHelper — босоо scroll-оор хуудас солих
1239
- // • ZoomRecyclerView zoom-г удирдана (adapter энгийн FrameLayout ашиглана)
1240
- // • lazy bitmap loading
1241
- // ─────────────────────────────────────────────────────────────────────────
1242
-
1243
- inner class SinglePageAdapter : RecyclerView.Adapter<SinglePageAdapter.Holder>() {
1244
-
1245
- inner class Holder(val frame: FrameLayout, val imageView: ImageView) :
1246
- RecyclerView.ViewHolder(frame) {
1247
- var boundPage = -1
1248
- var loadJob: Job? = null
1249
- var currentCanvas: AnnotationCanvasView? = null
1250
- }
1251
-
1252
- override fun getItemCount(): Int = totalPages
1253
-
1254
- override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
1255
- val frame = FrameLayout(context).apply {
1256
- layoutParams = RecyclerView.LayoutParams(
1257
- RecyclerView.LayoutParams.MATCH_PARENT,
1258
- RecyclerView.LayoutParams.MATCH_PARENT
1259
- )
1260
- setBackgroundColor(Color.parseColor("#E8E8E8"))
1261
- }
1262
- val iv = ImageView(context).apply {
1263
- layoutParams = FrameLayout.LayoutParams(
1264
- FrameLayout.LayoutParams.WRAP_CONTENT,
1265
- FrameLayout.LayoutParams.WRAP_CONTENT,
1266
- android.view.Gravity.CENTER
1267
- )
1268
- scaleType = ImageView.ScaleType.FIT_XY
1269
- }
1270
- frame.addView(iv)
1271
- return Holder(frame, iv)
1272
- }
1273
-
1274
- override fun onBindViewHolder(holder: Holder, position: Int) {
1275
- holder.boundPage = position
1276
- holder.loadJob?.cancel()
1277
- holder.imageView.setImageBitmap(null)
1278
- // Recycle хийгдсэн view-н zoom transform-г цэвэрлэнэ
1279
- holder.frame.scaleX = 1f; holder.frame.scaleY = 1f
1280
- holder.frame.translationX = 0f; holder.frame.translationY = 0f
1281
-
1282
- holder.currentCanvas?.let {
1283
- singlePageCanvases.remove(it.pageIndex)
1284
- holder.frame.removeView(it)
1285
- }
1286
- holder.currentCanvas = null
1287
-
1288
- holder.loadJob = scope.launch {
1289
- val pdf = renderer ?: return@launch
1290
- val bmp = renderPageBitmap(pdf, position, this@ExpoPdfReaderView.width)
1291
- val pdfW = pagePdfW.getOrElse(position) { 1 }.toFloat()
1292
- val pdfH = pagePdfH.getOrElse(position) { 1 }.toFloat()
1293
- withContext(Dispatchers.Main) {
1294
- if (holder.boundPage != position) return@withContext
1295
- val canvas = AnnotationCanvasView(context, position, pdfW, pdfH).apply {
1296
- layoutParams = FrameLayout.LayoutParams(
1297
- bmp.width, bmp.height,
1298
- android.view.Gravity.CENTER
1299
- )
1300
- translationZ = 1f
1301
- }
1302
- holder.frame.addView(canvas)
1303
- holder.currentCanvas = canvas
1304
- singlePageCanvases[position] = canvas
1305
- val lp = FrameLayout.LayoutParams(bmp.width, bmp.height, android.view.Gravity.CENTER)
1306
- holder.imageView.layoutParams = lp
1307
- holder.imageView.setImageBitmap(bmp)
1308
- val fw = this@ExpoPdfReaderView.width.takeIf { it > 0 } ?: return@withContext
1309
- val fh = this@ExpoPdfReaderView.height.takeIf { it > 0 } ?: return@withContext
1310
- holder.frame.measure(
1311
- MeasureSpec.makeMeasureSpec(fw, MeasureSpec.EXACTLY),
1312
- MeasureSpec.makeMeasureSpec(fh, MeasureSpec.EXACTLY)
1313
- )
1314
- holder.frame.layout(0, 0, fw, fh)
1315
- }
1316
- }
1317
- }
1318
-
1319
- override fun onViewRecycled(holder: Holder) {
1320
- holder.loadJob?.cancel()
1321
- holder.currentCanvas?.let {
1322
- singlePageCanvases.remove(it.pageIndex)
1323
- holder.frame.removeView(it)
1324
- }
1325
- holder.currentCanvas = null
1326
- holder.imageView.setImageBitmap(null)
1327
- }
1328
- }
1329
-
1330
- // ─────────────────────────────────────────────────────────────────────────
1331
- // ZoomRecyclerView — single mode-д pinch-to-zoom + pan дэмждэг RecyclerView
1332
- //
1333
- // Яагаад RecyclerView subclass хийх шаардлагатай вэ:
1334
- // AnnotationCanvasView ACTION_DOWN-д false буцаана RecyclerView
1335
- // gesture-г эзэмшиж авна дараагийн ACTION_POINTER_DOWN (2-р хуруу)
1336
- // шууд RecyclerView.onTouchEvent-д очдог.
1337
- // RecyclerView subclass хийснээр onTouchEvent-д multi-touch-г зохицуулна.
1338
- //
1339
- // Zoom тооцоо (pivot=0,0 + translation):
1340
- // Item view point (px,py) → дэлгэц: (px*scale + tx, py*scale + ty)
1341
- // Focus (fx,fy) тогтвортой байхад: new_tx = fx - (fx - tx) * scaleFactor
1342
- // ─────────────────────────────────────────────────────────────────────────
1343
-
1344
- inner class ZoomRecyclerView(context: Context) : RecyclerView(context) {
1345
- private var currentScale = 1f
1346
- private var tx = 0f
1347
- private var ty = 0f
1348
- private var panX = 0f
1349
- private var panY = 0f
1350
-
1351
- private val zoomDetector = ScaleGestureDetector(context,
1352
- object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
1353
- override fun onScale(d: ScaleGestureDetector): Boolean {
1354
- val newScale = (currentScale * d.scaleFactor).coerceIn(minZoom, maxZoom)
1355
- val af = newScale / currentScale
1356
- // Focus point тогтвортой байхын zoom-to-point тооцоо
1357
- tx = d.focusX - (d.focusX - tx) * af
1358
- ty = d.focusY - (d.focusY - ty) * af
1359
- currentScale = newScale
1360
- clampAndApply()
1361
- return true
1362
- }
1363
- }
1364
- )
1365
-
1366
- override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
1367
- // Zoom хийгдсэн + tool идэвхгүй → pan хийхийн тулд ACTION_DOWN-г intercept
1368
- if (currentScale > 1f && activeTool == null && ev.action == MotionEvent.ACTION_DOWN) {
1369
- return true
1370
- }
1371
- return super.onInterceptTouchEvent(ev)
1372
- }
1373
-
1374
- override fun onTouchEvent(ev: MotionEvent): Boolean {
1375
- zoomDetector.onTouchEvent(ev)
1376
-
1377
- // 2 хуруу байвал RecyclerView scroll хийхгүйгээр zoom-г л хийнэ
1378
- if (ev.pointerCount >= 2 || zoomDetector.isInProgress) {
1379
- return true
1380
- }
1381
-
1382
- // Zoom хийгдсэн үед нэг хуруугаар pan (tool идэвхгүй)
1383
- if (currentScale > 1f && activeTool == null) {
1384
- when (ev.actionMasked) {
1385
- MotionEvent.ACTION_DOWN -> { panX = ev.x; panY = ev.y }
1386
- MotionEvent.ACTION_MOVE -> {
1387
- tx += ev.x - panX
1388
- ty += ev.y - panY
1389
- panX = ev.x; panY = ev.y
1390
- clampAndApply()
1391
- }
1392
- MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { }
1393
- }
1394
- return true
1395
- }
1396
-
1397
- return super.onTouchEvent(ev)
1398
- }
1399
-
1400
- fun resetZoom() {
1401
- currentScale = 1f; tx = 0f; ty = 0f
1402
- applyToCurrentItem()
1403
- }
1404
-
1405
- private fun clampAndApply() {
1406
- val w = width.toFloat(); val h = height.toFloat()
1407
- if (w > 0f && h > 0f) {
1408
- val sw = w * currentScale; val sh = h * currentScale
1409
- tx = if (sw > w) tx.coerceIn(w - sw, 0f) else 0f
1410
- ty = if (sh > h) ty.coerceIn(h - sh, 0f) else 0f
1411
- }
1412
- applyToCurrentItem()
1413
- }
1414
-
1415
- private fun applyToCurrentItem() {
1416
- val lm = layoutManager as? LinearLayoutManager ?: return
1417
- val pos = lm.findFirstVisibleItemPosition().takeIf { it >= 0 } ?: return
1418
- val itemView = lm.findViewByPosition(pos) ?: return
1419
- itemView.pivotX = 0f; itemView.pivotY = 0f
1420
- itemView.scaleX = currentScale; itemView.scaleY = currentScale
1421
- itemView.translationX = tx; itemView.translationY = ty
1422
- }
1423
- }
1424
-
1425
- // ─────────────────────────────────────────────────────────────────────────
1426
- // AnnotationCanvasView (inner class — full access to outer view state)
1427
- // ─────────────────────────────────────────────────────────────────────────
1428
-
1429
- inner class AnnotationCanvasView(
1430
- context: Context,
1431
- val pageIndex: Int,
1432
- private val pdfWidth: Float,
1433
- private val pdfHeight: Float
1434
- ) : View(context) {
1435
-
1436
- private var drawTool: String? = null
1437
- private val drawPoints = mutableListOf<PointF>() // PDF coords
1438
- private var drawStart: PointF? = null
1439
-
1440
- // ── No-tool touch state (tap → event, long-press → drag) ─────────────
1441
- private var noToolDown = false
1442
- private var noToolDownScreen = PointF()
1443
- private var isDragging = false
1444
- private var dragAnn: PdfAnnotation? = null
1445
- private var dragOrigBounds = RectF()
1446
- private val LP_TIMEOUT = android.view.ViewConfiguration.getLongPressTimeout().toLong()
1447
- private val TOUCH_SLOP by lazy { android.view.ViewConfiguration.get(context).scaledTouchSlop.toFloat() }
1448
- private val lpHandler = android.os.Handler(android.os.Looper.getMainLooper())
1449
- private val lpRunnable = Runnable {
1450
- if (dragAnn?.type != "text" && dragAnn?.type != "note") return@Runnable
1451
- isDragging = true
1452
- pager.requestDisallowInterceptTouchEvent(true)
1453
- scrollView.requestDisallowInterceptTouchEvent(true)
1454
- invalidate()
1455
- }
1456
-
1457
- // Reusable objects — no allocation in onDraw
1458
- private val inkPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
1459
- style = Paint.Style.STROKE; strokeCap = Paint.Cap.ROUND; strokeJoin = Paint.Join.ROUND
1460
- }
1461
- private val bgFill = Paint().apply { style = Paint.Style.FILL }
1462
- private val bgStroke = Paint().apply {
1463
- style = Paint.Style.STROKE; strokeWidth = 1.5f; color = Color.DKGRAY
1464
- }
1465
- private val textPaint = TextPaint(Paint.ANTI_ALIAS_FLAG)
1466
- private val drawPath = Path()
1467
-
1468
- // ── Lifecycle ─────────────────────────────────────────────────────────
1469
-
1470
- override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
1471
- super.onSizeChanged(w, h, oldw, oldh)
1472
- // Single-mode canvases start with MATCH_PARENT (width=0) and receive their real
1473
- // size after RecyclerView lays out. Re-draw so initialAnnotations become visible.
1474
- if (w > 0 && oldw == 0) invalidate()
1475
- }
1476
-
1477
- // ── Coordinate helpers ────────────────────────────────────────────────
1478
-
1479
- private fun scale() = if (width > 0 && pdfWidth > 0) width.toFloat() / pdfWidth else 1f
1480
-
1481
- /** View-local screen PDF. */
1482
- private fun toPdf(sx: Float, sy: Float): PointF {
1483
- val s = scale(); return PointF(sx / s, pdfHeight - sy / s)
1484
- }
1485
-
1486
- /** PDF → view-local screen. */
1487
- private fun toScreen(px: Float, py: Float): PointF {
1488
- val s = scale(); return PointF(px * s, (pdfHeight - py) * s)
1489
- }
1490
-
1491
- /**
1492
- * PDF RectF → screen RectF.
1493
- * bounds.top = lower PDF y → screen bottom
1494
- * bounds.bottom = upper PDF y → screen top
1495
- */
1496
- private fun toScreenRect(b: RectF): RectF {
1497
- val s = scale()
1498
- return RectF(b.left * s, (pdfHeight - b.bottom) * s, b.right * s, (pdfHeight - b.top) * s)
1499
- }
1500
-
1501
- // ── Touch ─────────────────────────────────────────────────────────────
1502
-
1503
- override fun onTouchEvent(event: MotionEvent): Boolean {
1504
- val tool = activeTool
1505
- if (tool != null) {
1506
- // Drawing tool active — consume all events
1507
- scrollView.requestDisallowInterceptTouchEvent(true)
1508
- pager.requestDisallowInterceptTouchEvent(true)
1509
- val pdfPt = toPdf(event.x, event.y)
1510
- when (tool) {
1511
- "eraser" -> {
1512
- if (event.action == MotionEvent.ACTION_DOWN || event.action == MotionEvent.ACTION_MOVE)
1513
- eraseAt(pdfPt)
1514
- }
1515
- "text", "note" -> { if (event.action == MotionEvent.ACTION_DOWN) addInstantAnnotation(pdfPt, tool) }
1516
- else -> handleDraw(event, pdfPt, tool)
1517
- }
1518
- return true
1519
- }
1520
- return handleNoToolTouch(event)
1521
- }
1522
-
1523
- /**
1524
- * Touch handling when no drawing tool is active.
1525
- * - Single tap on text/note fire onTextPress / onNotePress
1526
- * - Long press on text/note drag the annotation
1527
- * The key issue: returning false on ACTION_DOWN means we never receive ACTION_UP.
1528
- * So we return true only when we detect a tap/drag target on ACTION_DOWN.
1529
- */
1530
- private fun handleNoToolTouch(event: MotionEvent): Boolean {
1531
- when (event.action) {
1532
- MotionEvent.ACTION_DOWN -> {
1533
- val pdfPt = toPdf(event.x, event.y)
1534
- val ann = tapTargetAt(pdfPt) ?: return false
1535
- dragAnn = ann
1536
- dragOrigBounds = RectF(ann.bounds)
1537
- noToolDownScreen = PointF(event.x, event.y)
1538
- noToolDown = true
1539
- lpHandler.postDelayed(lpRunnable, LP_TIMEOUT)
1540
- return true
1541
- }
1542
- MotionEvent.ACTION_MOVE -> {
1543
- if (!noToolDown) return false
1544
- val dx = event.x - noToolDownScreen.x
1545
- val dy = event.y - noToolDownScreen.y
1546
- if (isDragging) {
1547
- val ann = dragAnn ?: return true
1548
- val s = scale()
1549
- val annW = dragOrigBounds.width()
1550
- val annH = dragOrigBounds.height()
1551
- // PDF y is inverted: screen down (dy>0) → PDF y decreases
1552
- val newLeft = (dragOrigBounds.left + dx / s).coerceIn(0f, pdfWidth - annW)
1553
- val newBottom = (dragOrigBounds.bottom - dy / s).coerceIn(annH, pdfHeight)
1554
- ann.bounds = RectF(newLeft, newBottom - annH, newLeft + annW, newBottom)
1555
- invalidate()
1556
- return true
1557
- }
1558
- // Cancel long-press if finger moved beyond touch slop
1559
- if (Math.hypot(dx.toDouble(), dy.toDouble()) > TOUCH_SLOP) {
1560
- lpHandler.removeCallbacks(lpRunnable)
1561
- noToolDown = false
1562
- dragAnn = null
1563
- return false
1564
- }
1565
- return true
1566
- }
1567
- MotionEvent.ACTION_UP -> {
1568
- lpHandler.removeCallbacks(lpRunnable)
1569
- val wasDown = noToolDown
1570
- val wasDragging = isDragging
1571
- noToolDown = false; isDragging = false
1572
- pager.requestDisallowInterceptTouchEvent(false)
1573
- scrollView.requestDisallowInterceptTouchEvent(false)
1574
- if (!wasDown) { dragAnn = null; return false }
1575
- if (wasDragging) {
1576
- dragAnn = null
1577
- notifyAnnotationChange()
1578
- } else {
1579
- dragAnn = null
1580
- handleTap(event.x, event.y)
1581
- }
1582
- return true
1583
- }
1584
- MotionEvent.ACTION_CANCEL -> {
1585
- lpHandler.removeCallbacks(lpRunnable)
1586
- if (isDragging) { dragAnn?.bounds = RectF(dragOrigBounds); invalidate() }
1587
- noToolDown = false; isDragging = false; dragAnn = null
1588
- pager.requestDisallowInterceptTouchEvent(false)
1589
- scrollView.requestDisallowInterceptTouchEvent(false)
1590
- return false
1591
- }
1592
- }
1593
- return noToolDown
1594
- }
1595
-
1596
- /** Returns the first text or note annotation under [pdfPt], or null. */
1597
- private fun tapTargetAt(pdfPt: PointF): PdfAnnotation? {
1598
- val pageAnns = annotationMap[pageIndex] ?: return null
1599
- return pageAnns.firstOrNull { (it.type == "text" || it.type == "note") && it.bounds.contains(pdfPt.x, pdfPt.y) }
1600
- }
1601
-
1602
- private fun handleDraw(event: MotionEvent, pdfPt: PointF, tool: String) {
1603
- when (event.action) {
1604
- MotionEvent.ACTION_DOWN -> {
1605
- drawTool = tool; drawPoints.clear()
1606
- drawStart = PointF(pdfPt.x, pdfPt.y); drawPoints.add(PointF(pdfPt.x, pdfPt.y))
1607
- invalidate()
1608
- }
1609
- MotionEvent.ACTION_MOVE -> {
1610
- if (tool == "line") {
1611
- drawPoints.clear(); drawStart?.let { drawPoints.add(PointF(it.x, it.y)) }
1612
- }
1613
- drawPoints.add(PointF(pdfPt.x, pdfPt.y)); invalidate()
1614
- }
1615
- MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
1616
- if (drawPoints.size >= 2) commitStroke(tool)
1617
- drawTool = null; drawPoints.clear(); drawStart = null; invalidate()
1618
- }
1619
- }
1620
- }
1621
-
1622
- private fun commitStroke(tool: String) {
1623
- val pad = strokeWidthVal / 2f
1624
- val ann = PdfAnnotation(
1625
- type = tool, pageIndex = pageIndex,
1626
- bounds = RectF(
1627
- drawPoints.minOf { it.x } - pad, drawPoints.minOf { it.y } - pad,
1628
- drawPoints.maxOf { it.x } + pad, drawPoints.maxOf { it.y } + pad
1629
- ),
1630
- color = strokeColorVal, strokeWidth = strokeWidthVal
1631
- )
1632
- ann.paths.add(AnnPath(drawPoints.map { PointF(it.x, it.y) }.toMutableList()))
1633
- addAnnotationAndCommit(ann)
1634
- }
1635
-
1636
- private fun eraseAt(pdfPt: PointF) {
1637
- val pageAnns = annotationMap[pageIndex] ?: return
1638
- val r = 10f / scale()
1639
- val hit = RectF(pdfPt.x - r, pdfPt.y - r, pdfPt.x + r, pdfPt.y + r)
1640
- val toRemove = pageAnns.filter { RectF.intersects(it.bounds, hit) }
1641
- if (toRemove.isNotEmpty()) {
1642
- pageAnns.removeAll(toRemove.toSet())
1643
- toRemove.forEach { ann -> undoStack.removeAll { it.annotation === ann } }
1644
- invalidate(); notifyAnnotationChange(); notifyUndoRedoState()
1645
- }
1646
- }
1647
-
1648
- private fun addInstantAnnotation(pdfPt: PointF, tool: String) {
1649
- val s = scale()
1650
- val ann = if (tool == "note") {
1651
- PdfAnnotation("note", pageIndex,
1652
- RectF(pdfPt.x, pdfPt.y, pdfPt.x + 32f / s, pdfPt.y + 32f / s),
1653
- color = noteColorVal, contents = textContentVal.ifBlank { " " })
1654
- } else {
1655
- val w = 180f / s; val h = 40f / s
1656
- PdfAnnotation("text", pageIndex,
1657
- RectF(pdfPt.x, pdfPt.y - h, pdfPt.x + w, pdfPt.y),
1658
- color = textColorVal, contents = textContentVal.ifBlank { " " },
1659
- fontSize = textFontSizeVal, bold = textBoldVal, italic = textItalicVal)
1660
- }
1661
- addAnnotationAndCommit(ann)
1662
- }
1663
-
1664
- private fun handleTap(screenX: Float, screenY: Float) {
1665
- val pdfPt = toPdf(screenX, screenY)
1666
- val pageAnns = annotationMap[pageIndex] ?: return
1667
-
1668
- pageAnns.filter { it.type == "text" }.forEachIndexed { idx, ann ->
1669
- if (ann.bounds.contains(pdfPt.x, pdfPt.y)) {
1670
- onTextPress(mapOf("page" to pageIndex, "index" to idx,
1671
- "bounds" to boundsMap(ann), "contents" to ann.contents)); return
1672
- }
1673
- }
1674
- pageAnns.filter { it.type == "note" }.forEachIndexed { idx, ann ->
1675
- if (ann.bounds.contains(pdfPt.x, pdfPt.y)) {
1676
- onNotePress(mapOf("page" to pageIndex, "index" to idx,
1677
- "bounds" to boundsMap(ann), "contents" to ann.contents)); return
1678
- }
1679
- }
1680
- }
1681
-
1682
- private fun boundsMap(ann: PdfAnnotation) = mapOf(
1683
- "x" to ann.bounds.left.toDouble(), "y" to ann.bounds.top.toDouble(),
1684
- "width" to ann.bounds.width().toDouble(), "height" to ann.bounds.height().toDouble()
1685
- )
1686
-
1687
- // ── Draw ──────────────────────────────────────────────────────────────
1688
-
1689
- override fun onDraw(canvas: Canvas) {
1690
- for (ann in annotationMap[pageIndex] ?: emptyList()) drawAnn(canvas, ann)
1691
- drawTool?.let { drawActiveStroke(canvas, it) }
1692
- }
1693
-
1694
- private fun drawAnn(canvas: Canvas, ann: PdfAnnotation) = when (ann.type) {
1695
- "pen", "highlighter", "line" -> drawInk(canvas, ann)
1696
- "text" -> drawTextAnn(canvas, ann)
1697
- "note" -> drawNoteAnn(canvas, ann)
1698
- else -> Unit
1699
- }
1700
-
1701
- private fun drawInk(canvas: Canvas, ann: PdfAnnotation) {
1702
- val s = scale(); val base = safeColor(ann.color)
1703
- inkPaint.strokeWidth = ann.strokeWidth * s
1704
- inkPaint.color = if (ann.type == "highlighter")
1705
- Color.argb(102, Color.red(base), Color.green(base), Color.blue(base)) else base
1706
- for (ap in ann.paths) {
1707
- if (ap.points.isEmpty()) continue
1708
- drawPath.reset()
1709
- ap.points.forEachIndexed { i, pt ->
1710
- val sp = toScreen(pt.x, pt.y)
1711
- if (i == 0) drawPath.moveTo(sp.x, sp.y) else drawPath.lineTo(sp.x, sp.y)
1712
- }
1713
- canvas.drawPath(drawPath, inkPaint)
1714
- }
1715
- }
1716
-
1717
- private fun drawTextAnn(canvas: Canvas, ann: PdfAnnotation) {
1718
- val text = ann.contents.trim().ifEmpty { return }
1719
- val rect = toScreenRect(ann.bounds)
1720
- val s = scale()
1721
- textPaint.textSize = ann.fontSize * s
1722
- textPaint.color = safeColor(ann.color)
1723
- textPaint.typeface = when {
1724
- ann.bold && ann.italic -> Typeface.create(Typeface.DEFAULT, Typeface.BOLD_ITALIC)
1725
- ann.bold -> Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
1726
- ann.italic -> Typeface.create(Typeface.DEFAULT, Typeface.ITALIC)
1727
- else -> Typeface.DEFAULT
1728
- }
1729
- // No background — fully transparent
1730
- val maxW = (rect.width() - 8f).toInt().coerceAtLeast(1)
1731
- canvas.save()
1732
- canvas.translate(rect.left + 4f, rect.top + 4f)
1733
- StaticLayout.Builder.obtain(text, 0, text.length, textPaint, maxW)
1734
- .setAlignment(Layout.Alignment.ALIGN_NORMAL).build().draw(canvas)
1735
- canvas.restore()
1736
- }
1737
-
1738
- private fun drawNoteAnn(canvas: Canvas, ann: PdfAnnotation) {
1739
- val rect = toScreenRect(ann.bounds)
1740
- bgFill.color = try { Color.parseColor(ann.color) } catch (_: Exception) { Color.YELLOW }
1741
- canvas.drawRect(rect, bgFill); canvas.drawRect(rect, bgStroke)
1742
- textPaint.textSize = rect.height() * 0.55f; textPaint.color = Color.DKGRAY
1743
- textPaint.textAlign = Paint.Align.CENTER; textPaint.typeface = Typeface.DEFAULT
1744
- canvas.drawText("✎", rect.centerX(), rect.centerY() + textPaint.textSize * 0.35f, textPaint)
1745
- textPaint.textAlign = Paint.Align.LEFT
1746
- }
1747
-
1748
- private fun drawActiveStroke(canvas: Canvas, tool: String) {
1749
- if (drawPoints.isEmpty()) return
1750
- val s = scale(); val base = safeColor(strokeColorVal)
1751
- inkPaint.strokeWidth = strokeWidthVal * s
1752
- inkPaint.color = if (tool == "highlighter")
1753
- Color.argb(102, Color.red(base), Color.green(base), Color.blue(base)) else base
1754
- drawPath.reset()
1755
- drawPoints.forEachIndexed { i, pt ->
1756
- val sp = toScreen(pt.x, pt.y)
1757
- if (i == 0) drawPath.moveTo(sp.x, sp.y) else drawPath.lineTo(sp.x, sp.y)
1758
- }
1759
- canvas.drawPath(drawPath, inkPaint)
1760
- }
1761
-
1762
- private fun safeColor(hex: String): Int =
1763
- try { Color.parseColor(hex) } catch (_: Exception) { Color.RED }
1764
- }
1765
- }
1
+ package expo.modules.pdfreader
2
+
3
+ import android.content.Context
4
+ import android.graphics.*
5
+ import android.graphics.pdf.PdfRenderer
6
+ import android.os.ParcelFileDescriptor
7
+ import android.text.Layout
8
+ import android.text.StaticLayout
9
+ import android.text.TextPaint
10
+ import android.util.Log
11
+ import android.view.*
12
+ import android.widget.*
13
+ import androidx.recyclerview.widget.LinearLayoutManager
14
+ import androidx.recyclerview.widget.PagerSnapHelper
15
+ import androidx.recyclerview.widget.RecyclerView
16
+ import expo.modules.kotlin.AppContext
17
+ import expo.modules.kotlin.viewevent.EventDispatcher
18
+ import expo.modules.kotlin.views.ExpoView
19
+ import kotlinx.coroutines.*
20
+ import java.io.File
21
+ import java.io.FileOutputStream
22
+ import java.net.URL
23
+
24
+ // ─────────────────────────────────────────────────────────────────────────────
25
+ // Data classes
26
+ // ─────────────────────────────────────────────────────────────────────────────
27
+
28
+ data class AnnPath(val points: MutableList<PointF> = mutableListOf())
29
+
30
+ /**
31
+ * Bounds stored in PDF page coordinate space:
32
+ * origin = bottom-left of the page, y-axis points UP.
33
+ *
34
+ * RectF.left = left edge (PDF x)
35
+ * RectF.top = lower y in PDF space (screen-bottom side, smaller PDF-y value)
36
+ * RectF.right = right edge (PDF x)
37
+ * RectF.bottom = upper y in PDF space (screen-top side, larger PDF-y value)
38
+ *
39
+ * Matches iOS PDFKit: CGRect(x, y=lowerPdfY, width, height).
40
+ */
41
+ data class PdfAnnotation(
42
+ val type: String, // "pen" | "highlighter" | "line" | "text" | "note"
43
+ val pageIndex: Int,
44
+ var bounds: RectF,
45
+ val paths: MutableList<AnnPath> = mutableListOf(),
46
+ var color: String = "#FF0000",
47
+ var strokeWidth: Float = 2f,
48
+ var contents: String = "",
49
+ var fontSize: Float = 14f,
50
+ var bold: Boolean = false,
51
+ var italic: Boolean = false
52
+ )
53
+
54
+ data class UndoEntry(val annotation: PdfAnnotation)
55
+
56
+ // ─────────────────────────────────────────────────────────────────────────────
57
+ // Main view
58
+ // ─────────────────────────────────────────────────────────────────────────────
59
+
60
+ class ExpoPdfReaderView(
61
+ context: Context,
62
+ appContext: AppContext
63
+ ) : ExpoView(context, appContext) {
64
+
65
+ // ── Expo Events (ViewEventDelegate — Expo Modules-ын зөв механизм) ────────
66
+ val onReady by EventDispatcher()
67
+ val onPageChange by EventDispatcher()
68
+ val onAnnotationChange by EventDispatcher()
69
+ val onUndoRedoStateChange by EventDispatcher()
70
+ val onNotePress by EventDispatcher()
71
+ val onTextPress by EventDispatcher()
72
+
73
+ // ── Coroutines ───────────────────────────────────────────────────────────
74
+ private val pdfDispatcher = Dispatchers.IO.limitedParallelism(1)
75
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
76
+ private var renderJob: Job? = null
77
+
78
+ // ── PDF state ────────────────────────────────────────────────────────────
79
+ private var fileDescriptor: ParcelFileDescriptor? = null
80
+ private var renderer: PdfRenderer? = null
81
+ private var pagePdfW = intArrayOf() // PDF points per page
82
+ private var pagePdfH = intArrayOf()
83
+ var totalPages = 0
84
+ private set
85
+
86
+ // ── Display state ────────────────────────────────────────────────────────
87
+ private var displayMode = "continuous"
88
+ private var pendingUrl: String? = null
89
+ private var isLayoutReady = false
90
+ var currentPageIndex = 0
91
+ private set
92
+
93
+ // ── Lazy / chunked rendering ──────────────────────────────────────────────
94
+ private var renderedUpTo = -1 // last rendered page index (absolute)
95
+ private var windowStart = 0 // pageEntries[0]-н absolute page index
96
+ private var chunkLoading = false // chunk дуусах хүртэл дахин trigger хийхгүй
97
+ private var currentViewWidth = 0 // chunk load-д дахин хэрэглэнэ
98
+ private var loadingFooter: View? = null
99
+
100
+ companion object {
101
+ private const val PAGE_CHUNK = 10 // нэг удаа render хийх хуудасны тоо
102
+ private const val MAX_RENDERED = PAGE_CHUNK * 2 // санах ойд байлгах дээд хязгаар (20 хуудас)
103
+ }
104
+
105
+ // ── Annotation state ─────────────────────────────────────────────────────
106
+ val annotationMap = mutableMapOf<Int, MutableList<PdfAnnotation>>()
107
+ var activeTool: String? = null
108
+ var strokeColorVal = "#FF0000"
109
+ var strokeWidthVal = 2f
110
+ var textContentVal = ""
111
+ var textColorVal = "#000000"
112
+ var textFontSizeVal = 14f
113
+ var textBoldVal = false
114
+ var textItalicVal = false
115
+ var noteColorVal = "#FFFF00"
116
+
117
+ val undoStack = ArrayDeque<UndoEntry>()
118
+ val redoStack = ArrayDeque<UndoEntry>()
119
+ private var appliedFingerprint: String? = null
120
+
121
+ // ── Page view entries (continuous/twoUp modes) ───────────────────────────
122
+ // frame: container-аас устгахад шууд reference, bmpHeight: layout болохоос өмнө scroll тооцооны тулд
123
+ data class PageEntry(val frame: FrameLayout, val canvasView: AnnotationCanvasView, val bmpHeight: Int)
124
+ val pageEntries = mutableListOf<PageEntry>()
125
+
126
+ // ── Single-page pager (single mode) ──────────────────────────────────────
127
+ private lateinit var pager: RecyclerView
128
+ private var singleAdapter: SinglePageAdapter? = null
129
+ private val singlePageCanvases = mutableMapOf<Int, AnnotationCanvasView>()
130
+
131
+ // ── Zoom ─────────────────────────────────────────────────────────────────
132
+ private var minZoom = 1.0f
133
+ private var maxZoom = 5.0f
134
+ private var currentZoom = 1.0f
135
+
136
+ /**
137
+ * continuous / twoup / twoupcontinuous — pinch + zoom үед зөвхөн pan (босоо scroll унтрах).
138
+ * Pinch-ийг [dispatchTouchEvent]-ийн эхэнд дуудаж хүү canvas POINTER_DOWN-д баригдахаас өмнө 2 хуруу хүлээнэ.
139
+ */
140
+ private inner class ZoomableScrollView(ctx: Context) : ScrollView(ctx) {
141
+ /** ScrollView-д [isScrollEnabled] байхгүй тул: false үед босоо scroll (super) ажиллахгүй. */
142
+ var allowVerticalScrollFromTouch = true
143
+
144
+ private val pinch = ScaleGestureDetector(
145
+ ctx,
146
+ object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
147
+ override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
148
+ if (activeTool != null) return false
149
+ parent?.requestDisallowInterceptTouchEvent(true)
150
+ return true
151
+ }
152
+
153
+ override fun onScale(detector: ScaleGestureDetector): Boolean {
154
+ if (activeTool != null) return false
155
+ applyZoom((currentZoom * detector.scaleFactor).coerceIn(minZoom, maxZoom))
156
+ return true
157
+ }
158
+
159
+ override fun onScaleEnd(detector: ScaleGestureDetector) {
160
+ parent?.requestDisallowInterceptTouchEvent(false)
161
+ }
162
+ }
163
+ )
164
+ private var panLastX = 0f
165
+ private var panLastY = 0f
166
+
167
+ override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
168
+ if (activeTool != null) return super.onInterceptTouchEvent(ev)
169
+ // 2+ хуруу эсвэл pinch эхэлсэн үед ScrollView босоо scroll intercept хийхгүй (scroll + zoom өрсөлдөнө)
170
+ if (!allowVerticalScrollFromTouch) return false
171
+ if (ev.pointerCount > 1 || pinch.isInProgress) return false
172
+ return super.onInterceptTouchEvent(ev)
173
+ }
174
+
175
+ override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
176
+ if (activeTool == null) {
177
+ pinch.onTouchEvent(ev)
178
+ if (ev.actionMasked == MotionEvent.ACTION_POINTER_DOWN) {
179
+ stopNestedScroll()
180
+ }
181
+ if (pinch.isInProgress || ev.pointerCount > 1) {
182
+ parent?.requestDisallowInterceptTouchEvent(true)
183
+ }
184
+ }
185
+ return super.dispatchTouchEvent(ev)
186
+ }
187
+
188
+ override fun onTouchEvent(ev: MotionEvent): Boolean {
189
+ if (activeTool != null) return super.onTouchEvent(ev)
190
+ if (pinch.isInProgress || ev.pointerCount > 1) {
191
+ return true
192
+ }
193
+ if (currentZoom > 1.02f) {
194
+ when (ev.actionMasked) {
195
+ MotionEvent.ACTION_DOWN -> {
196
+ panLastX = ev.x
197
+ panLastY = ev.y
198
+ }
199
+ MotionEvent.ACTION_MOVE -> {
200
+ val dx = ev.x - panLastX
201
+ val dy = ev.y - panLastY
202
+ panLastX = ev.x
203
+ panLastY = ev.y
204
+ applyContainerPanDelta(dx, dy)
205
+ }
206
+ }
207
+ return true
208
+ }
209
+ if (!allowVerticalScrollFromTouch) return true
210
+ return super.onTouchEvent(ev)
211
+ }
212
+ }
213
+
214
+ // ── UI ───────────────────────────────────────────────────────────────────
215
+ private val scrollView = ZoomableScrollView(context).apply {
216
+ layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
217
+ isFillViewport = true
218
+ }
219
+ private val container = LinearLayout(context).apply {
220
+ orientation = LinearLayout.VERTICAL
221
+ layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
222
+ setBackgroundColor(Color.parseColor("#E8E8E8"))
223
+ }
224
+
225
+ init {
226
+ setBackgroundColor(Color.parseColor("#E8E8E8"))
227
+ scrollView.addView(container)
228
+ addView(scrollView)
229
+ scrollView.setOnScrollChangeListener { _, _, scrollY, _, _ ->
230
+ detectCurrentPage(scrollY)
231
+ maybeTriggerChunkLoad(scrollY)
232
+ }
233
+
234
+ // Single-page pager: ZoomRecyclerView + PagerSnapHelper (vertical)
235
+ pager = ZoomRecyclerView(context).apply {
236
+ layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
237
+ layoutManager = LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
238
+ PagerSnapHelper().attachToRecyclerView(this)
239
+ setBackgroundColor(Color.parseColor("#E8E8E8"))
240
+ overScrollMode = View.OVER_SCROLL_NEVER
241
+ isNestedScrollingEnabled = false
242
+ visibility = View.GONE
243
+ addOnScrollListener(object : RecyclerView.OnScrollListener() {
244
+ override fun onScrollStateChanged(rv: RecyclerView, newState: Int) {
245
+ if (newState == RecyclerView.SCROLL_STATE_IDLE) {
246
+ val lm = rv.layoutManager as? LinearLayoutManager ?: return
247
+ val pos = lm.findFirstCompletelyVisibleItemPosition()
248
+ .takeIf { it >= 0 } ?: return
249
+ if (pos != currentPageIndex) {
250
+ currentPageIndex = pos
251
+ // Хуудас солигдоход zoom-г reset хийнэ
252
+ (pager as? ZoomRecyclerView)?.resetZoom()
253
+ onPageChange(mapOf("currentPage" to pos, "totalPage" to totalPages))
254
+ }
255
+ }
256
+ }
257
+ })
258
+ }
259
+ addView(pager)
260
+ }
261
+
262
+ override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
263
+ super.onLayout(changed, l, t, r, b)
264
+ if (width > 0 && !isLayoutReady) {
265
+ isLayoutReady = true
266
+ pendingUrl?.let {
267
+ pendingUrl = null
268
+ startLoad(it)
269
+ }
270
+ }
271
+ // Note: width өөрчлөгдөхөд triggerRender дуудахгүй
272
+ // forced layout (forceLayoutScrollView) нь pages-г шууд харагдуулдаг
273
+ }
274
+
275
+ override fun onDetachedFromWindow() {
276
+ super.onDetachedFromWindow()
277
+ renderJob?.cancel()
278
+ scope.cancel()
279
+ safeCloseRenderer()
280
+ }
281
+
282
+ // ─────────────────────────────────────────────────────────────────────────
283
+ // Props
284
+ // ─────────────────────────────────────────────────────────────────────────
285
+
286
+ fun setUrl(url: String?) {
287
+ if (url.isNullOrBlank()) return
288
+ pendingUrl = url
289
+ if (isLayoutReady) {
290
+ startLoad(url)
291
+ } else {
292
+ // Layout ирээгүй байж болно — дараагийн frame-д шалгана
293
+ post { tryStartLoad() }
294
+ }
295
+ }
296
+
297
+ private fun tryStartLoad() {
298
+ val url = pendingUrl ?: return // onLayout аль хэдийн startLoad дуудсан бол pendingUrl = null → exit
299
+ if (width > 0) {
300
+ isLayoutReady = true
301
+ pendingUrl = null // давхар дуудлагаас сэргийлнэ
302
+ startLoad(url)
303
+ } else {
304
+ scope.launch {
305
+ delay(50)
306
+ tryStartLoad()
307
+ }
308
+ }
309
+ }
310
+
311
+ fun setDisplayMode(mode: String?) {
312
+ val newMode = mode?.lowercase() ?: "continuous"
313
+ if (newMode == displayMode) return
314
+ displayMode = newMode
315
+ if (renderer != null && isLayoutReady) triggerRender()
316
+ }
317
+
318
+ fun setInitialPage(page: Int) {
319
+ if (page < 0 || page >= totalPages) return
320
+ currentPageIndex = page
321
+ if (displayMode == "single") {
322
+ post { scrollToPage(page, smooth = false) }
323
+ return
324
+ }
325
+ val windowEnd = windowStart + pageEntries.size - 1
326
+ if (page in windowStart..windowEnd) {
327
+ // Хуудас render хийгдсэн window дотор байна → зүгээр scroll хийнэ
328
+ post { scrollToPage(page, smooth = false) }
329
+ } else {
330
+ // Хуудас render хийгдээгүй window-г тухайн хуудас руу шилжүүлнэ
331
+ jumpToPage(page)
332
+ }
333
+ }
334
+
335
+ fun setMinZoom(v: Double) { minZoom = v.toFloat() }
336
+ fun setMaxZoom(v: Double) { maxZoom = v.toFloat() }
337
+
338
+ fun setTool(tool: String?) {
339
+ activeTool = tool
340
+ if (displayMode == "single") {
341
+ pager.requestDisallowInterceptTouchEvent(tool != null)
342
+ } else {
343
+ scrollView.requestDisallowInterceptTouchEvent(tool != null)
344
+ }
345
+ }
346
+
347
+ fun setStrokeColor(hex: String) { strokeColorVal = hex }
348
+ fun setStrokeWidth(w: Double) { strokeWidthVal = w.toFloat() }
349
+ fun setTextContent(t: String) { textContentVal = t }
350
+ fun setTextColor(hex: String) { textColorVal = hex }
351
+ fun setTextFontSize(size: Double) { textFontSizeVal = size.toFloat() }
352
+ fun setTextBold(v: Boolean) { textBoldVal = v }
353
+ fun setTextItalic(v: Boolean) { textItalicVal = v }
354
+ fun setNoteColor(hex: String) { noteColorVal = hex }
355
+
356
+ // ─────────────────────────────────────────────────────────────────────────
357
+ // Commands
358
+ // ─────────────────────────────────────────────────────────────────────────
359
+
360
+ fun undo() {
361
+ val e = undoStack.removeLastOrNull() ?: return
362
+ redoStack.addLast(e)
363
+ annotationMap[e.annotation.pageIndex]?.remove(e.annotation)
364
+ invalidateCanvas(e.annotation.pageIndex)
365
+ notifyAnnotationChange()
366
+ notifyUndoRedoState()
367
+ }
368
+
369
+ fun redo() {
370
+ val e = redoStack.removeLastOrNull() ?: return
371
+ undoStack.addLast(e)
372
+ annotationMap.getOrPut(e.annotation.pageIndex) { mutableListOf() }.add(e.annotation)
373
+ invalidateCanvas(e.annotation.pageIndex)
374
+ notifyAnnotationChange()
375
+ notifyUndoRedoState()
376
+ }
377
+
378
+ fun setAnnotations(data: List<Map<String, Any?>>) {
379
+ val fp = buildFingerprint(data)
380
+ if (fp == appliedFingerprint) return
381
+ appliedFingerprint = fp
382
+ annotationMap.clear()
383
+
384
+ for (item in data) {
385
+ val pageIndex = (item["page"] as? Number)?.toInt() ?: continue
386
+ val typeStr = item["type"] as? String ?: continue
387
+ val b = item["bounds"] as? Map<*, *> ?: continue
388
+ val bX = (b["x"] as? Number)?.toFloat() ?: 0f
389
+ val bY = (b["y"] as? Number)?.toFloat() ?: 0f
390
+ val bW = (b["width"] as? Number)?.toFloat() ?: 0f
391
+ val bH = (b["height"] as? Number)?.toFloat() ?: 0f
392
+
393
+ val bounds = RectF(bX, bY, bX + bW, bY + bH)
394
+ val ann = PdfAnnotation(
395
+ type = typeStr, pageIndex = pageIndex, bounds = bounds,
396
+ color = item["color"] as? String ?: strokeColorVal,
397
+ strokeWidth = (item["strokeWidth"] as? Number)?.toFloat() ?: strokeWidthVal,
398
+ contents = item["contents"] as? String ?: "",
399
+ fontSize = (item["fontSize"] as? Number)?.toFloat() ?: textFontSizeVal,
400
+ bold = item["bold"] as? Boolean ?: false,
401
+ italic = item["italic"] as? Boolean ?: false
402
+ )
403
+
404
+ (item["paths"] as? List<*>)?.forEach { pathData ->
405
+ val pts = (pathData as? List<*>)?.mapNotNull { pt ->
406
+ val m = pt as? Map<*, *> ?: return@mapNotNull null
407
+ PointF(
408
+ (m["x"] as? Number)?.toFloat() ?: 0f,
409
+ (m["y"] as? Number)?.toFloat() ?: 0f
410
+ )
411
+ }
412
+ if (!pts.isNullOrEmpty()) ann.paths.add(AnnPath(pts.toMutableList()))
413
+ }
414
+
415
+ annotationMap.getOrPut(pageIndex) { mutableListOf() }.add(ann)
416
+ }
417
+ if (displayMode == "single") {
418
+ singlePageCanvases.values.forEach { it.invalidate() }
419
+ } else {
420
+ pageEntries.forEach { it.canvasView.invalidate() }
421
+ }
422
+ }
423
+
424
+ fun updateText(pageIndex: Int, index: Int, contents: String) {
425
+ val anns = annotationMap[pageIndex]?.filter { it.type == "text" } ?: return
426
+ if (index >= anns.size) return
427
+ val ann = anns[index]
428
+ ann.contents = contents.ifBlank { " " }
429
+ autoSizeTextAnnotation(ann)
430
+ invalidateCanvas(pageIndex)
431
+ notifyAnnotationChange()
432
+ }
433
+
434
+ /**
435
+ * Recalculate the annotation's bounds so the text fits without clipping.
436
+ * Width is capped at 70% of the view width; height grows with line count.
437
+ */
438
+ private fun autoSizeTextAnnotation(ann: PdfAnnotation) {
439
+ val text = ann.contents.trim().ifEmpty { return }
440
+ val pageW = pagePdfW.getOrElse(ann.pageIndex) { 0 }
441
+ if (pageW <= 0 || width <= 0) return
442
+ val s = width.toFloat() / pageW.toFloat()
443
+ val tp = android.text.TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
444
+ textSize = ann.fontSize * s
445
+ typeface = when {
446
+ ann.bold && ann.italic -> Typeface.create(Typeface.DEFAULT, Typeface.BOLD_ITALIC)
447
+ ann.bold -> Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
448
+ ann.italic -> Typeface.create(Typeface.DEFAULT, Typeface.ITALIC)
449
+ else -> Typeface.DEFAULT
450
+ }
451
+ }
452
+ val pad = 16f // screen px total padding (8px each side)
453
+ val maxW = (width * 0.7f - pad).toInt().coerceAtLeast(80)
454
+ val layout = StaticLayout.Builder
455
+ .obtain(text, 0, text.length, tp, maxW)
456
+ .setAlignment(android.text.Layout.Alignment.ALIGN_NORMAL)
457
+ .build()
458
+ val newW = (layout.width + pad) / s
459
+ val newH = (layout.height + pad) / s
460
+ // Keep top-left anchor fixed
461
+ ann.bounds = RectF(ann.bounds.left, ann.bounds.top, ann.bounds.left + newW, ann.bounds.top + newH)
462
+ }
463
+
464
+ fun updateNote(pageIndex: Int, index: Int, contents: String) {
465
+ val anns = annotationMap[pageIndex]?.filter { it.type == "note" } ?: return
466
+ if (index >= anns.size) return
467
+ anns[index].contents = contents.ifBlank { " " }
468
+ invalidateCanvas(pageIndex)
469
+ notifyAnnotationChange()
470
+ }
471
+
472
+ // ─────────────────────────────────────────────────────────────────────────
473
+ // Internal helpers (used by inner AnnotationCanvasView)
474
+ // ─────────────────────────────────────────────────────────────────────────
475
+
476
+ fun addAnnotationAndCommit(ann: PdfAnnotation) {
477
+ annotationMap.getOrPut(ann.pageIndex) { mutableListOf() }.add(ann)
478
+ undoStack.addLast(UndoEntry(ann))
479
+ redoStack.clear()
480
+ invalidateCanvas(ann.pageIndex)
481
+ notifyAnnotationChange()
482
+ notifyUndoRedoState()
483
+ }
484
+
485
+ fun invalidateCanvas(pageIndex: Int) {
486
+ if (displayMode == "single") {
487
+ singlePageCanvases[pageIndex]?.invalidate()
488
+ } else {
489
+ // continuous mode: pageEntries[0] = page windowStart → relative index
490
+ pageEntries.getOrNull(pageIndex - windowStart)?.canvasView?.invalidate()
491
+ }
492
+ }
493
+
494
+ fun notifyAnnotationChange() {
495
+ val all = mutableListOf<Map<String, Any>>()
496
+ for ((pageIndex, anns) in annotationMap) {
497
+ for (ann in anns) {
498
+ val m = mutableMapOf<String, Any>(
499
+ "type" to ann.type,
500
+ "page" to pageIndex,
501
+ "bounds" to mapOf(
502
+ "x" to ann.bounds.left.toDouble(),
503
+ "y" to ann.bounds.top.toDouble(),
504
+ "width" to ann.bounds.width().toDouble(),
505
+ "height" to ann.bounds.height().toDouble()
506
+ ),
507
+ "color" to ann.color,
508
+ "strokeWidth" to ann.strokeWidth.toDouble(),
509
+ "contents" to ann.contents
510
+ )
511
+ if (ann.type == "text") {
512
+ m["fontSize"] = ann.fontSize.toDouble()
513
+ m["bold"] = ann.bold
514
+ m["italic"] = ann.italic
515
+ }
516
+ if (ann.paths.isNotEmpty()) {
517
+ m["paths"] = ann.paths.map { p ->
518
+ p.points.map { pt ->
519
+ mapOf("x" to pt.x.toDouble(), "y" to pt.y.toDouble())
520
+ }
521
+ }
522
+ }
523
+ all.add(m)
524
+ }
525
+ }
526
+ onAnnotationChange(mapOf("annotations" to all))
527
+ }
528
+
529
+ fun notifyUndoRedoState() {
530
+ onUndoRedoStateChange(
531
+ mapOf("canUndo" to undoStack.isNotEmpty(), "canRedo" to redoStack.isNotEmpty())
532
+ )
533
+ }
534
+
535
+ // ─────────────────────────────────────────────────────────────────────────
536
+ // Zoom
537
+ // ─────────────────────────────────────────────────────────────────────────
538
+
539
+ private fun applyZoom(newZoom: Float) {
540
+ currentZoom = newZoom.coerceIn(minZoom, maxZoom)
541
+ container.pivotX = 0f
542
+ container.pivotY = 0f
543
+ container.scaleX = currentZoom
544
+ container.scaleY = currentZoom
545
+ if (displayMode != "single") {
546
+ if (currentZoom <= 1.02f) {
547
+ scrollView.allowVerticalScrollFromTouch = true
548
+ container.translationX = 0f
549
+ container.translationY = 0f
550
+ } else {
551
+ scrollView.allowVerticalScrollFromTouch = false
552
+ }
553
+ }
554
+ }
555
+
556
+ /** Zoom > 1 үед container-ийг translation-оор pan (хуудас хоорондын scroll унтраасан) */
557
+ private fun applyContainerPanDelta(dx: Float, dy: Float) {
558
+ val ch = container.height.toFloat()
559
+ val cw = container.width.toFloat()
560
+ if (ch <= 0f || cw <= 0f) return
561
+ val scaledH = ch * currentZoom
562
+ val scaledW = cw * currentZoom
563
+ val vw = scrollView.width.toFloat()
564
+ val vh = scrollView.height.toFloat()
565
+ var tx = container.translationX + dx
566
+ var ty = container.translationY + dy
567
+ if (scaledW <= vw) tx = 0f else tx = tx.coerceIn(vw - scaledW, 0f)
568
+ if (scaledH <= vh) ty = 0f else ty = ty.coerceIn(vh - scaledH, 0f)
569
+ container.translationX = tx
570
+ container.translationY = ty
571
+ }
572
+
573
+ // ─────────────────────────────────────────────────────────────────────────
574
+ // Scroll / page detection
575
+ // ─────────────────────────────────────────────────────────────────────────
576
+
577
+ /** twoupcontinuous: нэг мөр = хос entry (хуудас дараалсан), эсвэл эцсийн ганц entry. */
578
+ private fun twoupStrideAt(entryIdx: Int): Int {
579
+ if (displayMode != "twoupcontinuous") return 1
580
+ if (entryIdx >= pageEntries.size) return 0
581
+ if (entryIdx + 1 < pageEntries.size) {
582
+ val a = pageEntries[entryIdx].canvasView.pageIndex
583
+ val b = pageEntries[entryIdx + 1].canvasView.pageIndex
584
+ if (b == a + 1) return 2
585
+ }
586
+ return 1
587
+ }
588
+
589
+ /** scrollView доторх pageIndex хуудасны мөрийн дээд ирмэгийн Y (continuous / twoupcontinuous). */
590
+ private fun scrollYForScrollModePage(pageIndex: Int): Int {
591
+ if (pageEntries.isEmpty()) return 0
592
+ val lastAbsPage = windowStart + pageEntries.size - 1
593
+ val clamped = pageIndex.coerceIn(windowStart, lastAbsPage.coerceAtLeast(windowStart))
594
+ if (displayMode != "twoupcontinuous") {
595
+ val rel = (clamped - windowStart).coerceIn(0, maxOf(0, pageEntries.size - 1))
596
+ var y = 0
597
+ for (i in 0 until rel) {
598
+ y += (pageEntries[i].bmpHeight.takeIf { it > 0 } ?: 0) + 12
599
+ }
600
+ return y
601
+ }
602
+ var y = 0
603
+ var idx = 0
604
+ while (idx < pageEntries.size) {
605
+ val stride = twoupStrideAt(idx)
606
+ val rowH = pageEntries[idx].bmpHeight.takeIf { it > 0 } ?: 0
607
+ val p0 = pageEntries[idx].canvasView.pageIndex
608
+ val p1 = if (stride == 2) pageEntries[idx + 1].canvasView.pageIndex else p0
609
+ if (clamped in p0..p1) return y
610
+ y += rowH + 12
611
+ idx += stride
612
+ }
613
+ return y
614
+ }
615
+
616
+ private fun detectCurrentPage(scrollY: Int) {
617
+ if (pageEntries.isEmpty()) return
618
+ val viewH = scrollView.height.takeIf { it > 0 } ?: height
619
+ val centerY = scrollY + viewH / 2
620
+ if (displayMode == "twoupcontinuous") {
621
+ var accY = 0
622
+ var idx = 0
623
+ while (idx < pageEntries.size) {
624
+ val stride = twoupStrideAt(idx)
625
+ val rowH = pageEntries[idx].bmpHeight.takeIf { it > 0 } ?: 0
626
+ val nextY = accY + rowH + 12
627
+ val p0 = pageEntries[idx].canvasView.pageIndex
628
+ val p1 = if (stride == 2) pageEntries[idx + 1].canvasView.pageIndex else p0
629
+ if (centerY <= nextY || idx + stride >= pageEntries.size) {
630
+ val vx = scrollView.width.takeIf { it > 0 } ?: width
631
+ val absPage = if (stride == 2 && p1 > p0 && vx > 0) {
632
+ val midPix = vx / 2
633
+ val centerX = scrollView.scrollX + midPix
634
+ if (centerX < midPix) p0 else p1
635
+ } else p0
636
+ if (currentPageIndex != absPage) {
637
+ currentPageIndex = absPage
638
+ onPageChange(mapOf("currentPage" to absPage, "totalPage" to totalPages))
639
+ }
640
+ break
641
+ }
642
+ accY = nextY
643
+ idx += stride
644
+ }
645
+ return
646
+ }
647
+ // continuous — entry бүр нэг босоо мөр
648
+ var accY = 0
649
+ for (i in pageEntries.indices) {
650
+ val h = pageEntries[i].bmpHeight.takeIf { it > 0 } ?: continue
651
+ accY += h + 12
652
+ if (centerY <= accY || i == pageEntries.size - 1) {
653
+ val absolutePage = windowStart + i
654
+ if (currentPageIndex != absolutePage) {
655
+ currentPageIndex = absolutePage
656
+ onPageChange(mapOf("currentPage" to absolutePage, "totalPage" to totalPages))
657
+ }
658
+ break
659
+ }
660
+ }
661
+ }
662
+
663
+ private fun scrollToPage(pageIndex: Int, smooth: Boolean = true) {
664
+ if (displayMode == "single") {
665
+ if (smooth) pager.smoothScrollToPosition(pageIndex)
666
+ else pager.scrollToPosition(pageIndex)
667
+ currentPageIndex = pageIndex
668
+ onPageChange(mapOf("currentPage" to pageIndex, "totalPage" to totalPages))
669
+ return
670
+ }
671
+ if (pageEntries.isEmpty()) return
672
+ val y = scrollYForScrollModePage(pageIndex)
673
+ if (smooth) scrollView.smoothScrollTo(0, y) else scrollView.scrollTo(0, y)
674
+ currentPageIndex = pageIndex
675
+ onPageChange(mapOf("currentPage" to pageIndex, "totalPage" to totalPages))
676
+ }
677
+
678
+ // ─────────────────────────────────────────────────────────────────────────
679
+ // Load & Render
680
+ // ─────────────────────────────────────────────────────────────────────────
681
+
682
+ private fun startLoad(url: String) {
683
+ Log.d("ExpoPdfReader", "startLoad: $url")
684
+ renderJob?.cancel()
685
+ renderJob = scope.launch {
686
+ try {
687
+ withContext(pdfDispatcher) { safeCloseRenderer() }
688
+ val file = withContext(pdfDispatcher) { resolveFile(url) }
689
+ withContext(pdfDispatcher) {
690
+ fileDescriptor = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
691
+ renderer = PdfRenderer(fileDescriptor!!)
692
+ val r = renderer!!
693
+ totalPages = r.pageCount
694
+ // Хэмжээг энд бүгдийг нь уншихгүй — renderPageBitmap нээх бүрт бөглөнө (анхны ачаалал хурдан).
695
+ pagePdfW = IntArray(totalPages) { 0 }
696
+ pagePdfH = IntArray(totalPages) { 0 }
697
+ }
698
+ // annotationMap is intentionally NOT cleared here.
699
+ // setAnnotations() may have already populated it before startLoad() runs
700
+ // (Expo prop ordering: url first, then initialAnnotations).
701
+ // Canvases created in renderDocument() read annotationMap on their first onDraw().
702
+ undoStack.clear(); redoStack.clear()
703
+ appliedFingerprint = null; currentPageIndex = 0
704
+ renderDocument()
705
+ // After canvases are created, re-invalidate all of them so initialAnnotations appear.
706
+ withContext(Dispatchers.Main) {
707
+ if (displayMode == "single") {
708
+ singlePageCanvases.values.forEach { it.invalidate() }
709
+ } else {
710
+ pageEntries.forEach { it.canvasView.invalidate() }
711
+ }
712
+ }
713
+ } catch (e: CancellationException) { throw e }
714
+ catch (e: Exception) { Log.e("ExpoPdfReader", "Load error", e) }
715
+ }
716
+ }
717
+
718
+ private fun triggerRender() {
719
+ renderJob?.cancel()
720
+ renderJob = scope.launch {
721
+ try { renderDocument() }
722
+ catch (e: CancellationException) { throw e }
723
+ catch (e: Exception) { Log.e("ExpoPdfReader", "Render error", e) }
724
+ }
725
+ }
726
+
727
+ private suspend fun renderDocument() {
728
+ val pdf = renderer ?: return
729
+ val pageCount = withContext(pdfDispatcher) { pdf.pageCount }
730
+ if (pageCount <= 0) return
731
+ val viewWidth = width.takeIf { it > 0 } ?: resources.displayMetrics.widthPixels
732
+ if (viewWidth <= 0) return
733
+
734
+ // Chunk state reset
735
+ currentViewWidth = viewWidth
736
+ renderedUpTo = -1
737
+ windowStart = 0
738
+ chunkLoading = false
739
+
740
+ withContext(Dispatchers.Main) {
741
+ currentZoom = 1f
742
+ container.scaleX = 1f
743
+ container.scaleY = 1f
744
+ container.translationX = 0f
745
+ container.translationY = 0f
746
+ scrollView.allowVerticalScrollFromTouch = true
747
+ container.removeAllViews()
748
+ pageEntries.clear()
749
+ loadingFooter = null
750
+ }
751
+
752
+ when (displayMode) {
753
+ "single" -> renderSingleMode()
754
+ "twoup" -> renderAllPages(pdf, pageCount, viewWidth)
755
+ else -> renderScrollModeAround(
756
+ pdf,
757
+ pageCount,
758
+ viewWidth,
759
+ currentPageIndex.coerceIn(0, pageCount - 1)
760
+ )
761
+ }
762
+
763
+ // onReady: Main thread дээр шууд дуудна
764
+ withContext(Dispatchers.Main) {
765
+ val density = resources.displayMetrics.density
766
+ Log.d("ExpoPdfReader", "onReady firing: totalPages=$pageCount")
767
+ onReady(
768
+ mapOf(
769
+ "totalPages" to pageCount,
770
+ "width" to (this@ExpoPdfReaderView.width / density).toDouble(),
771
+ "height" to (this@ExpoPdfReaderView.height / density).toDouble()
772
+ )
773
+ )
774
+ }
775
+ }
776
+
777
+ // single mode: RecyclerView + PagerSnapHelper нэг хуудас бүрэн харагдана
778
+ private suspend fun renderSingleMode() {
779
+ withContext(Dispatchers.Main) {
780
+ singlePageCanvases.clear()
781
+ scrollView.visibility = View.GONE
782
+ pager.visibility = View.VISIBLE
783
+ singleAdapter = SinglePageAdapter()
784
+ pager.adapter = singleAdapter
785
+ pager.scrollToPosition(currentPageIndex)
786
+ // React Native Fabric blocks child requestLayout() calls, so the RecyclerView
787
+ // never binds its items until the next Fabric layout pass (e.g. thumbnail panel
788
+ // opening/closing). Force a manual measure+layout to make items bind immediately.
789
+ forcePagerLayout()
790
+ }
791
+ }
792
+
793
+ private fun forcePagerLayout() {
794
+ val w = width.takeIf { it > 0 } ?: return
795
+ val h = height.takeIf { it > 0 } ?: return
796
+ pager.measure(
797
+ MeasureSpec.makeMeasureSpec(w, MeasureSpec.EXACTLY),
798
+ MeasureSpec.makeMeasureSpec(h, MeasureSpec.EXACTLY)
799
+ )
800
+ pager.layout(0, 0, w, h)
801
+ }
802
+
803
+ // twoup — бүх хуудсыг render хийнэ
804
+ private suspend fun renderAllPages(pdf: PdfRenderer, pageCount: Int, viewWidth: Int) {
805
+ withContext(Dispatchers.Main) {
806
+ scrollView.visibility = View.VISIBLE
807
+ pager.visibility = View.GONE
808
+ }
809
+ renderTwoUpRange(pdf, pageCount, viewWidth, 0, pageCount)
810
+ withContext(Dispatchers.Main) { renderedUpTo = pageCount - 1 }
811
+ }
812
+
813
+ /**
814
+ * continuous / twoupcontinuous — [anchorPage]-ийн эргэн тойронд MAX_RENDERED хүртэлх цонх.
815
+ * Горим солиход одоогийн хуудас хадгалагдана.
816
+ */
817
+ private suspend fun renderScrollModeAround(
818
+ pdf: PdfRenderer,
819
+ pageCount: Int,
820
+ viewWidth: Int,
821
+ anchorPage: Int
822
+ ) {
823
+ withContext(Dispatchers.Main) {
824
+ scrollView.visibility = View.VISIBLE
825
+ pager.visibility = View.GONE
826
+ }
827
+ val target = anchorPage.coerceIn(0, pageCount - 1)
828
+ val newStart = maxOf(0, target - PAGE_CHUNK / 2)
829
+ val newEnd = minOf(pageCount, newStart + MAX_RENDERED)
830
+ withContext(Dispatchers.Main) {
831
+ windowStart = newStart
832
+ renderedUpTo = newStart - 1
833
+ }
834
+ if (displayMode == "twoupcontinuous") {
835
+ renderTwoUpRange(pdf, pageCount, viewWidth, newStart, newEnd)
836
+ withContext(Dispatchers.Main) {
837
+ val rawUpTo = if (newEnd % 2 == 0) newEnd - 1 else newEnd - 2
838
+ renderedUpTo = rawUpTo.coerceIn(0, pageCount - 1)
839
+ if (renderedUpTo < pageCount - 1) showLoadingFooter()
840
+ forceLayoutScrollView()
841
+ post { scrollToPage(target, smooth = false) }
842
+ }
843
+ } else {
844
+ for (i in newStart until newEnd) {
845
+ val bmp = renderPageBitmap(pdf, i, viewWidth)
846
+ val idx = i
847
+ withContext(Dispatchers.Main) {
848
+ addPageRow(
849
+ bmp, idx,
850
+ pagePdfW.getOrElse(idx) { 1 }.toFloat(),
851
+ pagePdfH.getOrElse(idx) { 1 }.toFloat()
852
+ )
853
+ }
854
+ }
855
+ withContext(Dispatchers.Main) {
856
+ renderedUpTo = (newEnd - 1).coerceIn(0, pageCount - 1)
857
+ if (newEnd < pageCount) showLoadingFooter()
858
+ forceLayoutScrollView()
859
+ post { scrollToPage(target, smooth = false) }
860
+ }
861
+ }
862
+ }
863
+
864
+ // twoUp row-г render хийх helper (start..end page range, pairs)
865
+ private suspend fun renderTwoUpRange(
866
+ pdf: PdfRenderer, pageCount: Int, viewWidth: Int, startPage: Int, endPage: Int
867
+ ) {
868
+ val halfWidth = viewWidth / 2
869
+ var i = startPage
870
+ while (i < endPage) {
871
+ val leftBmp = renderPageBitmap(pdf, i, halfWidth)
872
+ val rightBmp = if (i + 1 < minOf(endPage, pageCount))
873
+ renderPageBitmap(pdf, i + 1, halfWidth) else null
874
+ val li = i; val ri = i + 1
875
+ withContext(Dispatchers.Main) {
876
+ // twoUp row-ын өндрийг зүүн хуудасны bitmap-аас авна (хоёр хуудас ижил scale-тай)
877
+ val rowH = leftBmp.height
878
+ val row = LinearLayout(context).apply {
879
+ orientation = LinearLayout.HORIZONTAL
880
+ layoutParams = LinearLayout.LayoutParams(viewWidth, rowH)
881
+ .apply { setMargins(0, 6, 0, 6) }
882
+ }
883
+ row.addView(buildPageFrame(leftBmp, li,
884
+ pagePdfW.getOrElse(li) { 1 }.toFloat(), pagePdfH.getOrElse(li) { 1 }.toFloat(), 1f))
885
+ if (rightBmp != null) {
886
+ row.addView(buildPageFrame(rightBmp, ri,
887
+ pagePdfW.getOrElse(ri) { 1 }.toFloat(), pagePdfH.getOrElse(ri) { 1 }.toFloat(), 1f))
888
+ } else {
889
+ row.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(0, rowH, 1f) })
890
+ }
891
+ container.addView(row)
892
+ }
893
+ i += 2
894
+ }
895
+ }
896
+
897
+ // ── Lazy chunk helpers ────────────────────────────────────────────────────
898
+
899
+ private fun maybeTriggerChunkLoad(scrollY: Int) {
900
+ if (chunkLoading) return
901
+ if (displayMode !in listOf("continuous", "twoupcontinuous")) return
902
+
903
+ val visibleBottom = scrollY + scrollView.height
904
+
905
+ // Доош scroll: дараагийн chunk ачаална
906
+ if (renderedUpTo < totalPages - 1) {
907
+ val forwardTrigger = container.height - scrollView.height * 2
908
+ if (visibleBottom >= forwardTrigger) {
909
+ chunkLoading = true
910
+ scope.launch {
911
+ try { loadNextChunk() }
912
+ catch (e: CancellationException) { throw e }
913
+ catch (e: Exception) { Log.e("ExpoPdfReader", "Chunk load error", e) }
914
+ finally { chunkLoading = false }
915
+ }
916
+ return
917
+ }
918
+ }
919
+
920
+ // Дээш scroll: өмнөх chunk (continuous = нэг багана; twoupcontinuous = хос мөр prepend).
921
+ if (windowStart > 0 && scrollY <= scrollView.height * 2) {
922
+ chunkLoading = true
923
+ scope.launch {
924
+ try { loadPrevChunk() }
925
+ catch (e: CancellationException) { throw e }
926
+ catch (e: Exception) { Log.e("ExpoPdfReader", "Prev chunk load error", e) }
927
+ finally { chunkLoading = false }
928
+ }
929
+ }
930
+ }
931
+
932
+ private suspend fun loadNextChunk() {
933
+ val pdf = renderer ?: return
934
+ val start = renderedUpTo + 1
935
+ if (start >= totalPages) return
936
+ val end = minOf(start + PAGE_CHUNK, totalPages)
937
+ val viewWidth = currentViewWidth
938
+
939
+ withContext(Dispatchers.Main) { removeLoadingFooter() }
940
+
941
+ if (displayMode == "twoupcontinuous") {
942
+ renderTwoUpRange(pdf, totalPages, viewWidth, start, end)
943
+ withContext(Dispatchers.Main) {
944
+ renderedUpTo = if (end % 2 == 0) end - 1 else end - 2
945
+ if (renderedUpTo < totalPages - 1) showLoadingFooter()
946
+ forceLayoutScrollView()
947
+ }
948
+ } else {
949
+ for (i in start until end) {
950
+ val bmp = renderPageBitmap(pdf, i, viewWidth)
951
+ val idx = i
952
+ withContext(Dispatchers.Main) {
953
+ addPageRow(bmp, idx, pagePdfW.getOrElse(idx) { 1 }.toFloat(),
954
+ pagePdfH.getOrElse(idx) { 1 }.toFloat())
955
+ }
956
+ }
957
+ withContext(Dispatchers.Main) {
958
+ renderedUpTo = end - 1
959
+ // Sliding window: MAX_RENDERED-с илүү болвол дээд хэсгийг устга
960
+ val excess = pageEntries.size - MAX_RENDERED
961
+ if (excess > 0) {
962
+ val removed = pruneTopPages(excess)
963
+ scrollView.scrollBy(0, -removed)
964
+ }
965
+ if (end < totalPages) showLoadingFooter()
966
+ forceLayoutScrollView()
967
+ }
968
+ }
969
+ }
970
+
971
+ /**
972
+ * Дээд хэсгийн [count] хуудсыг устгаж, bitmap санах ойг чөлөөлнэ.
973
+ * @return устгагдсан хуудсуудын нийт өндөр (px) — scroll тохируулахад ашиглана
974
+ */
975
+ private fun pruneTopPages(count: Int): Int {
976
+ var removedHeight = 0
977
+ repeat(count) {
978
+ val entry = pageEntries.removeFirstOrNull() ?: return@repeat
979
+ removedHeight += entry.bmpHeight + 12 // +12 нь margin (дээш 6 + доош 6)
980
+ // Bitmap чөлөөлнэ
981
+ (entry.frame.getChildAt(0) as? ImageView)?.setImageBitmap(null)
982
+ container.removeView(entry.frame)
983
+ }
984
+ windowStart += count
985
+ return removedHeight
986
+ }
987
+
988
+ /**
989
+ * Өмнөх [PAGE_CHUNK] хуудсыг дээр нэмж, доод хэсгийг цэвэрлэнэ.
990
+ */
991
+ private suspend fun loadPrevChunk() {
992
+ val pdf = renderer ?: return
993
+ if (displayMode == "twoupcontinuous") {
994
+ loadPrevChunkTwoup(pdf)
995
+ return
996
+ }
997
+ val prevStart = maxOf(0, windowStart - PAGE_CHUNK)
998
+ val prevEnd = windowStart
999
+ if (prevStart >= prevEnd) return
1000
+ val viewWidth = currentViewWidth
1001
+
1002
+ data class PageBmp(val bmp: Bitmap, val idx: Int, val pdfW: Float, val pdfH: Float)
1003
+ val pages = mutableListOf<PageBmp>()
1004
+ for (i in prevStart until prevEnd) {
1005
+ val bmp = renderPageBitmap(pdf, i, viewWidth)
1006
+ pages.add(PageBmp(bmp, i,
1007
+ pagePdfW.getOrElse(i) { 1 }.toFloat(),
1008
+ pagePdfH.getOrElse(i) { 1 }.toFloat()))
1009
+ }
1010
+
1011
+ withContext(Dispatchers.Main) {
1012
+ var insertedHeight = 0
1013
+ // Reverse order-оор insert хийж эцэст нь зөв дараалал гарна
1014
+ // (бүгд index 0-д insert хийгддэг тул)
1015
+ for (page in pages.reversed()) {
1016
+ prependPageFrame(page.bmp, page.idx, page.pdfW, page.pdfH)
1017
+ insertedHeight += page.bmp.height + 12
1018
+ }
1019
+ windowStart = prevStart
1020
+ renderedUpTo = maxOf(renderedUpTo, windowStart + pageEntries.size - 1)
1021
+
1022
+ // Доод хэсгийг цэвэрлэнэ
1023
+ val excess = pageEntries.size - MAX_RENDERED
1024
+ if (excess > 0) pruneBottomPages(excess)
1025
+
1026
+ // Дээр нэмсэн өндрийг scroll-д нэмж харагдах хэсгийг хэвээр үлдээнэ
1027
+ scrollView.scrollBy(0, insertedHeight)
1028
+ forceLayoutScrollView()
1029
+ }
1030
+ }
1031
+
1032
+ /** twoupcontinuous: өмнөх chunk-ийг хос мөрөөр дээр нэмнэ. */
1033
+ private suspend fun loadPrevChunkTwoup(pdf: PdfRenderer) {
1034
+ val prevStart = maxOf(0, windowStart - PAGE_CHUNK)
1035
+ val prevEnd = windowStart
1036
+ if (prevStart >= prevEnd) return
1037
+ val viewWidth = currentViewWidth
1038
+ val halfW = viewWidth / 2
1039
+
1040
+ data class RowBmps(val leftIdx: Int, val leftBmp: Bitmap, val rightBmp: Bitmap?)
1041
+ val rows = mutableListOf<RowBmps>()
1042
+ var i = prevStart
1043
+ while (i < prevEnd) {
1044
+ val leftBmp = renderPageBitmap(pdf, i, halfW)
1045
+ val rightBmp = if (i + 1 < minOf(prevEnd, totalPages))
1046
+ renderPageBitmap(pdf, i + 1, halfW) else null
1047
+ rows.add(RowBmps(i, leftBmp, rightBmp))
1048
+ i += 2
1049
+ }
1050
+
1051
+ withContext(Dispatchers.Main) {
1052
+ var insertedHeight = 0
1053
+ for (rb in rows.asReversed()) {
1054
+ prependTwoupRowToContainer(
1055
+ rb.leftBmp, rb.leftIdx,
1056
+ rb.rightBmp,
1057
+ if (rb.rightBmp != null) rb.leftIdx + 1 else null,
1058
+ viewWidth
1059
+ )
1060
+ insertedHeight += rb.leftBmp.height + 12
1061
+ }
1062
+ windowStart = prevStart
1063
+ renderedUpTo = windowStart + pageEntries.size - 1
1064
+
1065
+ while (pageEntries.size > MAX_RENDERED) {
1066
+ pruneBottomTwoupOneRow()
1067
+ }
1068
+
1069
+ scrollView.scrollBy(0, insertedHeight)
1070
+ forceLayoutScrollView()
1071
+ }
1072
+ }
1073
+
1074
+ /** twoup мөрийг container + pageEntries-ийн эхэнд оруулна (дээд chunk). */
1075
+ private fun prependTwoupRowToContainer(
1076
+ leftBmp: Bitmap, li: Int,
1077
+ rightBmp: Bitmap?, ri: Int?,
1078
+ viewWidth: Int
1079
+ ) {
1080
+ val rowH = leftBmp.height
1081
+ val row = LinearLayout(context).apply {
1082
+ orientation = LinearLayout.HORIZONTAL
1083
+ layoutParams = LinearLayout.LayoutParams(viewWidth, rowH)
1084
+ .apply { setMargins(0, 6, 0, 6) }
1085
+ }
1086
+ val (leftFrame, leftEntry) = buildPageFrameDetached(
1087
+ leftBmp, li,
1088
+ pagePdfW.getOrElse(li) { 1 }.toFloat(),
1089
+ pagePdfH.getOrElse(li) { 1 }.toFloat(),
1090
+ 1f
1091
+ )
1092
+ row.addView(leftFrame)
1093
+ if (rightBmp != null && ri != null) {
1094
+ val (rightFrame, rightEntry) = buildPageFrameDetached(
1095
+ rightBmp, ri,
1096
+ pagePdfW.getOrElse(ri) { 1 }.toFloat(),
1097
+ pagePdfH.getOrElse(ri) { 1 }.toFloat(),
1098
+ 1f
1099
+ )
1100
+ row.addView(rightFrame)
1101
+ pageEntries.add(0, rightEntry)
1102
+ pageEntries.add(0, leftEntry)
1103
+ } else {
1104
+ row.addView(View(context).apply {
1105
+ layoutParams = LinearLayout.LayoutParams(0, rowH, 1f)
1106
+ })
1107
+ pageEntries.add(0, leftEntry)
1108
+ }
1109
+ container.addView(row, 0)
1110
+ }
1111
+
1112
+ /** twoupcontinuous: доод талын нэг мөрийг (эсвэл сүүлийн entry) устгана. */
1113
+ private fun pruneBottomTwoupOneRow() {
1114
+ val last = pageEntries.lastOrNull() ?: return
1115
+ val row = last.frame.parent as? LinearLayout
1116
+ if (row != null && row.parent === container && row.childCount > 0) {
1117
+ val removeList = pageEntries.filter { it.frame.parent === row }
1118
+ for (e in removeList) {
1119
+ (e.frame.getChildAt(0) as? ImageView)?.setImageBitmap(null)
1120
+ pageEntries.remove(e)
1121
+ }
1122
+ container.removeView(row)
1123
+ } else {
1124
+ val e = pageEntries.removeLastOrNull() ?: return
1125
+ (e.frame.getChildAt(0) as? ImageView)?.setImageBitmap(null)
1126
+ container.removeView(e.frame)
1127
+ }
1128
+ renderedUpTo = windowStart + pageEntries.size - 1
1129
+ }
1130
+
1131
+ /**
1132
+ * Дээр (index 0) шинэ хуудас оруулна. windowStart-г дуудагч тал шинэчилнэ.
1133
+ */
1134
+ private fun prependPageFrame(bmp: Bitmap, pageIdx: Int, pdfW: Float, pdfH: Float) {
1135
+ val bmpW = bmp.width
1136
+ val bmpH = bmp.height
1137
+ val frame = FrameLayout(context).apply {
1138
+ layoutParams = LinearLayout.LayoutParams(bmpW, bmpH).apply { setMargins(0, 6, 0, 6) }
1139
+ }
1140
+ val iv = ImageView(context).apply {
1141
+ layoutParams = FrameLayout.LayoutParams(bmpW, bmpH)
1142
+ scaleType = ImageView.ScaleType.FIT_XY
1143
+ setImageBitmap(bmp)
1144
+ }
1145
+ val canvasView = AnnotationCanvasView(context, pageIdx, pdfW, pdfH).apply {
1146
+ layoutParams = FrameLayout.LayoutParams(bmpW, bmpH)
1147
+ translationZ = 1f
1148
+ }
1149
+ frame.addView(iv)
1150
+ frame.addView(canvasView)
1151
+ container.addView(frame, 0)
1152
+ pageEntries.add(0, PageEntry(frame, canvasView, bmpH))
1153
+ }
1154
+
1155
+ /**
1156
+ * Доод хэсгийн [count] хуудсыг устгаж bitmap санах ойг чөлөөлнэ.
1157
+ * renderedUpTo-г буулгаж дараагийн доош scroll-д re-render хийгдэхийг зөвшөөрнэ.
1158
+ */
1159
+ private fun pruneBottomPages(count: Int) {
1160
+ repeat(count) {
1161
+ val entry = pageEntries.removeLastOrNull() ?: return@repeat
1162
+ (entry.frame.getChildAt(0) as? ImageView)?.setImageBitmap(null)
1163
+ container.removeView(entry.frame)
1164
+ }
1165
+ renderedUpTo = windowStart + pageEntries.size - 1
1166
+ }
1167
+
1168
+ /**
1169
+ * Render хийгдээгүй хуудас руу үсрэх.
1170
+ * Бүх одоогийн view-г цэвэрлэж, targetPage-ийн эргэн тойрны хуудсуудыг render хийнэ.
1171
+ */
1172
+ private fun jumpToPage(targetPage: Int) {
1173
+ val pdf = renderer ?: return
1174
+ val viewWidth = currentViewWidth.takeIf { it > 0 } ?: width.takeIf { it > 0 } ?: return
1175
+ renderJob?.cancel()
1176
+ renderJob = scope.launch {
1177
+ try {
1178
+ // Шинэ window: targetPage-ийн хагас chunk өмнөөс эхэлнэ
1179
+ val newStart = maxOf(0, targetPage - PAGE_CHUNK / 2)
1180
+ val newEnd = minOf(totalPages, newStart + MAX_RENDERED)
1181
+
1182
+ withContext(Dispatchers.Main) {
1183
+ container.removeAllViews()
1184
+ pageEntries.clear()
1185
+ loadingFooter = null
1186
+ windowStart = newStart
1187
+ renderedUpTo = newStart - 1
1188
+ chunkLoading = false
1189
+ }
1190
+
1191
+ if (displayMode == "twoupcontinuous") {
1192
+ renderTwoUpRange(pdf, totalPages, viewWidth, newStart, newEnd)
1193
+ withContext(Dispatchers.Main) {
1194
+ val rawUpTo = if (newEnd % 2 == 0) newEnd - 1 else newEnd - 2
1195
+ renderedUpTo = rawUpTo.coerceIn(0, totalPages - 1)
1196
+ if (renderedUpTo < totalPages - 1) showLoadingFooter()
1197
+ forceLayoutScrollView()
1198
+ post { scrollToPage(targetPage, smooth = false) }
1199
+ }
1200
+ } else {
1201
+ for (i in newStart until newEnd) {
1202
+ val bmp = renderPageBitmap(pdf, i, viewWidth)
1203
+ val idx = i
1204
+ withContext(Dispatchers.Main) {
1205
+ addPageRow(
1206
+ bmp, idx,
1207
+ pagePdfW.getOrElse(idx) { 1 }.toFloat(),
1208
+ pagePdfH.getOrElse(idx) { 1 }.toFloat()
1209
+ )
1210
+ }
1211
+ }
1212
+ withContext(Dispatchers.Main) {
1213
+ renderedUpTo = newEnd - 1
1214
+ if (newEnd < totalPages) showLoadingFooter()
1215
+ forceLayoutScrollView()
1216
+ post { scrollToPage(targetPage, smooth = false) }
1217
+ }
1218
+ }
1219
+ } catch (e: CancellationException) { throw e }
1220
+ catch (e: Exception) { Log.e("ExpoPdfReader", "JumpToPage error", e) }
1221
+ }
1222
+ }
1223
+
1224
+ /**
1225
+ * React Native Fabric нь child view-н requestLayout()-г таслах тул
1226
+ * addView() дараа layout pass автоматаар ажиллахгүй.
1227
+ * Энэ функц scrollView-г шууд measure + layout хийж pages-г харагдуулна.
1228
+ */
1229
+ private fun forceLayoutScrollView() {
1230
+ val w = width.takeIf { it > 0 } ?: return
1231
+ val h = height.takeIf { it > 0 } ?: return
1232
+ scrollView.measure(
1233
+ MeasureSpec.makeMeasureSpec(w, MeasureSpec.EXACTLY),
1234
+ MeasureSpec.makeMeasureSpec(h, MeasureSpec.EXACTLY)
1235
+ )
1236
+ scrollView.layout(0, 0, w, h)
1237
+ }
1238
+
1239
+ private fun showLoadingFooter() {
1240
+ if (loadingFooter != null) return
1241
+ val footer = ProgressBar(context, null, android.R.attr.progressBarStyleSmall).apply {
1242
+ layoutParams = LinearLayout.LayoutParams(
1243
+ LinearLayout.LayoutParams.MATCH_PARENT, 120
1244
+ ).apply { setMargins(0, 16, 0, 16) }
1245
+ isIndeterminate = true
1246
+ }
1247
+ container.addView(footer)
1248
+ loadingFooter = footer
1249
+ }
1250
+
1251
+ private fun removeLoadingFooter() {
1252
+ loadingFooter?.let { container.removeView(it) }
1253
+ loadingFooter = null
1254
+ }
1255
+
1256
+ private fun addPageRow(bmp: Bitmap, pageIdx: Int, pdfW: Float, pdfH: Float) =
1257
+ container.addView(buildPageFrame(bmp, pageIdx, pdfW, pdfH))
1258
+
1259
+ private fun buildPageFrameDetached(
1260
+ bmp: Bitmap, pageIdx: Int, pdfW: Float, pdfH: Float, weight: Float = 0f
1261
+ ): Pair<FrameLayout, PageEntry> {
1262
+ val bmpW = bmp.width
1263
+ val bmpH = bmp.height
1264
+ val frame = FrameLayout(context).apply {
1265
+ layoutParams = if (weight > 0f)
1266
+ LinearLayout.LayoutParams(0, bmpH, weight)
1267
+ else
1268
+ LinearLayout.LayoutParams(bmpW, bmpH)
1269
+ .apply { setMargins(0, 6, 0, 6) }
1270
+ }
1271
+ val iv = ImageView(context).apply {
1272
+ layoutParams = FrameLayout.LayoutParams(bmpW, bmpH)
1273
+ scaleType = ImageView.ScaleType.FIT_XY
1274
+ setImageBitmap(bmp)
1275
+ }
1276
+ val canvasView = AnnotationCanvasView(context, pageIdx, pdfW, pdfH).apply {
1277
+ layoutParams = FrameLayout.LayoutParams(bmpW, bmpH)
1278
+ translationZ = 1f
1279
+ }
1280
+ frame.addView(iv)
1281
+ frame.addView(canvasView)
1282
+ return frame to PageEntry(frame, canvasView, bmpH)
1283
+ }
1284
+
1285
+ private fun buildPageFrame(bmp: Bitmap, pageIdx: Int, pdfW: Float, pdfH: Float, weight: Float = 0f): FrameLayout {
1286
+ val (frame, entry) = buildPageFrameDetached(bmp, pageIdx, pdfW, pdfH, weight)
1287
+ pageEntries.add(entry)
1288
+ return frame
1289
+ }
1290
+
1291
+ private suspend fun renderPageBitmap(pdf: PdfRenderer, index: Int, targetWidth: Int): Bitmap =
1292
+ withContext(pdfDispatcher) {
1293
+ pdf.openPage(index).use { page ->
1294
+ if (index < pagePdfW.size) {
1295
+ pagePdfW[index] = page.width
1296
+ pagePdfH[index] = page.height
1297
+ }
1298
+ val s = targetWidth.toFloat() / page.width.toFloat()
1299
+ val h = (page.height * s).toInt().coerceAtLeast(1)
1300
+ val bmp = Bitmap.createBitmap(targetWidth, h, Bitmap.Config.ARGB_8888)
1301
+ bmp.eraseColor(Color.WHITE)
1302
+ page.render(bmp, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
1303
+ bmp
1304
+ }
1305
+ }
1306
+
1307
+ private fun safeCloseRenderer() {
1308
+ try { renderer?.close(); fileDescriptor?.close() }
1309
+ catch (_: Exception) { }
1310
+ finally { renderer = null; fileDescriptor = null }
1311
+ }
1312
+
1313
+ private suspend fun resolveFile(url: String): File = withContext(pdfDispatcher) {
1314
+ when {
1315
+ url.startsWith("file://") -> File(url.removePrefix("file://"))
1316
+ url.startsWith("/") -> File(url)
1317
+ else -> {
1318
+ val f = File.createTempFile("pdf_", ".pdf", context.cacheDir)
1319
+ URL(url).openStream().use { inp -> FileOutputStream(f).use { inp.copyTo(it) } }
1320
+ f
1321
+ }
1322
+ }
1323
+ }
1324
+
1325
+ private fun buildFingerprint(data: List<Map<String, Any?>>): String =
1326
+ data.mapNotNull { item ->
1327
+ val p = (item["page"] as? Number)?.toInt() ?: return@mapNotNull null
1328
+ val t = item["type"] as? String ?: return@mapNotNull null
1329
+ val b = item["bounds"] as? Map<*, *> ?: return@mapNotNull null
1330
+ "$p|$t|${b["x"]},${b["y"]},${b["width"]},${b["height"]}"
1331
+ }.sorted().joinToString("||")
1332
+
1333
+ // ─────────────────────────────────────────────────────────────────────────
1334
+ // SinglePageAdapter — RecyclerView adapter for "single" display mode
1335
+ // vertical PagerSnapHelper босоо scroll-оор хуудас солих
1336
+ // ZoomRecyclerView zoom-г удирдана (adapter энгийн FrameLayout ашиглана)
1337
+ // lazy bitmap loading
1338
+ // ─────────────────────────────────────────────────────────────────────────
1339
+
1340
+ inner class SinglePageAdapter : RecyclerView.Adapter<SinglePageAdapter.Holder>() {
1341
+
1342
+ inner class Holder(val frame: FrameLayout, val imageView: ImageView) :
1343
+ RecyclerView.ViewHolder(frame) {
1344
+ var boundPage = -1
1345
+ var loadJob: Job? = null
1346
+ var currentCanvas: AnnotationCanvasView? = null
1347
+ }
1348
+
1349
+ override fun getItemCount(): Int = totalPages
1350
+
1351
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
1352
+ val frame = FrameLayout(context).apply {
1353
+ layoutParams = RecyclerView.LayoutParams(
1354
+ RecyclerView.LayoutParams.MATCH_PARENT,
1355
+ RecyclerView.LayoutParams.MATCH_PARENT
1356
+ )
1357
+ setBackgroundColor(Color.parseColor("#E8E8E8"))
1358
+ }
1359
+ val iv = ImageView(context).apply {
1360
+ layoutParams = FrameLayout.LayoutParams(
1361
+ FrameLayout.LayoutParams.WRAP_CONTENT,
1362
+ FrameLayout.LayoutParams.WRAP_CONTENT,
1363
+ android.view.Gravity.CENTER
1364
+ )
1365
+ scaleType = ImageView.ScaleType.FIT_XY
1366
+ }
1367
+ frame.addView(iv)
1368
+ return Holder(frame, iv)
1369
+ }
1370
+
1371
+ override fun onBindViewHolder(holder: Holder, position: Int) {
1372
+ holder.boundPage = position
1373
+ holder.loadJob?.cancel()
1374
+ holder.imageView.setImageBitmap(null)
1375
+ // Recycle хийгдсэн view-н zoom transform-г цэвэрлэнэ
1376
+ holder.frame.scaleX = 1f; holder.frame.scaleY = 1f
1377
+ holder.frame.translationX = 0f; holder.frame.translationY = 0f
1378
+
1379
+ holder.currentCanvas?.let {
1380
+ singlePageCanvases.remove(it.pageIndex)
1381
+ holder.frame.removeView(it)
1382
+ }
1383
+ holder.currentCanvas = null
1384
+
1385
+ holder.loadJob = scope.launch {
1386
+ val pdf = renderer ?: return@launch
1387
+ val bmp = renderPageBitmap(pdf, position, this@ExpoPdfReaderView.width)
1388
+ val pdfW = pagePdfW.getOrElse(position) { 1 }.toFloat()
1389
+ val pdfH = pagePdfH.getOrElse(position) { 1 }.toFloat()
1390
+ withContext(Dispatchers.Main) {
1391
+ if (holder.boundPage != position) return@withContext
1392
+ val canvas = AnnotationCanvasView(context, position, pdfW, pdfH).apply {
1393
+ layoutParams = FrameLayout.LayoutParams(
1394
+ bmp.width, bmp.height,
1395
+ android.view.Gravity.CENTER
1396
+ )
1397
+ translationZ = 1f
1398
+ }
1399
+ holder.frame.addView(canvas)
1400
+ holder.currentCanvas = canvas
1401
+ singlePageCanvases[position] = canvas
1402
+ val lp = FrameLayout.LayoutParams(bmp.width, bmp.height, android.view.Gravity.CENTER)
1403
+ holder.imageView.layoutParams = lp
1404
+ holder.imageView.setImageBitmap(bmp)
1405
+ val fw = this@ExpoPdfReaderView.width.takeIf { it > 0 } ?: return@withContext
1406
+ val fh = this@ExpoPdfReaderView.height.takeIf { it > 0 } ?: return@withContext
1407
+ holder.frame.measure(
1408
+ MeasureSpec.makeMeasureSpec(fw, MeasureSpec.EXACTLY),
1409
+ MeasureSpec.makeMeasureSpec(fh, MeasureSpec.EXACTLY)
1410
+ )
1411
+ holder.frame.layout(0, 0, fw, fh)
1412
+ }
1413
+ }
1414
+ }
1415
+
1416
+ override fun onViewRecycled(holder: Holder) {
1417
+ holder.loadJob?.cancel()
1418
+ holder.currentCanvas?.let {
1419
+ singlePageCanvases.remove(it.pageIndex)
1420
+ holder.frame.removeView(it)
1421
+ }
1422
+ holder.currentCanvas = null
1423
+ holder.imageView.setImageBitmap(null)
1424
+ }
1425
+ }
1426
+
1427
+ // ─────────────────────────────────────────────────────────────────────────
1428
+ // ZoomRecyclerView — single mode-д pinch-to-zoom + pan дэмждэг RecyclerView
1429
+ //
1430
+ // Яагаад RecyclerView subclass хийх шаардлагатай вэ:
1431
+ // AnnotationCanvasView ACTION_DOWN-д false буцаана → RecyclerView
1432
+ // gesture-г эзэмшиж авна → дараагийн ACTION_POINTER_DOWN (2-р хуруу)
1433
+ // шууд RecyclerView.onTouchEvent-д очдог.
1434
+ // RecyclerView subclass хийснээр onTouchEvent-д multi-touch-г зохицуулна.
1435
+ //
1436
+ // Zoom тооцоо (pivot=0,0 + translation):
1437
+ // Item view point (px,py) дэлгэц: (px*scale + tx, py*scale + ty)
1438
+ // Focus (fx,fy) тогтвортой байхад: new_tx = fx - (fx - tx) * scaleFactor
1439
+ // ─────────────────────────────────────────────────────────────────────────
1440
+
1441
+ inner class ZoomRecyclerView(context: Context) : RecyclerView(context) {
1442
+ private var currentScale = 1f
1443
+ private var tx = 0f
1444
+ private var ty = 0f
1445
+ private var panX = 0f
1446
+ private var panY = 0f
1447
+
1448
+ private val zoomDetector = ScaleGestureDetector(context,
1449
+ object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
1450
+ override fun onScale(d: ScaleGestureDetector): Boolean {
1451
+ val newScale = (currentScale * d.scaleFactor).coerceIn(minZoom, maxZoom)
1452
+ val af = newScale / currentScale
1453
+ // Focus point тогтвортой байхын zoom-to-point тооцоо
1454
+ tx = d.focusX - (d.focusX - tx) * af
1455
+ ty = d.focusY - (d.focusY - ty) * af
1456
+ currentScale = newScale
1457
+ clampAndApply()
1458
+ return true
1459
+ }
1460
+ }
1461
+ )
1462
+
1463
+ override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
1464
+ if (activeTool == null) {
1465
+ zoomDetector.onTouchEvent(ev)
1466
+ if (ev.actionMasked == MotionEvent.ACTION_POINTER_DOWN) {
1467
+ stopScroll()
1468
+ }
1469
+ if (ev.pointerCount > 1 || zoomDetector.isInProgress) {
1470
+ parent?.requestDisallowInterceptTouchEvent(true)
1471
+ }
1472
+ }
1473
+ return super.dispatchTouchEvent(ev)
1474
+ }
1475
+
1476
+ override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
1477
+ if (activeTool != null) return super.onInterceptTouchEvent(ev)
1478
+ // Pinch үед RecyclerView хэвийн scroll intercept хийхгүй
1479
+ if (ev.pointerCount > 1 || zoomDetector.isInProgress) return false
1480
+ // Zoom хийгдсэн + tool идэвхгүй → pan хийхийн тулд ACTION_DOWN-г intercept
1481
+ if (currentScale > 1.02f && ev.action == MotionEvent.ACTION_DOWN) {
1482
+ return true
1483
+ }
1484
+ return super.onInterceptTouchEvent(ev)
1485
+ }
1486
+
1487
+ override fun onTouchEvent(ev: MotionEvent): Boolean {
1488
+ // zoomDetector-ийг dispatchTouchEvent-д аль хэдийн дамжуулсан
1489
+
1490
+ // 2 хуруу байвал RecyclerView scroll хийхгүйгээр zoom-г л хийнэ
1491
+ if (ev.pointerCount >= 2 || zoomDetector.isInProgress) {
1492
+ return true
1493
+ }
1494
+
1495
+ // Zoom хийгдсэн үед нэг хуруугаар pan (tool идэвхгүй)
1496
+ if (currentScale > 1.02f && activeTool == null) {
1497
+ when (ev.actionMasked) {
1498
+ MotionEvent.ACTION_DOWN -> {
1499
+ stopScroll()
1500
+ panX = ev.x; panY = ev.y
1501
+ }
1502
+ MotionEvent.ACTION_MOVE -> {
1503
+ tx += ev.x - panX
1504
+ ty += ev.y - panY
1505
+ panX = ev.x; panY = ev.y
1506
+ clampAndApply()
1507
+ }
1508
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { }
1509
+ }
1510
+ return true
1511
+ }
1512
+
1513
+ return super.onTouchEvent(ev)
1514
+ }
1515
+
1516
+ fun resetZoom() {
1517
+ currentScale = 1f; tx = 0f; ty = 0f
1518
+ applyToCurrentItem()
1519
+ }
1520
+
1521
+ private fun clampAndApply() {
1522
+ val w = width.toFloat(); val h = height.toFloat()
1523
+ if (w > 0f && h > 0f) {
1524
+ val sw = w * currentScale; val sh = h * currentScale
1525
+ tx = if (sw > w) tx.coerceIn(w - sw, 0f) else 0f
1526
+ ty = if (sh > h) ty.coerceIn(h - sh, 0f) else 0f
1527
+ }
1528
+ applyToCurrentItem()
1529
+ }
1530
+
1531
+ private fun applyToCurrentItem() {
1532
+ val lm = layoutManager as? LinearLayoutManager ?: return
1533
+ val pos = lm.findFirstVisibleItemPosition().takeIf { it >= 0 } ?: return
1534
+ val itemView = lm.findViewByPosition(pos) ?: return
1535
+ itemView.pivotX = 0f; itemView.pivotY = 0f
1536
+ itemView.scaleX = currentScale; itemView.scaleY = currentScale
1537
+ itemView.translationX = tx; itemView.translationY = ty
1538
+ }
1539
+ }
1540
+
1541
+ // ─────────────────────────────────────────────────────────────────────────
1542
+ // AnnotationCanvasView (inner class — full access to outer view state)
1543
+ // ─────────────────────────────────────────────────────────────────────────
1544
+
1545
+ inner class AnnotationCanvasView(
1546
+ context: Context,
1547
+ val pageIndex: Int,
1548
+ private val pdfWidth: Float,
1549
+ private val pdfHeight: Float
1550
+ ) : View(context) {
1551
+
1552
+ private var drawTool: String? = null
1553
+ private val drawPoints = mutableListOf<PointF>() // PDF coords
1554
+ private var drawStart: PointF? = null
1555
+
1556
+ // ── No-tool touch state (tap → event, long-press → drag) ─────────────
1557
+ private var noToolDown = false
1558
+ private var noToolDownScreen = PointF()
1559
+ private var isDragging = false
1560
+ private var dragAnn: PdfAnnotation? = null
1561
+ private var dragOrigBounds = RectF()
1562
+ private val LP_TIMEOUT = android.view.ViewConfiguration.getLongPressTimeout().toLong()
1563
+ private val TOUCH_SLOP by lazy { android.view.ViewConfiguration.get(context).scaledTouchSlop.toFloat() }
1564
+ private val lpHandler = android.os.Handler(android.os.Looper.getMainLooper())
1565
+ private val lpRunnable = Runnable {
1566
+ if (dragAnn?.type != "text" && dragAnn?.type != "note") return@Runnable
1567
+ isDragging = true
1568
+ pager.requestDisallowInterceptTouchEvent(true)
1569
+ scrollView.requestDisallowInterceptTouchEvent(true)
1570
+ invalidate()
1571
+ }
1572
+
1573
+ // Reusable objects — no allocation in onDraw
1574
+ private val inkPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
1575
+ style = Paint.Style.STROKE; strokeCap = Paint.Cap.ROUND; strokeJoin = Paint.Join.ROUND
1576
+ }
1577
+ private val bgFill = Paint().apply { style = Paint.Style.FILL }
1578
+ private val bgStroke = Paint().apply {
1579
+ style = Paint.Style.STROKE; strokeWidth = 1.5f; color = Color.DKGRAY
1580
+ }
1581
+ private val textPaint = TextPaint(Paint.ANTI_ALIAS_FLAG)
1582
+ private val drawPath = Path()
1583
+
1584
+ // ── Lifecycle ─────────────────────────────────────────────────────────
1585
+
1586
+ override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
1587
+ super.onSizeChanged(w, h, oldw, oldh)
1588
+ // Single-mode canvases start with MATCH_PARENT (width=0) and receive their real
1589
+ // size after RecyclerView lays out. Re-draw so initialAnnotations become visible.
1590
+ if (w > 0 && oldw == 0) invalidate()
1591
+ }
1592
+
1593
+ // ── Coordinate helpers ────────────────────────────────────────────────
1594
+
1595
+ private fun scale() = if (width > 0 && pdfWidth > 0) width.toFloat() / pdfWidth else 1f
1596
+
1597
+ /** View-local screen PDF. */
1598
+ private fun toPdf(sx: Float, sy: Float): PointF {
1599
+ val s = scale(); return PointF(sx / s, pdfHeight - sy / s)
1600
+ }
1601
+
1602
+ /** PDF view-local screen. */
1603
+ private fun toScreen(px: Float, py: Float): PointF {
1604
+ val s = scale(); return PointF(px * s, (pdfHeight - py) * s)
1605
+ }
1606
+
1607
+ /**
1608
+ * PDF RectF → screen RectF.
1609
+ * bounds.top = lower PDF y → screen bottom
1610
+ * bounds.bottom = upper PDF y → screen top
1611
+ */
1612
+ private fun toScreenRect(b: RectF): RectF {
1613
+ val s = scale()
1614
+ return RectF(b.left * s, (pdfHeight - b.bottom) * s, b.right * s, (pdfHeight - b.top) * s)
1615
+ }
1616
+
1617
+ // ── Touch ─────────────────────────────────────────────────────────────
1618
+
1619
+ override fun onTouchEvent(event: MotionEvent): Boolean {
1620
+ val tool = activeTool
1621
+ if (tool != null) {
1622
+ // Drawing tool active — consume all events
1623
+ scrollView.requestDisallowInterceptTouchEvent(true)
1624
+ pager.requestDisallowInterceptTouchEvent(true)
1625
+ val pdfPt = toPdf(event.x, event.y)
1626
+ when (tool) {
1627
+ "eraser" -> {
1628
+ if (event.action == MotionEvent.ACTION_DOWN || event.action == MotionEvent.ACTION_MOVE)
1629
+ eraseAt(pdfPt)
1630
+ }
1631
+ "text", "note" -> { if (event.action == MotionEvent.ACTION_DOWN) addInstantAnnotation(pdfPt, tool) }
1632
+ else -> handleDraw(event, pdfPt, tool)
1633
+ }
1634
+ return true
1635
+ }
1636
+ return handleNoToolTouch(event)
1637
+ }
1638
+
1639
+ /**
1640
+ * Touch handling when no drawing tool is active.
1641
+ * - Single tap on text/note → fire onTextPress / onNotePress
1642
+ * - Long press on text/note → drag the annotation
1643
+ * The key issue: returning false on ACTION_DOWN means we never receive ACTION_UP.
1644
+ * So we return true only when we detect a tap/drag target on ACTION_DOWN.
1645
+ */
1646
+ private fun handleNoToolTouch(event: MotionEvent): Boolean {
1647
+ when (event.action) {
1648
+ MotionEvent.ACTION_DOWN -> {
1649
+ val pdfPt = toPdf(event.x, event.y)
1650
+ val ann = tapTargetAt(pdfPt) ?: return false
1651
+ dragAnn = ann
1652
+ dragOrigBounds = RectF(ann.bounds)
1653
+ noToolDownScreen = PointF(event.x, event.y)
1654
+ noToolDown = true
1655
+ lpHandler.postDelayed(lpRunnable, LP_TIMEOUT)
1656
+ return true
1657
+ }
1658
+ MotionEvent.ACTION_MOVE -> {
1659
+ if (!noToolDown) return false
1660
+ val dx = event.x - noToolDownScreen.x
1661
+ val dy = event.y - noToolDownScreen.y
1662
+ if (isDragging) {
1663
+ val ann = dragAnn ?: return true
1664
+ val s = scale()
1665
+ val annW = dragOrigBounds.width()
1666
+ val annH = dragOrigBounds.height()
1667
+ // PDF y is inverted: screen down (dy>0) → PDF y decreases
1668
+ val newLeft = (dragOrigBounds.left + dx / s).coerceIn(0f, pdfWidth - annW)
1669
+ val newBottom = (dragOrigBounds.bottom - dy / s).coerceIn(annH, pdfHeight)
1670
+ ann.bounds = RectF(newLeft, newBottom - annH, newLeft + annW, newBottom)
1671
+ invalidate()
1672
+ return true
1673
+ }
1674
+ // Cancel long-press if finger moved beyond touch slop
1675
+ if (Math.hypot(dx.toDouble(), dy.toDouble()) > TOUCH_SLOP) {
1676
+ lpHandler.removeCallbacks(lpRunnable)
1677
+ noToolDown = false
1678
+ dragAnn = null
1679
+ return false
1680
+ }
1681
+ return true
1682
+ }
1683
+ MotionEvent.ACTION_UP -> {
1684
+ lpHandler.removeCallbacks(lpRunnable)
1685
+ val wasDown = noToolDown
1686
+ val wasDragging = isDragging
1687
+ noToolDown = false; isDragging = false
1688
+ pager.requestDisallowInterceptTouchEvent(false)
1689
+ scrollView.requestDisallowInterceptTouchEvent(false)
1690
+ if (!wasDown) { dragAnn = null; return false }
1691
+ if (wasDragging) {
1692
+ dragAnn = null
1693
+ notifyAnnotationChange()
1694
+ } else {
1695
+ dragAnn = null
1696
+ handleTap(event.x, event.y)
1697
+ }
1698
+ return true
1699
+ }
1700
+ MotionEvent.ACTION_CANCEL -> {
1701
+ lpHandler.removeCallbacks(lpRunnable)
1702
+ if (isDragging) { dragAnn?.bounds = RectF(dragOrigBounds); invalidate() }
1703
+ noToolDown = false; isDragging = false; dragAnn = null
1704
+ pager.requestDisallowInterceptTouchEvent(false)
1705
+ scrollView.requestDisallowInterceptTouchEvent(false)
1706
+ return false
1707
+ }
1708
+ }
1709
+ return noToolDown
1710
+ }
1711
+
1712
+ /** Returns the first text or note annotation under [pdfPt], or null. */
1713
+ private fun tapTargetAt(pdfPt: PointF): PdfAnnotation? {
1714
+ val pageAnns = annotationMap[pageIndex] ?: return null
1715
+ return pageAnns.firstOrNull { (it.type == "text" || it.type == "note") && it.bounds.contains(pdfPt.x, pdfPt.y) }
1716
+ }
1717
+
1718
+ private fun handleDraw(event: MotionEvent, pdfPt: PointF, tool: String) {
1719
+ when (event.action) {
1720
+ MotionEvent.ACTION_DOWN -> {
1721
+ drawTool = tool; drawPoints.clear()
1722
+ drawStart = PointF(pdfPt.x, pdfPt.y); drawPoints.add(PointF(pdfPt.x, pdfPt.y))
1723
+ invalidate()
1724
+ }
1725
+ MotionEvent.ACTION_MOVE -> {
1726
+ if (tool == "line") {
1727
+ drawPoints.clear(); drawStart?.let { drawPoints.add(PointF(it.x, it.y)) }
1728
+ }
1729
+ drawPoints.add(PointF(pdfPt.x, pdfPt.y)); invalidate()
1730
+ }
1731
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
1732
+ if (drawPoints.size >= 2) commitStroke(tool)
1733
+ drawTool = null; drawPoints.clear(); drawStart = null; invalidate()
1734
+ }
1735
+ }
1736
+ }
1737
+
1738
+ private fun commitStroke(tool: String) {
1739
+ val pad = strokeWidthVal / 2f
1740
+ val ann = PdfAnnotation(
1741
+ type = tool, pageIndex = pageIndex,
1742
+ bounds = RectF(
1743
+ drawPoints.minOf { it.x } - pad, drawPoints.minOf { it.y } - pad,
1744
+ drawPoints.maxOf { it.x } + pad, drawPoints.maxOf { it.y } + pad
1745
+ ),
1746
+ color = strokeColorVal, strokeWidth = strokeWidthVal
1747
+ )
1748
+ ann.paths.add(AnnPath(drawPoints.map { PointF(it.x, it.y) }.toMutableList()))
1749
+ addAnnotationAndCommit(ann)
1750
+ }
1751
+
1752
+ private fun eraseAt(pdfPt: PointF) {
1753
+ val pageAnns = annotationMap[pageIndex] ?: return
1754
+ val r = 10f / scale()
1755
+ val hit = RectF(pdfPt.x - r, pdfPt.y - r, pdfPt.x + r, pdfPt.y + r)
1756
+ val toRemove = pageAnns.filter { RectF.intersects(it.bounds, hit) }
1757
+ if (toRemove.isNotEmpty()) {
1758
+ pageAnns.removeAll(toRemove.toSet())
1759
+ toRemove.forEach { ann -> undoStack.removeAll { it.annotation === ann } }
1760
+ invalidate(); notifyAnnotationChange(); notifyUndoRedoState()
1761
+ }
1762
+ }
1763
+
1764
+ private fun addInstantAnnotation(pdfPt: PointF, tool: String) {
1765
+ val s = scale()
1766
+ val ann = if (tool == "note") {
1767
+ PdfAnnotation("note", pageIndex,
1768
+ RectF(pdfPt.x, pdfPt.y, pdfPt.x + 32f / s, pdfPt.y + 32f / s),
1769
+ color = noteColorVal, contents = textContentVal.ifBlank { " " })
1770
+ } else {
1771
+ val w = 180f / s; val h = 40f / s
1772
+ PdfAnnotation("text", pageIndex,
1773
+ RectF(pdfPt.x, pdfPt.y - h, pdfPt.x + w, pdfPt.y),
1774
+ color = textColorVal, contents = textContentVal.ifBlank { " " },
1775
+ fontSize = textFontSizeVal, bold = textBoldVal, italic = textItalicVal)
1776
+ }
1777
+ addAnnotationAndCommit(ann)
1778
+ }
1779
+
1780
+ private fun handleTap(screenX: Float, screenY: Float) {
1781
+ val pdfPt = toPdf(screenX, screenY)
1782
+ val pageAnns = annotationMap[pageIndex] ?: return
1783
+
1784
+ pageAnns.filter { it.type == "text" }.forEachIndexed { idx, ann ->
1785
+ if (ann.bounds.contains(pdfPt.x, pdfPt.y)) {
1786
+ onTextPress(mapOf("page" to pageIndex, "index" to idx,
1787
+ "bounds" to boundsMap(ann), "contents" to ann.contents)); return
1788
+ }
1789
+ }
1790
+ pageAnns.filter { it.type == "note" }.forEachIndexed { idx, ann ->
1791
+ if (ann.bounds.contains(pdfPt.x, pdfPt.y)) {
1792
+ onNotePress(mapOf("page" to pageIndex, "index" to idx,
1793
+ "bounds" to boundsMap(ann), "contents" to ann.contents)); return
1794
+ }
1795
+ }
1796
+ }
1797
+
1798
+ private fun boundsMap(ann: PdfAnnotation) = mapOf(
1799
+ "x" to ann.bounds.left.toDouble(), "y" to ann.bounds.top.toDouble(),
1800
+ "width" to ann.bounds.width().toDouble(), "height" to ann.bounds.height().toDouble()
1801
+ )
1802
+
1803
+ // ── Draw ──────────────────────────────────────────────────────────────
1804
+
1805
+ override fun onDraw(canvas: Canvas) {
1806
+ for (ann in annotationMap[pageIndex] ?: emptyList()) drawAnn(canvas, ann)
1807
+ drawTool?.let { drawActiveStroke(canvas, it) }
1808
+ }
1809
+
1810
+ private fun drawAnn(canvas: Canvas, ann: PdfAnnotation) = when (ann.type) {
1811
+ "pen", "highlighter", "line" -> drawInk(canvas, ann)
1812
+ "text" -> drawTextAnn(canvas, ann)
1813
+ "note" -> drawNoteAnn(canvas, ann)
1814
+ else -> Unit
1815
+ }
1816
+
1817
+ private fun drawInk(canvas: Canvas, ann: PdfAnnotation) {
1818
+ val s = scale(); val base = safeColor(ann.color)
1819
+ inkPaint.strokeWidth = ann.strokeWidth * s
1820
+ inkPaint.color = if (ann.type == "highlighter")
1821
+ Color.argb(102, Color.red(base), Color.green(base), Color.blue(base)) else base
1822
+ for (ap in ann.paths) {
1823
+ if (ap.points.isEmpty()) continue
1824
+ drawPath.reset()
1825
+ ap.points.forEachIndexed { i, pt ->
1826
+ val sp = toScreen(pt.x, pt.y)
1827
+ if (i == 0) drawPath.moveTo(sp.x, sp.y) else drawPath.lineTo(sp.x, sp.y)
1828
+ }
1829
+ canvas.drawPath(drawPath, inkPaint)
1830
+ }
1831
+ }
1832
+
1833
+ private fun drawTextAnn(canvas: Canvas, ann: PdfAnnotation) {
1834
+ val text = ann.contents.trim().ifEmpty { return }
1835
+ val rect = toScreenRect(ann.bounds)
1836
+ val s = scale()
1837
+ textPaint.textSize = ann.fontSize * s
1838
+ textPaint.color = safeColor(ann.color)
1839
+ textPaint.typeface = when {
1840
+ ann.bold && ann.italic -> Typeface.create(Typeface.DEFAULT, Typeface.BOLD_ITALIC)
1841
+ ann.bold -> Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
1842
+ ann.italic -> Typeface.create(Typeface.DEFAULT, Typeface.ITALIC)
1843
+ else -> Typeface.DEFAULT
1844
+ }
1845
+ // No background — fully transparent
1846
+ val maxW = (rect.width() - 8f).toInt().coerceAtLeast(1)
1847
+ canvas.save()
1848
+ canvas.translate(rect.left + 4f, rect.top + 4f)
1849
+ StaticLayout.Builder.obtain(text, 0, text.length, textPaint, maxW)
1850
+ .setAlignment(Layout.Alignment.ALIGN_NORMAL).build().draw(canvas)
1851
+ canvas.restore()
1852
+ }
1853
+
1854
+ private fun drawNoteAnn(canvas: Canvas, ann: PdfAnnotation) {
1855
+ val rect = toScreenRect(ann.bounds)
1856
+ bgFill.color = try { Color.parseColor(ann.color) } catch (_: Exception) { Color.YELLOW }
1857
+ canvas.drawRect(rect, bgFill); canvas.drawRect(rect, bgStroke)
1858
+ textPaint.textSize = rect.height() * 0.55f; textPaint.color = Color.DKGRAY
1859
+ textPaint.textAlign = Paint.Align.CENTER; textPaint.typeface = Typeface.DEFAULT
1860
+ canvas.drawText("✎", rect.centerX(), rect.centerY() + textPaint.textSize * 0.35f, textPaint)
1861
+ textPaint.textAlign = Paint.Align.LEFT
1862
+ }
1863
+
1864
+ private fun drawActiveStroke(canvas: Canvas, tool: String) {
1865
+ if (drawPoints.isEmpty()) return
1866
+ val s = scale(); val base = safeColor(strokeColorVal)
1867
+ inkPaint.strokeWidth = strokeWidthVal * s
1868
+ inkPaint.color = if (tool == "highlighter")
1869
+ Color.argb(102, Color.red(base), Color.green(base), Color.blue(base)) else base
1870
+ drawPath.reset()
1871
+ drawPoints.forEachIndexed { i, pt ->
1872
+ val sp = toScreen(pt.x, pt.y)
1873
+ if (i == 0) drawPath.moveTo(sp.x, sp.y) else drawPath.lineTo(sp.x, sp.y)
1874
+ }
1875
+ canvas.drawPath(drawPath, inkPaint)
1876
+ }
1877
+
1878
+ private fun safeColor(hex: String): Int =
1879
+ try { Color.parseColor(hex) } catch (_: Exception) { Color.RED }
1880
+ }
1881
+ }