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