@quran.ws/qvp-react-native 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,148 @@
1
+ # @quran.ws/qvp-react-native
2
+
3
+ React Native wrapper for the QVP vector mushaf engine. Android-first: a native view
4
+ (`<QvpPageView />`) hosting the Kotlin library's `QvpPageView` (`net.quranpedia.qvp`,
5
+ `packages/android/qvp`, JNI over `crates/qvp-ffi/include/qvp.h`) plus a promise-based module
6
+ (`QvpModule`) for queries. The wrapper is *thin*: no hit-testing, layout or styling logic in JS or
7
+ in the module — it marshals props/targets/selectors and reconciles declarative arrays against engine
8
+ handles. Names follow `docs/API.md`. **The package ships no page data.**
9
+
10
+ ## Install
11
+
12
+ ```sh
13
+ npm install @quran.ws/qvp-react-native # or a file:/git dependency inside the monorepo
14
+ ```
15
+
16
+ Android needs the Kotlin library. Preferred: include it as a Gradle project in your app's
17
+ `android/settings.gradle` (this is what `../example` does):
18
+
19
+ ```groovy
20
+ include ':qvp'
21
+ project(':qvp').projectDir = file('<repo>/packages/android/qvp') // e.g. '../../../android/qvp'
22
+ ```
23
+
24
+ The module then depends on `project(':qvp')`. If `:qvp` is not included, the module compiles the
25
+ library's Kotlin + JNI sources itself from `qvpAndroidDir` (Gradle property; default
26
+ `../../../android/qvp`, right for the monorepo). Either way the engine comes from
27
+ `packages/android/qvp/prebuilt/<abi>/libqvp_ffi.a` — produce it once with
28
+ `scripts/build-engine-android.sh` from the repo root.
29
+
30
+ Requirements: RN ≥ 0.76 (legacy view manager / native module through the interop layer, verified on
31
+ 0.87 with the New Architecture), minSdk 24, NDK 27, CMake 3.22.
32
+
33
+ Page data: `cargo run -p qvp-convert --release -- batch pages dist/pages` → `NNN.qvp`,
34
+ `NNN.words.json`, `atlas.qva`. Put them wherever you like (assets, files dir, downloads) and pass
35
+ URIs: `asset://pages/042.qvp`, `file:///…`, `/abs/path`, or `base64:…`.
36
+
37
+ ## The view
38
+
39
+ ```tsx
40
+ import { QvpPageView, useQvp, Sel, T, LAYER, KIND, DECO, rgba } from '@quran.ws/qvp-react-native';
41
+
42
+ const qvp = useQvp();
43
+ <QvpPageView
44
+ ref={qvp.ref}
45
+ style={{ flex: 1 }}
46
+ pageUri="asset://pages/042.qvp"
47
+ wordsUri="asset://pages/042.words.json" // optional sidecar (rasm_imlai/qpc/rasm/search forms)
48
+ padTop={12} padBottom={12} padSide={8} // dp
49
+ lineSpacing={1} lineGap={0} fillHeight={false} // engine layout knobs (page units for lineGap)
50
+ paperColor="#fffdf7" defaultInk="#231f20"
51
+ theme={{ diacritics: '#1a73e8', dots: '#c62828', waqf: '#0a7d32', ms: 200 }} // page.theme(...) — one handle
52
+ styles={[
53
+ { id: 'hide-marks', selector: Sel.kind(KIND.MARK), hide: true }, // page.hide(sel)
54
+ { id: 'gold', selector: Sel.deco(DECO.AYAH_MARK), color: '#b8860b', ms: 300, layer: LAYER.THEME + 1 },
55
+ { id: 'mark', selector: Sel.wordMark(12, 1), color: '#ef6c00', ms: 200, layer: LAYER.TOP }, // 2nd diacritic of word 12
56
+ { id: 'ayah', target: '2:255', color: '#0a7d32' }, // page.styleTarget(...)
57
+ ]}
58
+ highlights={[
59
+ { id: 'sel', target: T.word(12), style: { mode: 'both', ink: '#1a73e8', band: rgba('#1a73e8', 0.18), radius: 1.5, ms: 250, layer: LAYER.SELECTION } },
60
+ { id: 'search', target: T.words([3, 9, 41]), style: { mode: 'both', ink: '#c62828', band: rgba('#c62828', 0.12), height: 'ink' } },
61
+ ]}
62
+ mask={{ target: '2:255', mode: 'hide' }} // or 'block' | 'blur' (+ blockColor, padX, padY, radius)
63
+ reveal={{ lit: 2, grey: '#c9c4b8', ink: '#231f20', ms: 150, at: 3 }} // greyed page; `at` drives revealGoto
64
+ onWordTap={({ word, hit }) => …} // word: {idx, surah, ayah, word, line, …, text, wordKey, aid, forms, label, paths[]}
65
+ onDecoTap={({ deco, hit }) => …} // ayah markers, banners, basmalah, rosettes, sajdah signs
66
+ onEmptyTap={() => …}
67
+ onSelectionChanged={({ words, text, citation, textWithCitation }) => …} // long-press-drag selection
68
+ onPageLoad={info => …} // {page, width, height, nLines, nAyahs, nWords, nPaths, nDecos, forms, loadMs, bytes}
69
+ onRevealChanged={({ steps, at }) => …}
70
+ onError={({ message }) => …}
71
+ />
72
+ ```
73
+
74
+ Reconciliation: each `highlights` entry maps to one engine handle. A new id → `highlight`; a changed
75
+ `target` → `rehighlight` (the band slides); a changed `style` → `restyleHighlight`; a removed id →
76
+ `unhighlight` (fades out). `styles` works the same over `style` / `styleTarget` / `hide` / `restyle` /
77
+ `unstyle`; `theme` is one handle; `mask` re-applies `maskOptions` + `mask` when the object changes
78
+ (`null` → `unmask`); `reveal` calls `revealStart` when its config changes and `revealGoto` when `at`
79
+ changes (`null` → `revealStop`). When `pageUri` changes, the old page is freed and every declarative
80
+ prop is re-applied to the new page.
81
+
82
+ Gestures are the Kotlin view's: tap → gap-aware `hitTestViewEx` → `onWordTap` / `onDecoTap` /
83
+ `onEmptyTap`; long-press-drag → engine `select` with a band in `LAYER.SELECTION`; pinch / pan;
84
+ double-tap resets. Props `selectionEnabled`, `zoomEnabled`, `hitMaxDistance`, `selectionBand`.
85
+
86
+ Colours: `'#rgb' | '#rrggbb' | '#rrggbbaa' | 0xRRGGBBAA` (`css()` / `rgba(c, alpha)` helpers).
87
+ Targets: `'page' | '2:255' | '2:255:3' | '2:255-257' | 'line:7' | 'surah:2' | wordIdx | [idx…] | T.*`.
88
+ Selectors: `Sel.page/path/wordPath/wordMark/wordMarkNamed/wordBody/wordMarks/word/ayah/line/mark/category/family/kind/deco/decoIdx`.
89
+
90
+ ## The module
91
+
92
+ Everything takes the view's react tag (`findNodeHandle`) — or use the bound forms:
93
+ `useQvp()` returns `{ ref, tag, ...api }`, and the component ref exposes the same `api`.
94
+
95
+ ```ts
96
+ const qvp = useQvp();
97
+ await qvp.info(); qvp.words(); qvp.word(i); qvp.ayahs(); qvp.lines(); qvp.decos();
98
+ await qvp.search('الرحمان', { mode: 'includes' }); // [{word, wordKey, text, index, loose}]
99
+ await qvp.text('2:255', { form: 'search', wordSep: ' ' });
100
+ await qvp.resolve('line:7'); qvp.findWord(2, 255, 3); qvp.wordForm(i, 'rasm_imlai'); qvp.hasForm('qpc');
101
+ await qvp.attachWords(jsonString); // when you do not use the wordsUri prop
102
+ await qvp.surahs(); qvp.divisions(); qvp.markers(); qvp.rosettes(); qvp.sajdahs(); qvp.ayahKeys();
103
+ await qvp.ayahWordCount(2, 255); qvp.reciteMap(2, 255, 4); qvp.wordLabel(i); qvp.ayahLabel(ai);
104
+ await qvp.citation([12, 13, 14]);
105
+ await qvp.cropSvg('2:255', { pad: 3, keepMarkers: true, background: '#fffdf7' }); qvp.cropBox(target);
106
+ await qvp.select(anchor, focus); qvp.clearSelection(); qvp.selection(); qvp.selectionText('rasm_uthmani', true);
107
+ await qvp.revealNext(1); qvp.hideBack(1); qvp.revealWord(i); qvp.hideWord(i); qvp.revealAll(); qvp.hideAll();
108
+ await qvp.maskHidden(); qvp.maskWords(); qvp.revealSteps(); qvp.revealAt(); qvp.revealStepOf(i);
109
+ await qvp.hitTestViewEx(x, y, { maxDistance: 6 }); qvp.wordBoxView(i); qvp.currentLayout(); qvp.relayout(); qvp.resetView(); qvp.stats();
110
+
111
+ // engine-wide (Qvp.*)
112
+ await Qvp.strip(s); Qvp.fold(s); Qvp.normalize(s); Qvp.looseKey(s); // = Qvp.arabic(kind, s)
113
+ await Qvp.gapToFill(pageW, pageH, lines, viewW, viewH); Qvp.wastedFraction(...)
114
+ Qvp.markName(7); Qvp.kindName(1); Qvp.categoryName(1); Qvp.familyName(3); Qvp.version
115
+
116
+ // atlas (cross-page)
117
+ const atlas = await QvpAtlas.load('asset://pages/atlas.qva');
118
+ await atlas.pageOf(2, 255); atlas.pageRange(42); atlas.surah(36); atlas.surahs(); atlas.pageOfSurah(36);
119
+ await atlas.juz(30); atlas.hizb(3); atlas.rubuAlHizb(7); atlas.juzAt(2, 255); atlas.divisionAt('hizb', 2, 255);
120
+ await atlas.pagesOfJuz(30); atlas.findSurah('cow' | 'البقرة' | '2'); atlas.free();
121
+ // tag-level equivalents: Qvp.atlasPageOf(id, s, a), Qvp.atlasFindSurah(id, text), Qvp.atlasPagesOfJuz(id, n), Qvp.atlasJuzAt(id, s, a), …
122
+ ```
123
+
124
+ All page calls run on the UI thread (where the view draws), so the engine is never touched from two
125
+ threads. Only `maskHidden`, `unmask` (via the `mask` prop), `revealNext` etc. mutate state; queries are
126
+ microseconds.
127
+
128
+ ## Layout of this package
129
+
130
+ ```
131
+ android/build.gradle library module; depends on project(':qvp') or compiles its sources
132
+ android/src/main/java/net/quranpedia/qvp/rn/
133
+ QvpRnPageView.kt QvpPageView subclass: page loading, prop → handle reconciliation, events
134
+ QvpPageViewManager.kt SimpleViewManager<QvpRnPageView> (props, events, commit after each prop batch)
135
+ QvpModule.kt ReactContextBaseJavaModule: page queries by react tag, engine helpers, atlas
136
+ Marshal.kt JS shapes ↔ Kotlin types (targets, selectors, styles, records)
137
+ QvpPackage.kt
138
+ src/index.tsx typed API: <QvpPageView/>, useQvp(), Qvp, QvpAtlas, Sel, T, constants
139
+ ```
140
+
141
+ ## iOS — TODO
142
+
143
+ Not implemented (no macOS in the build environment). The iOS side mirrors the Android module over the
144
+ same `qvp.h`: a Swift `QvpPage`/`QvpAtlas` over `libqvp_ffi.a` (build with
145
+ `cargo build -p qvp-ffi --release --target aarch64-apple-ios[-sim]`), a `UIView` renderer with the
146
+ same draw order (highlight bands → cached base ink → styled ink → mask boxes, `tick(now)` per
147
+ `CADisplayLink` frame), an `RCTViewManager` exposing the props above with the same reconciliation, and
148
+ an `RCTBridgeModule` with the same method names. `src/index.tsx` needs no changes.
@@ -0,0 +1,53 @@
1
+ // @quran.ws/qvp-react-native — Android module. Thin: marshalling + handle reconciliation over the Kotlin
2
+ // library `net.quranpedia.qvp` (packages/android/qvp), which is JNI over crates/qvp-ffi/include/qvp.h.
3
+ //
4
+ // Linking the Kotlin library, two ways:
5
+ // 1. (preferred) the app's settings.gradle includes it as a Gradle project:
6
+ // include(":qvp"); project(":qvp").projectDir = file("<repo>/packages/android/qvp")
7
+ // and this module depends on `project(":qvp")`.
8
+ // 2. otherwise this module compiles the library's Kotlin + JNI sources straight from `qvpAndroidDir`
9
+ // (gradle property; default: ../../../android/qvp inside the monorepo). Same code, one AAR.
10
+ buildscript {
11
+ if (project == rootProject) {
12
+ repositories { google(); mavenCentral() }
13
+ dependencies {
14
+ classpath("com.android.tools.build:gradle:8.7.3")
15
+ classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.21")
16
+ }
17
+ }
18
+ }
19
+
20
+ apply plugin: "com.android.library"
21
+ apply plugin: "org.jetbrains.kotlin.android"
22
+
23
+ def safeExtGet(prop, fallback) { rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback }
24
+ def qvpProject = findProject(":qvp")
25
+ def qvpDir = file(rootProject.findProperty("qvpAndroidDir") ?: "$projectDir/../../../android/qvp")
26
+
27
+ android {
28
+ namespace "net.quranpedia.qvp.rn"
29
+ compileSdk safeExtGet("compileSdkVersion", 35)
30
+ defaultConfig {
31
+ minSdkVersion safeExtGet("minSdkVersion", 24)
32
+ if (qvpProject == null) {
33
+ externalNativeBuild { cmake { arguments "-DANDROID_STL=none" } }
34
+ ndk { abiFilters "arm64-v8a", "x86_64", "armeabi-v7a" }
35
+ }
36
+ }
37
+ if (qvpProject == null) {
38
+ ndkVersion safeExtGet("ndkVersion", "27.2.12479018")
39
+ externalNativeBuild { cmake { path file("$qvpDir/src/main/cpp/CMakeLists.txt"); version "3.22.1" } }
40
+ sourceSets { main { kotlin.srcDirs += "$qvpDir/src/main/kotlin"; java.srcDirs += "$qvpDir/src/main/kotlin" } }
41
+ }
42
+ compileOptions { sourceCompatibility JavaVersion.VERSION_17; targetCompatibility JavaVersion.VERSION_17 }
43
+ buildFeatures { buildConfig false }
44
+ }
45
+ kotlin { compilerOptions { jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 } }
46
+
47
+ repositories { google(); mavenCentral() }
48
+
49
+ dependencies {
50
+ // version pinned by the React Native Gradle Plugin of the consuming app
51
+ implementation "com.facebook.react:react-android"
52
+ if (qvpProject != null) implementation project(":qvp")
53
+ }
@@ -0,0 +1 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -0,0 +1,191 @@
1
+ package net.quranpedia.qvp.rn
2
+
3
+ import com.facebook.react.bridge.Arguments
4
+ import com.facebook.react.bridge.ReadableArray
5
+ import com.facebook.react.bridge.ReadableMap
6
+ import com.facebook.react.bridge.WritableArray
7
+ import com.facebook.react.bridge.WritableMap
8
+ import net.quranpedia.qvp.*
9
+ import net.quranpedia.qvp.Target
10
+
11
+ /**
12
+ * Marshalling only: JS shapes (docs/API.md) ↔ Kotlin library types. No hit-testing, layout or
13
+ * styling decisions live here — every call goes to [QvpPage] / [QvpAtlas], which go to the engine.
14
+ *
15
+ * Targets from JS: "page" | "2:255" | "2:255:3" | "2:255-257" | "line:7" | "surah:2" | word index |
16
+ * [word indices] | {kind, a, b, c, words} (kind = QVP_TARGET_* or its name).
17
+ * Selectors from JS: {kind, a, b, c} (kind = QVP_SEL_* or its name). Colours: '#rgb' | '#rrggbb' | '#rrggbbaa'.
18
+ */
19
+ object Marshal {
20
+ // ── colours ──
21
+ fun color(v: Any?, default: Int): Int = when (v) {
22
+ null -> default
23
+ is String -> if (v.isBlank()) default else runCatching { QvpColor.parse(v) }.getOrDefault(default)
24
+ is Number -> v.toLong().toInt()
25
+ else -> default
26
+ }
27
+ fun colorOrNull(v: Any?): Int? = if (v == null) null else color(v, 0).let { if (it == 0) null else it }
28
+
29
+ private fun num(v: Any?): Int? = (v as? Number)?.toInt() ?: (v as? String)?.toIntOrNull()
30
+ private fun flt(v: Any?, d: Float): Float = (v as? Number)?.toFloat() ?: (v as? String)?.toFloatOrNull() ?: d
31
+ private fun bool(v: Any?, d: Boolean): Boolean = (v as? Boolean) ?: (v as? Number)?.let { it.toInt() != 0 } ?: d
32
+ private fun ints(v: Any?): IntArray = when (v) {
33
+ is Iterable<*> -> v.mapNotNull { num(it) }.toIntArray()
34
+ is IntArray -> v
35
+ is Number -> intArrayOf(v.toInt())
36
+ else -> IntArray(0)
37
+ }
38
+
39
+ private val TARGET_KINDS = listOf("page", "word", "words", "ayah", "ayahRange", "line", "surah", "range")
40
+ private val SEL_KINDS = listOf("page", "path", "wordPath", "wordMark", "wordMarkNamed", "wordBody", "wordMarks", "word", "ayah", "line", "mark", "category", "family", "kind", "deco", "decoIdx")
41
+ private fun kindOf(v: Any?, names: List<String>): Int = when (v) {
42
+ is Number -> v.toInt()
43
+ is String -> names.indexOf(v).let { if (it >= 0) it else v.toIntOrNull() ?: -1 }
44
+ else -> -1
45
+ }
46
+
47
+ /** JS target (already converted with toHashMap/toArrayList or a plain value) → [Target]. Unknown → empty word list. */
48
+ fun target(v: Any?, page: QvpPage?): Target = when (v) {
49
+ null -> Target.page()
50
+ is String -> runCatching { Target.parse(v, page) }.getOrElse { Target.words(IntArray(0)) }
51
+ is Number -> Target.word(v.toInt())
52
+ is Iterable<*> -> Target.words(ints(v))
53
+ is IntArray -> Target.words(v)
54
+ is Map<*, *> -> {
55
+ val a = num(v["a"]) ?: 0; val b = num(v["b"]) ?: 0; val c = num(v["c"]) ?: 0
56
+ when (kindOf(v["kind"], TARGET_KINDS)) {
57
+ 0 -> Target.page(); 1 -> Target.word(a); 2 -> Target.words(ints(v["words"] ?: v["a"]))
58
+ 3 -> Target.ayah(a, b); 4 -> Target.ayahRange(a, b, c); 5 -> Target.line(a); 6 -> Target.surah(a); 7 -> Target.range(a, b)
59
+ else -> Target.words(IntArray(0))
60
+ }
61
+ }
62
+ else -> Target.words(IntArray(0))
63
+ }
64
+
65
+ /** JS selector {kind, a, b, c} → [Selector], or null when malformed. */
66
+ fun selector(v: Any?): Selector? {
67
+ val m = v as? Map<*, *> ?: return null
68
+ val a = num(m["a"]) ?: 0; val b = num(m["b"]) ?: 0; val c = num(m["c"]) ?: 0
69
+ return when (kindOf(m["kind"], SEL_KINDS)) {
70
+ 0 -> Selector.page(); 1 -> Selector.path(a); 2 -> Selector.wordPath(a, b); 3 -> Selector.wordMark(a, b)
71
+ 4 -> Selector.wordMarkNamed(a, (m["mark"] as? String) ?: QVP_MARKS.getOrElse(b) { "" }, c)
72
+ 5 -> Selector.wordBody(a); 6 -> Selector.wordMarks(a); 7 -> Selector.word(a); 8 -> Selector.ayah(a, b); 9 -> Selector.line(a)
73
+ 10 -> (m["mark"] as? String)?.let { Selector.mark(it) } ?: Selector.mark(a)
74
+ 11 -> Selector.category(a); 12 -> Selector.family(a); 13 -> Selector.kind(a); 14 -> Selector.deco(a); 15 -> Selector.decoIdx(a)
75
+ else -> null
76
+ }
77
+ }
78
+
79
+ fun form(v: Any?, d: Form = Form.RASM_UTHMANI): Form = when (v) {
80
+ is Number -> Form.entries.getOrElse(v.toInt()) { d }
81
+ is String -> Form.entries.firstOrNull { it.name.equals(v, true) } ?: d
82
+ else -> d
83
+ }
84
+ fun searchMode(v: Any?): SearchMode = when (v) {
85
+ is Number -> SearchMode.entries.getOrElse(v.toInt()) { SearchMode.INCLUDES }
86
+ is String -> SearchMode.entries.firstOrNull { it.name.equals(v, true) } ?: SearchMode.INCLUDES
87
+ else -> SearchMode.INCLUDES
88
+ }
89
+ fun maskMode(v: Any?): MaskMode = when (v) {
90
+ is Number -> MaskMode.entries.getOrElse(v.toInt()) { MaskMode.HIDE }
91
+ is String -> MaskMode.entries.firstOrNull { it.name.equals(v, true) } ?: MaskMode.HIDE
92
+ else -> MaskMode.HIDE
93
+ }
94
+ fun division(v: Any?): Division = when (v) {
95
+ is Number -> Division.entries.getOrElse(v.toInt()) { Division.JUZ }
96
+ is String -> Division.entries.firstOrNull { it.name.equals(v, true) } ?: Division.JUZ
97
+ else -> Division.JUZ
98
+ }
99
+
100
+ /** {mode, ink, band, height, padX, padY, radius, seam, ms, layer} → [QvpHighlightStyle] (missing keys keep the library defaults). */
101
+ fun highlightStyle(v: Any?): QvpHighlightStyle {
102
+ val m = v as? Map<*, *> ?: return QvpHighlightStyle()
103
+ val d = QvpHighlightStyle()
104
+ val mode = when (val x = m["mode"]) { is Number -> HighlightMode.entries.getOrElse(x.toInt()) { d.mode }; is String -> HighlightMode.entries.firstOrNull { it.name.equals(x, true) } ?: d.mode; else -> d.mode }
105
+ val height = when (val x = m["height"]) { is Number -> BandHeight.entries.getOrElse(x.toInt()) { d.height }; is String -> BandHeight.entries.firstOrNull { it.name.equals(x, true) } ?: d.height; else -> d.height }
106
+ return QvpHighlightStyle(mode = mode, ink = color(m["ink"], d.ink), band = color(m["band"], d.band), height = height, padX = flt(m["padX"], d.padX), padY = flt(m["padY"], d.padY),
107
+ radius = flt(m["radius"], d.radius), seam = flt(m["seam"], d.seam), transitionMs = num(m["ms"] ?: m["transitionMs"]) ?: d.transitionMs, layer = num(m["layer"]) ?: d.layer)
108
+ }
109
+ /** {ink, diacritics, dots, waqf, sifr, ayahMark, numeral, headers, marks: {name: colour}, ms} → [QvpTheme]. */
110
+ fun theme(v: Any?): QvpTheme {
111
+ val m = v as? Map<*, *> ?: return QvpTheme()
112
+ val marks = (m["marks"] as? Map<*, *>)?.entries?.mapNotNull { (k, c) -> colorOrNull(c)?.let { (k as String) to it } }?.toMap() ?: emptyMap()
113
+ return QvpTheme(ink = colorOrNull(m["ink"]), diacritics = colorOrNull(m["diacritics"]), dots = colorOrNull(m["dots"]), waqf = colorOrNull(m["waqf"]), sifr = colorOrNull(m["sifr"]),
114
+ ayahMark = colorOrNull(m["ayahMark"]), numeral = colorOrNull(m["numeral"]), headers = colorOrNull(m["headers"]), marks = marks, transitionMs = num(m["ms"] ?: m["transitionMs"]) ?: 0)
115
+ }
116
+
117
+ // ── Kotlin → JS ──
118
+ fun word(p: QvpPage, w: QvpWord): Map<String, Any?> {
119
+ val forms = LinkedHashMap<String, String>()
120
+ for (f in Form.entries) if (f == Form.RASM_UTHMANI || p.hasForm(f)) forms[f.name.lowercase()] = p.wordForm(w.idx, f)
121
+ val paths = (w.firstPath until w.firstPath + w.nPaths).map { i ->
122
+ val kind = p.pathKind(i); val mark = p.pathMark(i); val cat = p.pathCategory(i); val fam = p.pathFamily(i)
123
+ mapOf("idx" to i, "kind" to kind, "kindName" to QvpEngine.kindName(kind), "mark" to mark, "markName" to QvpEngine.markName(mark), "nthMark" to p.pathNthMark(i),
124
+ "nthInWord" to p.pathNthInWord(i), "category" to cat, "categoryName" to QvpEngine.categoryName(cat), "family" to fam, "familyName" to QvpEngine.familyName(fam), "line" to p.pathLine(i))
125
+ }
126
+ return mapOf("idx" to w.idx, "surah" to w.surah, "ayah" to w.ayah, "word" to w.word, "line" to w.line, "lineIdx" to w.lineIdx, "ayahIdx" to w.ayahIdx,
127
+ "x0" to w.x0, "y0" to w.y0, "x1" to w.x1, "y1" to w.y1, "text" to w.text, "firstPath" to w.firstPath, "nPaths" to w.nPaths,
128
+ "wordKey" to w.wordKey, "ayahKey" to w.ayahKey, "forms" to forms, "label" to p.wordLabel(w.idx), "paths" to paths)
129
+ }
130
+ fun deco(d: QvpDecoration): Map<String, Any?> = mapOf("idx" to d.idx, "kind" to d.kind, "kindName" to listOf("ayah-ayahMark", "surah-name", "basmalah", "division-mark", "sajdah-mark").getOrElse(d.kind) { "other" },
131
+ "surah" to d.surah, "ayah" to d.ayah, "line" to d.line, "x0" to d.x0, "y0" to d.y0, "x1" to d.x1, "y1" to d.y1, "text" to d.text, "firstPath" to d.firstPath, "nPaths" to d.nPaths)
132
+ fun hit(p: QvpPage, h: QvpHitEx): Map<String, Any?> {
133
+ val w = if (h.word >= 0) p.words[h.word] else null
134
+ return mapOf("word" to h.word, "path" to h.path, "deco" to h.deco, "line" to h.line, "distance" to h.distance, "exact" to h.exact, "wordKey" to w?.wordKey, "ayahKey" to w?.ayahKey)
135
+ }
136
+ fun ayah(a: QvpAyah): Map<String, Any?> = mapOf("idx" to a.idx, "surah" to a.surah, "ayah" to a.ayah, "fragment" to a.fragment, "fragments" to a.fragments, "flags" to a.flags, "rubuAlHizb" to a.rubuAlHizb, "firstWord" to a.firstWord, "nWords" to a.nWords,
137
+ "ayahMarkDeco" to a.ayahMarkDeco, "bbox" to listOf(a.x0, a.y0, a.x1, a.y1))
138
+ fun line(l: QvpLine): Map<String, Any?> = mapOf("idx" to l.idx, "lineNo" to l.lineNo, "isHeader" to l.isHeader, "firstWord" to l.firstWord, "nWords" to l.nWords, "bbox" to listOf(l.x0, l.y0, l.x1, l.y1),
139
+ "bandY0" to l.bandY0, "bandY1" to l.bandY1, "centre" to l.centre)
140
+ fun surah(s: QvpSurah): Map<String, Any?> = mapOf("number" to s.number, "ayahCount" to s.ayahCount, "hasBanner" to s.hasBanner, "hasBasmalah" to s.hasBasmalah, "place" to s.place, "bannerDeco" to s.bannerDeco,
141
+ "arabic" to s.arabic, "latin" to s.latin, "english" to s.english)
142
+ fun division(d: QvpDivision): Map<String, Any?> = mapOf("kind" to d.kind.name.lowercase(), "n" to d.n, "surah" to d.surah, "ayah" to d.ayah, "line" to d.line, "ayahIdx" to d.ayahIdx)
143
+ fun ayahMark(m: QvpAyahMark): Map<String, Any?> = mapOf("deco" to m.deco, "surah" to m.surah, "ayah" to m.ayah, "line" to m.line, "cx" to m.cx, "cy" to m.cy, "r" to m.r, "ornamentPath" to m.ornamentPath, "numeralPath" to m.numeralPath)
144
+ fun rosette(r: QvpRosette): Map<String, Any?> = mapOf("deco" to r.deco, "surah" to r.surah, "ayah" to r.ayah, "juz" to r.juz, "hizb" to r.hizb, "nisf" to r.nisf, "rubuAlHizb" to r.rubuAlHizb, "rubuAlHizbInHizb" to r.rubuAlHizbInHizb)
145
+ fun sajdah(s: QvpSajdah): Map<String, Any?> = mapOf("deco" to s.deco, "surah" to s.surah, "ayah" to s.ayah, "signPath" to s.signPath)
146
+ fun match(m: QvpMatch): Map<String, Any?> = mapOf("word" to m.word, "index" to m.index, "loose" to m.loose, "wordKey" to m.wordKey, "text" to m.text)
147
+ fun cropBox(c: QvpCropBox): Map<String, Any?> = mapOf("x0" to c.x0, "y0" to c.y0, "x1" to c.x1, "y1" to c.y1, "nWords" to c.nWords, "ayahMarkDeco" to c.ayahMarkDeco)
148
+ fun atlasSurah(s: QvpAtlasSurah): Map<String, Any?> = mapOf("n" to s.n, "number" to s.n, "page" to s.page, "ayahCount" to s.ayahCount, "place" to s.place, "arabic" to s.arabic, "latin" to s.latin, "english" to s.english)
149
+ fun atlasRubuAlHizb(r: QvpAtlasRubuAlHizb): Map<String, Any?> = mapOf("rubuAlHizb" to r.rubuAlHizb, "surah" to r.surah, "ayah" to r.ayah, "page" to r.page, "ayahKey" to r.ayahKey)
150
+ fun layout(l: QvpLayout): Map<String, Any?> = mapOf("scale" to l.scale, "ox" to l.ox, "oy" to l.oy, "contentW" to l.contentW, "contentH" to l.contentH, "pitch" to l.pitch, "lineDy" to l.lineDy.toList(),
151
+ "slots" to l.slotTop.indices.map { listOf(l.slotTop[it], l.slotBottom[it]) })
152
+ fun pageInfo(p: QvpPage): Map<String, Any?> = mapOf("page" to p.pageNo, "width" to p.width, "height" to p.height, "nLines" to p.nLines, "nAyahs" to p.nAyahs, "nWords" to p.nWords, "nPaths" to p.nPaths, "nDecos" to p.nDecos,
153
+ "naturalPitch" to p.naturalPitch, "forms" to Form.entries.filter { it == Form.RASM_UTHMANI || p.hasForm(it) }.map { it.name.lowercase() })
154
+ fun selection(p: QvpPage): Map<String, Any?> {
155
+ val ws = p.selection()
156
+ return mapOf("words" to ws.toList(), "text" to (if (ws.isEmpty()) "" else p.text(Target.words(ws))), "citation" to (if (ws.isEmpty()) "" else p.citation(ws)),
157
+ "textWithCitation" to (if (ws.isEmpty()) "" else p.selectionText(Form.RASM_UTHMANI, true)))
158
+ }
159
+
160
+ /** Any Kotlin value (maps, lists, arrays, primitives) → a bridge value. */
161
+ fun toJs(v: Any?): Any? = when (v) {
162
+ null -> null
163
+ is Boolean, is String, is Int, is Double -> v
164
+ is Float -> v.toDouble(); is Long -> v.toDouble(); is Short -> v.toInt(); is Byte -> v.toInt()
165
+ is Map<*, *> -> toMap(v)
166
+ is Iterable<*> -> toArray(v)
167
+ is IntArray -> toArray(v.toList()); is FloatArray -> toArray(v.toList()); is Array<*> -> toArray(v.toList())
168
+ is Pair<*, *> -> toArray(listOf(v.first, v.second))
169
+ is Enum<*> -> v.name.lowercase()
170
+ else -> v.toString()
171
+ }
172
+ fun toMap(m: Map<*, *>): WritableMap {
173
+ val out = Arguments.createMap()
174
+ for ((k, v) in m) put(out, k.toString(), toJs(v))
175
+ return out
176
+ }
177
+ fun toArray(l: Iterable<*>): WritableArray {
178
+ val out = Arguments.createArray()
179
+ for (v in l) when (val j = toJs(v)) {
180
+ null -> out.pushNull(); is Boolean -> out.pushBoolean(j); is Int -> out.pushInt(j); is Double -> out.pushDouble(j); is String -> out.pushString(j)
181
+ is WritableMap -> out.pushMap(j); is WritableArray -> out.pushArray(j); else -> out.pushString(j.toString())
182
+ }
183
+ return out
184
+ }
185
+ private fun put(out: WritableMap, k: String, j: Any?) { when (j) {
186
+ null -> out.putNull(k); is Boolean -> out.putBoolean(k, j); is Int -> out.putInt(k, j); is Double -> out.putDouble(k, j); is String -> out.putString(k, j)
187
+ is WritableMap -> out.putMap(k, j); is WritableArray -> out.putArray(k, j); else -> out.putString(k, j.toString())
188
+ } }
189
+ fun plain(m: ReadableMap?): HashMap<String, Any?>? = m?.toHashMap()
190
+ fun plain(a: ReadableArray?): ArrayList<Any?>? = a?.toArrayList()
191
+ }
@@ -0,0 +1,149 @@
1
+ package net.quranpedia.qvp.rn
2
+
3
+ import com.facebook.react.bridge.*
4
+ import com.facebook.react.module.annotations.ReactModule
5
+ import com.facebook.react.uimanager.UIManagerHelper
6
+ import net.quranpedia.qvp.*
7
+ import net.quranpedia.qvp.Target
8
+
9
+ /**
10
+ * Promise-based queries on the page behind a `<QvpPageView />` (looked up by react tag) plus engine-wide
11
+ * helpers and the atlas. Everything runs on the UI thread, where the view draws, so the engine is never
12
+ * touched from two threads. Names follow docs/API.md.
13
+ */
14
+ @ReactModule(name = QvpModule.NAME)
15
+ class QvpModule(private val ctx: ReactApplicationContext) : ReactContextBaseJavaModule(ctx) {
16
+ override fun getName() = NAME
17
+ private val atlases = HashMap<Int, QvpAtlas>()
18
+ private val atlasByUri = HashMap<String, Int>()
19
+ private var nextAtlas = 1
20
+
21
+ private fun view(tag: Int): QvpRnPageView? = QvpRegistry.get(tag) ?: runCatching { UIManagerHelper.getUIManagerForReactTag(ctx, tag)?.resolveView(tag) as? QvpRnPageView }.getOrNull()
22
+ // Every @ReactMethod must be `void` for the TurboModule interop layer: these helpers return Unit on purpose.
23
+ private fun ui(promise: Promise, block: () -> Any?) {
24
+ UiThreadUtil.runOnUiThread {
25
+ try { promise.resolve(Marshal.toJs(block())) } catch (e: Throwable) { promise.reject("qvp", e.message ?: e.toString(), e) }
26
+ }
27
+ }
28
+ private fun withPage(tag: Int, promise: Promise, block: (QvpRnPageView, QvpPage) -> Any?) {
29
+ ui(promise) {
30
+ val v = view(tag) ?: throw IllegalStateException("no QvpPageView with tag $tag")
31
+ val p = v.page ?: throw IllegalStateException("QvpPageView $tag has no page loaded")
32
+ block(v, p)
33
+ }
34
+ }
35
+ private fun withAtlas(id: Int, promise: Promise, block: (QvpAtlas) -> Any?) { ui(promise) { block(atlases[id] ?: throw IllegalStateException("no atlas $id")) } }
36
+ private fun opt(m: ReadableMap?): Map<String, Any?> = m?.toHashMap() ?: emptyMap()
37
+ private fun tgt(d: Dynamic, p: QvpPage): Target = Marshal.target(when (d.type) {
38
+ ReadableType.String -> d.asString(); ReadableType.Number -> d.asDouble(); ReadableType.Array -> d.asArray()?.toArrayList(); ReadableType.Map -> d.asMap()?.toHashMap(); else -> null }, p)
39
+ private fun ints(a: ReadableArray?): IntArray = a?.toArrayList()?.mapNotNull { (it as? Number)?.toInt() }?.toIntArray() ?: IntArray(0)
40
+
41
+ // ── page facts ──
42
+ @ReactMethod fun info(tag: Int, promise: Promise) = withPage(tag, promise) { v, p -> Marshal.pageInfo(p) + mapOf("loadMs" to v.loadMs, "bytes" to v.pageBytes) }
43
+ @ReactMethod fun words(tag: Int, promise: Promise) = withPage(tag, promise) { _, p -> p.words.map { Marshal.word(p, it) } }
44
+ @ReactMethod fun word(tag: Int, idx: Int, promise: Promise) = withPage(tag, promise) { _, p -> p.words.getOrNull(idx)?.let { Marshal.word(p, it) } }
45
+ @ReactMethod fun ayahs(tag: Int, promise: Promise) = withPage(tag, promise) { _, p -> p.ayahs.map { Marshal.ayah(it) } }
46
+ @ReactMethod fun lines(tag: Int, promise: Promise) = withPage(tag, promise) { _, p -> p.lines.map { Marshal.line(it) } }
47
+ @ReactMethod fun decos(tag: Int, promise: Promise) = withPage(tag, promise) { _, p -> p.decos.map { Marshal.deco(it) } }
48
+ @ReactMethod fun findWord(tag: Int, s: Int, a: Int, w: Int, promise: Promise) = withPage(tag, promise) { _, p -> p.findWord(s, a, w) }
49
+ @ReactMethod fun resolve(tag: Int, target: Dynamic, promise: Promise) = withPage(tag, promise) { _, p -> p.resolve(tgt(target, p)).toList() }
50
+ @ReactMethod fun wordForm(tag: Int, idx: Int, form: String?, promise: Promise) = withPage(tag, promise) { _, p -> p.wordForm(idx, Marshal.form(form)) }
51
+ @ReactMethod fun hasForm(tag: Int, form: String?, promise: Promise) = withPage(tag, promise) { _, p -> p.hasForm(Marshal.form(form)) }
52
+ @ReactMethod fun attachWords(tag: Int, json: String, promise: Promise) = withPage(tag, promise) { _, p -> p.attachWords(json) }
53
+
54
+ // ── metadata ──
55
+ @ReactMethod fun surahs(tag: Int, promise: Promise) = withPage(tag, promise) { _, p -> p.surahs().map { Marshal.surah(it) } }
56
+ @ReactMethod fun divisions(tag: Int, promise: Promise) = withPage(tag, promise) { _, p -> p.divisions().map { Marshal.division(it) } }
57
+ @ReactMethod fun ayahMarks(tag: Int, promise: Promise) = withPage(tag, promise) { _, p -> p.ayahMarks().map { Marshal.ayahMark(it) } }
58
+ @ReactMethod fun rosettes(tag: Int, promise: Promise) = withPage(tag, promise) { _, p -> p.rosettes().map { Marshal.rosette(it) } }
59
+ @ReactMethod fun sajdahs(tag: Int, promise: Promise) = withPage(tag, promise) { _, p -> p.sajdahs().map { Marshal.sajdah(it) } }
60
+ @ReactMethod fun ayahKeys(tag: Int, promise: Promise) = withPage(tag, promise) { _, p -> p.ayahKeys().map { mapOf("surah" to it.first, "ayah" to it.second, "ayahKey" to "${it.first}:${it.second}") } }
61
+ @ReactMethod fun ayahWordCount(tag: Int, s: Int, a: Int, promise: Promise) = withPage(tag, promise) { _, p -> p.ayahWordCount(s, a).let { mapOf("count" to it.first, "complete" to it.second) } }
62
+ @ReactMethod fun reciteMap(tag: Int, s: Int, a: Int, n: Int, promise: Promise) = withPage(tag, promise) { _, p -> p.reciteMap(s, a, n)?.toList() }
63
+ @ReactMethod fun wordLabel(tag: Int, i: Int, promise: Promise) = withPage(tag, promise) { _, p -> p.wordLabel(i) }
64
+ @ReactMethod fun ayahLabel(tag: Int, i: Int, promise: Promise) = withPage(tag, promise) { _, p -> p.ayahLabel(i) }
65
+
66
+ // ── text & search ──
67
+ @ReactMethod fun text(tag: Int, target: Dynamic, opts: ReadableMap?, promise: Promise) = withPage(tag, promise) { _, p ->
68
+ val o = opt(opts); p.text(tgt(target, p), Marshal.form(o["form"]), (o["wordSep"] as? String) ?: " ", (o["lineSep"] as? String) ?: "\n") }
69
+ @ReactMethod fun search(tag: Int, query: String, opts: ReadableMap?, promise: Promise) = withPage(tag, promise) { _, p ->
70
+ val o = opt(opts)
71
+ p.search(query, Marshal.form(o["form"], Form.SEARCH), Marshal.searchMode(o["mode"]), o["normalize"] != false, o["loose"] != false, (o["limit"] as? Number)?.toInt() ?: 0).map { Marshal.match(it) } }
72
+ @ReactMethod fun citation(tag: Int, words: ReadableArray, promise: Promise) = withPage(tag, promise) { _, p -> p.citation(ints(words)) }
73
+ @ReactMethod fun arabic(kind: String, s: String, promise: Promise) = ui(promise) { when (kind) { "strip" -> QvpEngine.strip(s); "fold" -> QvpEngine.fold(s); "normalize" -> QvpEngine.normalize(s); "loose", "looseKey" -> QvpEngine.looseKey(s); else -> s } }
74
+
75
+ // ── hit testing / layout (the view already does gestures; these are for scroll-into-view etc.) ──
76
+ @ReactMethod fun hitTestViewEx(tag: Int, x: Double, y: Double, opts: ReadableMap?, promise: Promise) = withPage(tag, promise) { v, p ->
77
+ val o = opt(opts); val d = v.resources.displayMetrics.density
78
+ p.hitTestViewEx(((x * d).toFloat() - v.inner.viewOx) / v.inner.viewScale, ((y * d).toFloat() - v.inner.viewOy) / v.inner.viewScale, QvpHitOptions((o["maxDistance"] as? Number)?.toFloat() ?: 6f, (o["gapBias"] as? Number)?.toFloat() ?: 0.6f, o["exactFirst"] != false))?.let { Marshal.hit(p, it) } }
79
+ @ReactMethod fun wordBoxView(tag: Int, i: Int, promise: Promise) = withPage(tag, promise) { v, p -> val d = v.resources.displayMetrics.density
80
+ p.wordBoxView(i)?.let { b -> mapOf("x0" to (v.inner.viewOx + b[0] * v.inner.viewScale) / d, "y0" to (v.inner.viewOy + b[1] * v.inner.viewScale) / d, "x1" to (v.inner.viewOx + b[2] * v.inner.viewScale) / d, "y1" to (v.inner.viewOy + b[3] * v.inner.viewScale) / d) } }
81
+ @ReactMethod fun currentLayout(tag: Int, promise: Promise) = withPage(tag, promise) { _, p -> p.currentLayout?.let { Marshal.layout(it) } }
82
+ @ReactMethod fun relayout(tag: Int, promise: Promise) = withPage(tag, promise) { v, _ -> v.inner.relayout(); v.resetView(); null }
83
+ @ReactMethod fun resetView(tag: Int, promise: Promise) = withPage(tag, promise) { v, _ -> v.resetView(); null }
84
+ @ReactMethod fun gapToFill(pw: Double, ph: Double, lines: Int, vw: Double, vh: Double, max: Double, promise: Promise) = ui(promise) { QvpEngine.gapToFill(pw.toFloat(), ph.toFloat(), lines, vw.toFloat(), vh.toFloat(), max.toFloat()) }
85
+ @ReactMethod fun wastedFraction(pw: Double, ph: Double, vw: Double, vh: Double, promise: Promise) = ui(promise) { QvpEngine.wastedFraction(pw.toFloat(), ph.toFloat(), vw.toFloat(), vh.toFloat()) }
86
+ @ReactMethod fun stats(tag: Int, promise: Promise) = ui(promise) { view(tag)?.stats() }
87
+ @ReactMethod fun invalidate(tag: Int, promise: Promise) = ui(promise) { view(tag)?.inner?.invalidate(); null }
88
+
89
+ // ── selection ──
90
+ @ReactMethod fun select(tag: Int, anchor: Int, focus: Int, promise: Promise) = withPage(tag, promise) { v, p -> p.select(anchor, focus); v.inner.invalidate(); Marshal.selection(p) }
91
+ @ReactMethod fun clearSelection(tag: Int, promise: Promise) = withPage(tag, promise) { v, _ -> v.inner.clearSelection(); null }
92
+ @ReactMethod fun selection(tag: Int, promise: Promise) = withPage(tag, promise) { _, p -> Marshal.selection(p) }
93
+ @ReactMethod fun selectionText(tag: Int, form: String?, citation: Boolean, promise: Promise) = withPage(tag, promise) { _, p -> p.selectionText(Marshal.form(form), citation) }
94
+
95
+ // ── memorisation (stepwise ops; `mask` / `reveal` props hold the declarative part) ──
96
+ @ReactMethod fun revealNext(tag: Int, n: Int, promise: Promise) = withPage(tag, promise) { v, p -> p.revealNext(n).also { v.inner.invalidate() } }
97
+ @ReactMethod fun hideBack(tag: Int, n: Int, promise: Promise) = withPage(tag, promise) { v, p -> p.hideBack(n).also { v.inner.invalidate() } }
98
+ @ReactMethod fun revealWord(tag: Int, i: Int, promise: Promise) = withPage(tag, promise) { v, p -> p.revealWord(i).also { v.inner.invalidate() } }
99
+ @ReactMethod fun hideWord(tag: Int, i: Int, promise: Promise) = withPage(tag, promise) { v, p -> p.hideWord(i).also { v.inner.invalidate() } }
100
+ @ReactMethod fun revealAll(tag: Int, promise: Promise) = withPage(tag, promise) { v, p -> p.revealAll(); v.inner.invalidate(); null }
101
+ @ReactMethod fun hideAll(tag: Int, promise: Promise) = withPage(tag, promise) { v, p -> p.hideAll(); v.inner.invalidate(); null }
102
+ @ReactMethod fun maskHidden(tag: Int, promise: Promise) = withPage(tag, promise) { _, p -> p.maskHidden().toList() }
103
+ @ReactMethod fun maskWords(tag: Int, promise: Promise) = withPage(tag, promise) { _, p -> p.maskWords().toList() }
104
+ @ReactMethod fun revealSteps(tag: Int, promise: Promise) = withPage(tag, promise) { _, p -> p.revealSteps() }
105
+ @ReactMethod fun revealAt(tag: Int, promise: Promise) = withPage(tag, promise) { _, p -> p.revealAt()?.toDouble() }
106
+ @ReactMethod fun revealStepOf(tag: Int, i: Int, promise: Promise) = withPage(tag, promise) { _, p -> p.revealStepOf(i).toDouble() }
107
+
108
+ // ── crop ──
109
+ @ReactMethod fun cropBox(tag: Int, target: Dynamic, opts: ReadableMap?, promise: Promise) = withPage(tag, promise) { _, p ->
110
+ val o = opt(opts); p.cropBox(tgt(target, p), (o["pad"] as? Number)?.toFloat() ?: 2f, o["keepAyahMarks"] != false)?.let { Marshal.cropBox(it) } }
111
+ @ReactMethod fun cropSvg(tag: Int, target: Dynamic, opts: ReadableMap?, promise: Promise) = withPage(tag, promise) { _, p ->
112
+ val o = opt(opts); p.cropSvg(tgt(target, p), (o["pad"] as? Number)?.toFloat() ?: 2f, o["keepAyahMarks"] != false, Marshal.color(o["background"], 0)) }
113
+
114
+ // ── atlas ──
115
+ @ReactMethod fun loadAtlas(uri: String, promise: Promise) = ui(promise) {
116
+ atlasByUri[uri] ?: run {
117
+ val bytes = when {
118
+ uri.startsWith("asset://") -> ctx.assets.open(uri.removePrefix("asset://")).use { it.readBytes() }
119
+ uri.startsWith("asset:") -> ctx.assets.open(uri.removePrefix("asset:")).use { it.readBytes() }
120
+ uri.startsWith("file://") -> java.io.File(uri.removePrefix("file://")).readBytes()
121
+ uri.startsWith("/") -> java.io.File(uri).readBytes()
122
+ uri.startsWith("base64:") -> android.util.Base64.decode(uri.removePrefix("base64:"), android.util.Base64.DEFAULT)
123
+ else -> ctx.assets.open(uri).use { it.readBytes() }
124
+ }
125
+ val id = nextAtlas++; atlases[id] = QvpAtlas(bytes); atlasByUri[uri] = id; id
126
+ }
127
+ }
128
+ @ReactMethod fun freeAtlas(id: Int, promise: Promise) = ui(promise) { atlases.remove(id)?.close(); atlasByUri.entries.removeAll { it.value == id }; null }
129
+ @ReactMethod fun atlasPageOf(id: Int, s: Int, a: Int, promise: Promise) = withAtlas(id, promise) { it.pageOf(s, a) }
130
+ @ReactMethod fun atlasPageRange(id: Int, page: Int, promise: Promise) = withAtlas(id, promise) { it.pageRange(page)?.let { r -> mapOf("first" to mapOf("surah" to r.first.first, "ayah" to r.first.second), "last" to mapOf("surah" to r.second.first, "ayah" to r.second.second)) } }
131
+ @ReactMethod fun atlasPages(id: Int, promise: Promise) = withAtlas(id, promise) { it.pages() }
132
+ @ReactMethod fun atlasSurah(id: Int, n: Int, promise: Promise) = withAtlas(id, promise) { it.surah(n)?.let { s -> Marshal.atlasSurah(s) } }
133
+ @ReactMethod fun atlasSurahs(id: Int, promise: Promise) = withAtlas(id, promise) { it.surahs().map { s -> Marshal.atlasSurah(s) } }
134
+ @ReactMethod fun atlasPageOfSurah(id: Int, n: Int, promise: Promise) = withAtlas(id, promise) { it.pageOfSurah(n) }
135
+ @ReactMethod fun atlasDivision(id: Int, kind: String, n: Int, promise: Promise) = withAtlas(id, promise) { it.division(Marshal.division(kind), n)?.let { r -> Marshal.atlasRubuAlHizb(r) } }
136
+ @ReactMethod fun atlasDivisionAt(id: Int, kind: String, s: Int, a: Int, promise: Promise) = withAtlas(id, promise) { it.divisionAt(Marshal.division(kind), s, a) }
137
+ @ReactMethod fun atlasJuzAt(id: Int, s: Int, a: Int, promise: Promise) = withAtlas(id, promise) { it.juzAt(s, a) }
138
+ @ReactMethod fun atlasPagesOfJuz(id: Int, n: Int, promise: Promise) = withAtlas(id, promise) { it.pagesOfJuz(n)?.let { r -> listOf(r.first, r.second) } }
139
+ @ReactMethod fun atlasFindSurah(id: Int, text: String, promise: Promise) = withAtlas(id, promise) { it.findSurah(text).map { s -> Marshal.atlasSurah(s) } }
140
+
141
+ // ── names ──
142
+ @ReactMethod fun markName(m: Int, promise: Promise) = ui(promise) { QvpEngine.markName(m) }
143
+ @ReactMethod fun markFromName(s: String, promise: Promise) = ui(promise) { QvpEngine.markFromName(s) }
144
+ override fun getConstants(): Map<String, Any> = mapOf("version" to QvpEngine.version(), "marks" to QVP_MARKS,
145
+ "kinds" to (0..4).map { QvpEngine.kindName(it) }, "families" to (0..7).map { QvpEngine.familyName(it) }, "categories" to (0..8).map { QvpEngine.categoryName(it) })
146
+
147
+ override fun invalidate() { atlases.values.forEach { it.close() }; atlases.clear(); atlasByUri.clear(); super.invalidate() }
148
+ companion object { const val NAME = "QvpModule" }
149
+ }
@@ -0,0 +1,11 @@
1
+ package net.quranpedia.qvp.rn
2
+
3
+ import com.facebook.react.ReactPackage
4
+ import com.facebook.react.bridge.NativeModule
5
+ import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.uimanager.ViewManager
7
+
8
+ class QvpPackage : ReactPackage {
9
+ override fun createNativeModules(ctx: ReactApplicationContext): List<NativeModule> = listOf(QvpModule(ctx))
10
+ override fun createViewManagers(ctx: ReactApplicationContext): List<ViewManager<*, *>> = listOf(QvpPageViewManager())
11
+ }
@@ -0,0 +1,48 @@
1
+ package net.quranpedia.qvp.rn
2
+
3
+ import com.facebook.react.bridge.ReadableArray
4
+ import com.facebook.react.bridge.ReadableMap
5
+ import com.facebook.react.uimanager.SimpleViewManager
6
+ import com.facebook.react.uimanager.ThemedReactContext
7
+ import com.facebook.react.uimanager.annotations.ReactProp
8
+ import net.quranpedia.qvp.QvpColor
9
+
10
+ /** `<QvpPageView />`: props are marshalled onto [QvpRnPageView]; [onAfterUpdateTransaction] commits them in one pass. */
11
+ class QvpPageViewManager : SimpleViewManager<QvpRnPageView>() {
12
+ override fun getName() = NAME
13
+ override fun createViewInstance(c: ThemedReactContext) = QvpRnPageView(c)
14
+ override fun onDropViewInstance(view: QvpRnPageView) { view.destroy(); super.onDropViewInstance(view) }
15
+ override fun onAfterUpdateTransaction(view: QvpRnPageView) { super.onAfterUpdateTransaction(view); view.commit() }
16
+
17
+ override fun getExportedCustomDirectEventTypeConstants(): MutableMap<String, Any> {
18
+ val m = HashMap<String, Any>()
19
+ for (e in EVENTS) m[e] = mapOf("registrationName" to e)
20
+ return m
21
+ }
22
+
23
+ @ReactProp(name = "pageUri") fun setPageUri(v: QvpRnPageView, s: String?) { v.pageUri = s }
24
+ @ReactProp(name = "pageBase64") fun setPageBase64(v: QvpRnPageView, s: String?) { v.pageBase64 = s }
25
+ @ReactProp(name = "wordsUri") fun setWordsUri(v: QvpRnPageView, s: String?) { v.wordsUri = s }
26
+ @ReactProp(name = "padTop", defaultFloat = 0f) fun setPadTop(v: QvpRnPageView, f: Float) = v.setPadTopDp(f)
27
+ @ReactProp(name = "padBottom", defaultFloat = 0f) fun setPadBottom(v: QvpRnPageView, f: Float) = v.setPadBottomDp(f)
28
+ @ReactProp(name = "padSide", defaultFloat = 0f) fun setPadSide(v: QvpRnPageView, f: Float) = v.setPadSideDp(f)
29
+ @ReactProp(name = "lineSpacing", defaultFloat = 1f) fun setLineSpacing(v: QvpRnPageView, f: Float) = v.setLineSpacingProp(f)
30
+ @ReactProp(name = "lineGap", defaultFloat = 0f) fun setLineGap(v: QvpRnPageView, f: Float) = v.setLineGapProp(f)
31
+ @ReactProp(name = "fillHeight", defaultBoolean = false) fun setFillHeight(v: QvpRnPageView, b: Boolean) = v.setFillHeightProp(b)
32
+ @ReactProp(name = "paperColor") fun setPaperColor(v: QvpRnPageView, s: String?) { v.paperColor = if (s.isNullOrBlank()) 0 else QvpColor.argb(QvpColor.parse(s)); v.invalidate() }
33
+ @ReactProp(name = "defaultInk") fun setDefaultInk(v: QvpRnPageView, s: String?) { v.defaultInkProp = s }
34
+ @ReactProp(name = "selectionBand") fun setSelectionBand(v: QvpRnPageView, s: String?) { if (!s.isNullOrBlank()) v.selectionBand = QvpColor.parse(s) }
35
+ @ReactProp(name = "selectionEnabled", defaultBoolean = true) fun setSelectionEnabled(v: QvpRnPageView, b: Boolean) { v.selectionEnabled = b }
36
+ @ReactProp(name = "zoomEnabled", defaultBoolean = true) fun setZoomEnabled(v: QvpRnPageView, b: Boolean) { v.zoomEnabled = b }
37
+ @ReactProp(name = "hitMaxDistance", defaultFloat = 6f) fun setHitMaxDistance(v: QvpRnPageView, f: Float) { v.hitOptions = v.hitOptions.copy(maxDistance = f) }
38
+ @ReactProp(name = "theme") fun setTheme(v: QvpRnPageView, m: ReadableMap?) { v.themeProp = Marshal.plain(m) }
39
+ @ReactProp(name = "styles") fun setStyles(v: QvpRnPageView, a: ReadableArray?) { v.stylesProp = Marshal.plain(a) }
40
+ @ReactProp(name = "highlights") fun setHighlights(v: QvpRnPageView, a: ReadableArray?) { v.highlightsProp = Marshal.plain(a) }
41
+ @ReactProp(name = "mask") fun setMask(v: QvpRnPageView, m: ReadableMap?) { v.maskProp = Marshal.plain(m) }
42
+ @ReactProp(name = "reveal") fun setReveal(v: QvpRnPageView, m: ReadableMap?) { v.revealProp = Marshal.plain(m) }
43
+
44
+ companion object {
45
+ const val NAME = "QvpPageView"
46
+ val EVENTS = listOf("onWordTap", "onDecoTap", "onEmptyTap", "onSelectionChanged", "onPageLoad", "onRevealChanged", "onError")
47
+ }
48
+ }
@@ -0,0 +1,235 @@
1
+ package net.quranpedia.qvp.rn
2
+
3
+ import android.util.Base64
4
+ import android.util.Log
5
+ import com.facebook.react.bridge.Arguments
6
+ import com.facebook.react.bridge.WritableMap
7
+ import com.facebook.react.uimanager.ThemedReactContext
8
+ import com.facebook.react.uimanager.UIManagerHelper
9
+ import com.facebook.react.uimanager.events.Event
10
+ import android.widget.FrameLayout
11
+ import net.quranpedia.qvp.*
12
+ import net.quranpedia.qvp.Target
13
+ import java.io.File
14
+
15
+ /**
16
+ * The React Native host of [QvpPageView]. It owns the loaded [QvpPage], loads bytes from the prop-given
17
+ * URI, and reconciles the declarative props (theme / styles / highlights / mask / reveal) against engine
18
+ * handles so JS never sees a handle. Every decision (hit-test, layout, style resolution, bands, masks)
19
+ * is the engine's; this class only marshals and forwards.
20
+ */
21
+ class QvpRnPageView(private val ctx: ThemedReactContext) : FrameLayout(ctx) {
22
+ private val density = ctx.resources.displayMetrics.density
23
+ /** The Kotlin library's renderer (final class, so hosted rather than subclassed). */
24
+ val inner = QvpPageView(ctx).also { addView(it, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) }
25
+ var page: QvpPage?
26
+ get() = inner.page
27
+ set(v) { inner.page = v }
28
+ var paperColor: Int get() = inner.paperColor; set(v) { inner.paperColor = v; inner.invalidate() }
29
+ var selectionBand: Int get() = inner.selectionBand; set(v) { inner.selectionBand = v }
30
+ var selectionEnabled: Boolean get() = inner.selectionEnabled; set(v) { inner.selectionEnabled = v }
31
+ var zoomEnabled: Boolean get() = inner.zoomEnabled; set(v) { inner.zoomEnabled = v }
32
+ var hitOptions: QvpHitOptions get() = inner.hitOptions; set(v) { inner.hitOptions = v }
33
+
34
+ /** React Native sizes this host directly (no measure pass), so size the child by hand. */
35
+ override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
36
+ val w = r - l; val h = b - t
37
+ inner.measure(MeasureSpec.makeMeasureSpec(w, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(h, MeasureSpec.EXACTLY))
38
+ inner.layout(0, 0, w, h)
39
+ centre()
40
+ }
41
+ /** Fit + centre: the library's resetView fits by height and pins the pan at x = 0; centre the pan horizontally (pan is host state). */
42
+ fun resetView() { inner.resetView(); centre() }
43
+ private fun centre() {
44
+ val l = page?.currentLayout ?: return
45
+ inner.viewOx = ((inner.width - l.contentW * inner.viewScale) / 2f).coerceAtLeast(0f); inner.invalidate()
46
+ }
47
+
48
+ // ── desired props (set by the manager) ──
49
+ var pageUri: String? = null
50
+ var pageBase64: String? = null
51
+ var wordsUri: String? = null
52
+ var defaultInkProp: String? = null
53
+ var themeProp: Map<String, Any?>? = null
54
+ var stylesProp: List<Any?>? = null
55
+ var highlightsProp: List<Any?>? = null
56
+ var maskProp: Map<String, Any?>? = null
57
+ var revealProp: Map<String, Any?>? = null
58
+ var layoutDirty = true
59
+
60
+ // ── applied state ──
61
+ private var loadedKey: String? = null
62
+ private var appliedTheme: Map<String, Any?>? = null
63
+ private var themeHandle = 0
64
+ private class Rule(val handle: Int, val entry: Map<*, *>)
65
+ private val styleHandles = LinkedHashMap<String, Rule>()
66
+ private val highlightHandles = LinkedHashMap<String, Rule>()
67
+ private var appliedMask: Map<String, Any?>? = null
68
+ private var appliedReveal: Map<String, Any?>? = null // without "at"
69
+ private var appliedRevealAt: Long? = null
70
+ private var appliedInk: Int? = null
71
+ var revealSteps = 0; private set
72
+ var loadMs = 0.0; private set
73
+ var pageBytes = 0; private set
74
+
75
+ init {
76
+ inner.onWordTap = { w, h -> page?.let { p -> emit("onWordTap", mapOf("word" to Marshal.word(p, w), "hit" to Marshal.hit(p, h))) } }
77
+ inner.onDecoTap = { d, h -> page?.let { p -> emit("onDecoTap", mapOf("deco" to Marshal.deco(d), "hit" to Marshal.hit(p, h))) } }
78
+ inner.onEmptyTap = { emit("onEmptyTap", emptyMap()) }
79
+ inner.onSelectionChanged = { page?.let { p -> emit("onSelectionChanged", Marshal.selection(p)) } }
80
+ }
81
+
82
+ override fun onAttachedToWindow() { super.onAttachedToWindow(); QvpRegistry.register(this) }
83
+ override fun onDetachedFromWindow() { QvpRegistry.unregister(this); super.onDetachedFromWindow() }
84
+
85
+ fun destroy() { QvpRegistry.unregister(this); page?.close(); page = null; loadedKey = null }
86
+
87
+ /** Padding props arrive in dp. */
88
+ fun setPadTopDp(v: Float) { inner.padTop = v * density; layoutDirty = true }
89
+ fun setPadBottomDp(v: Float) { inner.padBottom = v * density; layoutDirty = true }
90
+ fun setPadSideDp(v: Float) { inner.padSide = v * density; layoutDirty = true }
91
+ fun setLineSpacingProp(v: Float) { inner.lineSpacing = v; layoutDirty = true }
92
+ fun setLineGapProp(v: Float) { inner.lineGap = v; layoutDirty = true }
93
+ fun setFillHeightProp(v: Boolean) { inner.fillHeight = v; layoutDirty = true }
94
+
95
+ // ── bytes ──
96
+ private fun readUri(uri: String): ByteArray {
97
+ val u = uri.trim()
98
+ return when {
99
+ u.startsWith("asset://") -> ctx.assets.open(u.removePrefix("asset://")).use { it.readBytes() }
100
+ u.startsWith("asset:") -> ctx.assets.open(u.removePrefix("asset:")).use { it.readBytes() }
101
+ u.startsWith("file://") -> File(u.removePrefix("file://")).readBytes()
102
+ u.startsWith("/") -> File(u).readBytes()
103
+ u.startsWith("base64:") -> Base64.decode(u.removePrefix("base64:"), Base64.DEFAULT)
104
+ else -> ctx.assets.open(u).use { it.readBytes() }
105
+ }
106
+ }
107
+
108
+ /** Called by the manager after every prop batch. */
109
+ fun commit() {
110
+ try {
111
+ ensurePage()
112
+ val p = page
113
+ if (p != null) {
114
+ applyInk(p); applyTheme(p); applyStyles(p); applyHighlights(p); applyMask(p); applyReveal(p)
115
+ }
116
+ if (layoutDirty) { layoutDirty = false; inner.relayout(); resetView() }
117
+ inner.invalidate()
118
+ } catch (e: Exception) {
119
+ Log.e("QvpRn", "commit failed", e)
120
+ emit("onError", mapOf("message" to (e.message ?: e.toString())))
121
+ }
122
+ }
123
+
124
+ private fun ensurePage() {
125
+ val key = pageBase64?.let { "b64:" + it.hashCode() } ?: pageUri
126
+ if (key == loadedKey) return
127
+ // drop everything bound to the old page: handles die with it
128
+ themeHandle = 0; appliedTheme = null; styleHandles.clear(); highlightHandles.clear(); appliedMask = null; appliedReveal = null; appliedRevealAt = null; appliedInk = null; revealSteps = 0
129
+ page?.close(); page = null; loadedKey = key
130
+ if (key == null) return
131
+ val bytes = pageBase64?.let { Base64.decode(it, Base64.DEFAULT) } ?: readUri(pageUri!!)
132
+ val t0 = System.nanoTime()
133
+ val p = QvpPage(bytes); p.buildPaths()
134
+ loadMs = (System.nanoTime() - t0) / 1e6; pageBytes = bytes.size
135
+ wordsUri?.let { runCatching { p.attachWords(readUri(it)) }.onFailure { e -> Log.w("QvpRn", "words sidecar: $e") } }
136
+ page = p; layoutDirty = true
137
+ emit("onPageLoad", Marshal.pageInfo(p) + mapOf("loadMs" to loadMs, "bytes" to pageBytes, "uri" to pageUri))
138
+ }
139
+
140
+ private fun applyInk(p: QvpPage) {
141
+ val ink = Marshal.color(defaultInkProp, 0x231f20ff.toInt())
142
+ if (appliedInk != ink) { appliedInk = ink; p.setDefaultInk(ink) }
143
+ }
144
+ private fun applyTheme(p: QvpPage) {
145
+ if (themeProp == appliedTheme) return
146
+ if (themeHandle != 0) { p.unstyle(themeHandle); themeHandle = 0 }
147
+ appliedTheme = themeProp
148
+ themeProp?.let { themeHandle = p.theme(Marshal.theme(it)) }
149
+ }
150
+ private fun entries(list: List<Any?>?): LinkedHashMap<String, Map<*, *>> {
151
+ val out = LinkedHashMap<String, Map<*, *>>()
152
+ list?.forEach { e -> (e as? Map<*, *>)?.let { m -> (m["id"]?.toString())?.let { out[it] = m } } }
153
+ return out
154
+ }
155
+ private fun applyStyles(p: QvpPage) {
156
+ val want = entries(stylesProp)
157
+ for (id in styleHandles.keys.toList()) if (!want.containsKey(id)) { p.unstyle(styleHandles.remove(id)!!.handle) }
158
+ for ((id, e) in want) {
159
+ val old = styleHandles[id]
160
+ if (old != null && old.entry == e) continue
161
+ val sameShape = old != null && old.entry["selector"] == e["selector"] && old.entry["target"] == e["target"] && old.entry["layer"] == e["layer"] && old.entry["hide"] == e["hide"] && e["hide"] != true
162
+ if (sameShape) { p.restyle(old!!.handle, Marshal.color(e["color"], 0), (e["ms"] as? Number)?.toInt() ?: 0); styleHandles[id] = Rule(old.handle, e); continue }
163
+ if (old != null) p.unstyle(old.handle)
164
+ val ms = (e["ms"] as? Number)?.toInt() ?: 0
165
+ val layer = (e["layer"] as? Number)?.toInt() ?: QvpLayer.BASE
166
+ val color = Marshal.color(e["color"], 0)
167
+ val h = when {
168
+ e["hide"] == true -> Marshal.selector(e["selector"])?.let { p.hide(it) } ?: 0
169
+ e["selector"] != null -> Marshal.selector(e["selector"])?.let { p.style(it, color, ms, layer) } ?: 0
170
+ e["target"] != null -> p.styleTarget(Marshal.target(e["target"], p), color, ms, layer)
171
+ else -> 0
172
+ }
173
+ if (h != 0) styleHandles[id] = Rule(h, e) else styleHandles.remove(id)
174
+ }
175
+ }
176
+ private fun applyHighlights(p: QvpPage) {
177
+ val want = entries(highlightsProp)
178
+ for (id in highlightHandles.keys.toList()) if (!want.containsKey(id)) { p.unhighlight(highlightHandles.remove(id)!!.handle) }
179
+ for ((id, e) in want) {
180
+ val old = highlightHandles[id]
181
+ if (old != null && old.entry == e) continue
182
+ val style = Marshal.highlightStyle(e["style"])
183
+ if (old == null) { val h = p.highlight(Marshal.target(e["target"], p), style); if (h != 0) highlightHandles[id] = Rule(h, e); continue }
184
+ if (old.entry["style"] != e["style"]) p.restyleHighlight(old.handle, style)
185
+ if (old.entry["target"] != e["target"]) p.rehighlight(old.handle, Marshal.target(e["target"], p))
186
+ highlightHandles[id] = Rule(old.handle, e)
187
+ }
188
+ }
189
+ private fun applyMask(p: QvpPage) {
190
+ if (maskProp == appliedMask) return
191
+ appliedMask = maskProp
192
+ p.unmask()
193
+ val m = maskProp ?: return
194
+ val paper = Marshal.color(m["blockColor"], 0xd9d4c8ff.toInt())
195
+ p.maskOptions(paper, (m["padX"] as? Number)?.toFloat() ?: 0.6f, (m["padY"] as? Number)?.toFloat() ?: 0.6f, (m["radius"] as? Number)?.toFloat() ?: 0.8f, m["reverse"] == true)
196
+ p.mask(Marshal.target(m["target"], p), Marshal.maskMode(m["mode"]))
197
+ }
198
+ private fun applyReveal(p: QvpPage) {
199
+ val r = revealProp
200
+ val cfg = r?.filterKeys { it != "at" }
201
+ if (cfg != appliedReveal) {
202
+ appliedReveal = cfg; appliedRevealAt = null
203
+ if (cfg == null) { p.revealStop(); revealSteps = 0; emit("onRevealChanged", mapOf("steps" to 0, "at" to null)); return }
204
+ revealSteps = p.revealStart((cfg["lit"] as? Number)?.toInt() ?: 1, cfg["byAyah"] == true, Marshal.color(cfg["grey"], 0xc9c4b8ff.toInt()), Marshal.color(cfg["ink"], appliedInk ?: 0x231f20ff.toInt()),
205
+ cfg["ayahMarks"] != false, (cfg["ms"] as? Number)?.toInt() ?: 0)
206
+ emit("onRevealChanged", mapOf("steps" to revealSteps, "at" to -1))
207
+ }
208
+ if (r == null) return
209
+ val at = (r["at"] as? Number)?.toLong() ?: -1L
210
+ if (at != appliedRevealAt) { appliedRevealAt = at; p.revealGoto(at) }
211
+ }
212
+
213
+ // ── events ──
214
+ private class QvpEvent(surfaceId: Int, viewTag: Int, private val name: String, private val payload: WritableMap) : Event<QvpEvent>(surfaceId, viewTag) {
215
+ override fun getEventName() = name
216
+ override fun getEventData(): WritableMap = payload
217
+ }
218
+ fun emit(name: String, payload: Map<String, Any?>) {
219
+ if (id <= 0) return
220
+ val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(ctx, id) ?: return
221
+ dispatcher.dispatchEvent(QvpEvent(UIManagerHelper.getSurfaceId(this), id, name, Marshal.toMap(payload)))
222
+ }
223
+
224
+ fun stats(): Map<String, Any?> = mapOf("loadMs" to loadMs, "bytes" to pageBytes, "baseMs" to inner.lastBaseMs, "overlayMs" to inner.lastOverlayMs, "basePaths" to inner.lastBasePaths, "overlayPaths" to inner.lastOverlayPaths,
225
+ "bands" to inner.lastBands, "hitUs" to inner.lastHitUs, "animating" to inner.animating, "styleHandles" to (page?.styleHandles()?.size ?: 0), "highlightHandles" to (page?.highlightHandles()?.size ?: 0),
226
+ "engineVersion" to QvpEngine.version(), "viewScale" to inner.viewScale, "layout" to page?.currentLayout?.let { Marshal.layout(it) })
227
+ }
228
+
229
+ /** Views by react tag, so the module can find the page behind a `viewTag` without going through the UIManager. */
230
+ object QvpRegistry {
231
+ private val views = HashMap<Int, QvpRnPageView>()
232
+ fun register(v: QvpRnPageView) { if (v.id > 0) views[v.id] = v }
233
+ fun unregister(v: QvpRnPageView) { views.entries.removeAll { it.value === v } }
234
+ fun get(tag: Int): QvpRnPageView? = views[tag]
235
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@quran.ws/qvp-react-native",
3
+ "version": "0.1.0",
4
+ "description": "React Native wrapper for the QVP vector mushaf engine (Android; iOS TODO). Thin marshalling over the Kotlin library, which is JNI over qvp.h.",
5
+ "main": "src/index.tsx",
6
+ "types": "src/index.tsx",
7
+ "react-native": "src/index.tsx",
8
+ "source": "src/index.tsx",
9
+ "files": [
10
+ "src",
11
+ "android/build.gradle",
12
+ "android/src",
13
+ "README.md",
14
+ "package.json"
15
+ ],
16
+ "scripts": {
17
+ "typecheck": "tsc --noEmit",
18
+ "build": "tsc -p tsconfig.build.json"
19
+ },
20
+ "keywords": [
21
+ "react-native",
22
+ "quran",
23
+ "mushaf",
24
+ "qvp",
25
+ "vector"
26
+ ],
27
+ "license": "MIT",
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/quran-ws/quran-engine.git",
34
+ "directory": "packages/react-native/qvp-react-native"
35
+ },
36
+ "peerDependencies": {
37
+ "react": "*",
38
+ "react-native": "*"
39
+ },
40
+ "devDependencies": {
41
+ "@types/react": "^19.1.0",
42
+ "react": "19.2.3",
43
+ "react-native": "0.87.1",
44
+ "typescript": "^5.8.0"
45
+ }
46
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,346 @@
1
+ /**
2
+ * @quran.ws/qvp-react-native — typed JS API over the native module.
3
+ *
4
+ * Names follow docs/API.md. Nothing here decides anything: targets, selectors and colours are marshalled
5
+ * to the Kotlin library (`net.quranpedia.qvp`), which is JNI over crates/qvp-ffi/include/qvp.h.
6
+ * Declarative props (`highlights`, `styles`, `theme`, `mask`, `reveal`) are reconciled against engine
7
+ * handles on the native side, so JS never sees a handle.
8
+ */
9
+ import React, { forwardRef, useImperativeHandle, useMemo, useRef } from 'react';
10
+ import {
11
+ findNodeHandle,
12
+ NativeModules,
13
+ requireNativeComponent,
14
+ type HostComponent,
15
+ type NativeSyntheticEvent,
16
+ type ViewProps,
17
+ } from 'react-native';
18
+
19
+ // ── constants (mirror qvp.h) ─────────────────────────────────────────────────────────────────
20
+ export const KIND = { BODY: 0, MARK: 1, AYAH_NUMBER: 2, AYAH_MARK_ORNAMENT: 3, HEADER_INK: 4, OTHER: 255 } as const;
21
+ export const FAMILY = { NONE: 0, DIACRITIC: 1, TANWIN: 2, DOTS: 3, WAQF: 4, SIFR: 5, SAJDAH: 6, READING_SIGN: 7 } as const;
22
+ export const CATEGORY = { NONE: 0, HARAKAH: 1, TANWIN: 2, LETTER_DOT: 3, ORTHOGRAPHIC: 4, DABT: 5, WAQF: 6, READING_SIGN: 7, STANDALONE: 8 } as const;
23
+ export const DECO = { AYAH_MARK: 0, SURAH_NAME: 1, BASMALAH: 2, DIVISION_MARK: 3, SAJDAH_MARK: 4 } as const;
24
+ export const LAYER = { BASE: 0, THEME: 10, HIGHLIGHT: 50, SELECTION: 60, TOP: 100 } as const;
25
+ export const DIVISION = { JUZ: 0, HIZB: 1, NISF: 2, RUBU_AL_HIZB: 3 } as const;
26
+ export const MARKS = ['', 'fathah', 'kasrah', 'dammah', 'tanwin_al_fath', 'tanwin_al_kasr', 'tanwin_al_damm', 'shaddah', 'sukun', 'maddah', 'hamzah', 'hamzat_al_wasl', 'omitted_alif', 'small_waw', 'small_yaa', 'small_noon', 'dot', 'two_dots', 'three_dots', 'rounded_zero', 'rectangular_zero', 'waqf_jaiz_mustawi_al_tarafayn', 'waqf_jaiz_waqf_awla', 'waqf_jaiz_wasl_awla', 'waqf_lazim', 'waqf_al_muanaqah', 'saktah', 'small_meem', 'hizb', 'sajdah', 'sajdah_mark', 'sajdah_line', 'seen_al_qiraah', 'tashil', 'ishmam', 'imalah'];
27
+ const KIND_NAMES = ['body', 'mark', 'ayah-number', 'ayah-ornament', 'header-ink'];
28
+ const FAMILY_NAMES = ['none', 'diacritic', 'tanwin', 'dots', 'waqf', 'sifr', 'sajdah', 'reading_sign'];
29
+ const CATEGORY_NAMES = ['none', 'harakah', 'tanwin', 'letter_dot', 'orthographic', 'dabt', 'waqf', 'reading_sign', 'standalone'];
30
+ const DECO_NAMES = ['ayah-mark', 'surah-name', 'basmalah', 'division-mark', 'sajdah-mark', 'page-number', 'running-head'];
31
+ export function markId(name: string | number): number {
32
+ if (typeof name === 'number') return name;
33
+ const i = MARKS.indexOf(name);
34
+ return i < 0 ? 255 : i;
35
+ }
36
+ const named = (v: string | number, names: string[]) => (typeof v === 'number' ? v : Math.max(0, names.indexOf(v)));
37
+
38
+ // ── colours ──────────────────────────────────────────────────────────────────────────────────
39
+ export type Color = string | number;
40
+ /** Any accepted colour ('#rgb' | '#rrggbb' | '#rrggbbaa' | 0xRRGGBBAA) → '#rrggbbaa'. */
41
+ export function css(c: Color | null | undefined): string | undefined {
42
+ if (c == null) return undefined;
43
+ if (typeof c === 'number') return '#' + (c >>> 0).toString(16).padStart(8, '0');
44
+ let h = c.trim().replace(/^#/, '');
45
+ if (h.length === 3) h = h.split('').map(x => x + x).join('');
46
+ if (h.length === 6) h += 'ff';
47
+ return '#' + h.toLowerCase();
48
+ }
49
+ /** Colour with a replaced alpha (0..1). */
50
+ export function rgba(c: Color, alpha: number): string {
51
+ const h = css(c)!;
52
+ return h.slice(0, 7) + Math.round(Math.max(0, Math.min(1, alpha)) * 255).toString(16).padStart(2, '0');
53
+ }
54
+
55
+ // ── targets and selectors (same shapes as web/qvp.js T / Sel) ────────────────────────────────
56
+ export interface TargetObj { kind: number; a?: number; b?: number; c?: number; words?: number[] }
57
+ /** 'page' | '2:255' | '2:255:3' | '2:255-257' | 'line:7' | 'surah:2' | word index | [indices] | T.* */
58
+ export type Target = string | number | number[] | TargetObj;
59
+ export const T = {
60
+ page: (): TargetObj => ({ kind: 0 }),
61
+ word: (i: number): TargetObj => ({ kind: 1, a: i }),
62
+ words: (ws: number[]): TargetObj => ({ kind: 2, words: ws }),
63
+ ayah: (s: number, a: number): TargetObj => ({ kind: 3, a: s, b: a }),
64
+ ayahRange: (s: number, a: number, b: number): TargetObj => ({ kind: 4, a: s, b: a, c: b }),
65
+ line: (n: number): TargetObj => ({ kind: 5, a: n }),
66
+ surah: (s: number): TargetObj => ({ kind: 6, a: s }),
67
+ range: (a: number, b: number): TargetObj => ({ kind: 7, a, b }),
68
+ };
69
+ export interface Selector { kind: number; a?: number; b?: number; c?: number; mark?: string }
70
+ export const Sel = {
71
+ page: (): Selector => ({ kind: 0 }),
72
+ path: (p: number): Selector => ({ kind: 1, a: p }),
73
+ wordPath: (w: number, nth: number): Selector => ({ kind: 2, a: w, b: nth }),
74
+ /** nth mark of the word (0-based, marks only) */
75
+ wordMark: (w: number, nth: number): Selector => ({ kind: 3, a: w, b: nth }),
76
+ wordMarkNamed: (w: number, mark: string, nth = 0): Selector => ({ kind: 4, a: w, b: markId(mark), c: nth, mark }),
77
+ wordBody: (w: number): Selector => ({ kind: 5, a: w }),
78
+ wordMarks: (w: number): Selector => ({ kind: 6, a: w }),
79
+ word: (w: number): Selector => ({ kind: 7, a: w }),
80
+ ayah: (s: number, a: number): Selector => ({ kind: 8, a: s, b: a }),
81
+ line: (n: number): Selector => ({ kind: 9, a: n }),
82
+ mark: (m: string | number): Selector => ({ kind: 10, a: markId(m) }),
83
+ category: (c: string | number): Selector => ({ kind: 11, a: named(c, CATEGORY_NAMES) }),
84
+ family: (f: string | number): Selector => ({ kind: 12, a: named(f, FAMILY_NAMES) }),
85
+ kind: (k: string | number): Selector => ({ kind: 13, a: named(k, KIND_NAMES) }),
86
+ deco: (k: string | number): Selector => ({ kind: 14, a: named(k, DECO_NAMES) }),
87
+ decoIdx: (d: number): Selector => ({ kind: 15, a: d }),
88
+ };
89
+
90
+ // ── records ──────────────────────────────────────────────────────────────────────────────────
91
+ export type Form = 'rasmUthmani' | 'rasmImlai' | 'qpc' | 'rasm' | 'search';
92
+ export type HighlightMode = 'ink' | 'band' | 'both';
93
+ export type MaskMode = 'hide' | 'block' | 'blur';
94
+ export type DivisionKind = 'juz' | 'hizb' | 'nisf' | 'rubuAlHizb';
95
+ export interface WordPath { idx: number; kind: number; kindName: string; mark: number; markName: string; nthMark: number; nthInWord: number; category: number; categoryName: string; family: number; familyName: string; line: number }
96
+ export interface Word {
97
+ idx: number; surah: number; ayah: number; word: number; line: number; lineIdx: number; ayahIdx: number;
98
+ x0: number; y0: number; x1: number; y1: number; text: string; firstPath: number; nPaths: number;
99
+ wordKey: string; ayahKey: string; forms: Partial<Record<Form, string>>; label: string; paths: WordPath[];
100
+ }
101
+ export interface Deco { idx: number; kind: number; kindName: string; surah: number; ayah: number; line: number; x0: number; y0: number; x1: number; y1: number; text: string; firstPath: number; nPaths: number }
102
+ export interface Hit { word: number; path: number; deco: number; line: number; distance: number; exact: boolean; wordKey: string | null; ayahKey: string | null }
103
+ export interface Ayah { idx: number; surah: number; ayah: number; fragment: number; fragments: number; flags: number; rubuAlHizb: number; firstWord: number; nWords: number; ayahMarkDeco: number; bbox: number[] }
104
+ export interface Line { idx: number; lineNo: number; isHeader: boolean; firstWord: number; nWords: number; bbox: number[]; bandY0: number; bandY1: number; centre: number }
105
+ export interface SelectionInfo { words: number[]; text: string; citation: string; textWithCitation: string }
106
+ export interface PageInfo { page: number; width: number; height: number; nLines: number; nAyahs: number; nWords: number; nPaths: number; nDecos: number; naturalPitch: number; forms: Form[]; loadMs: number; bytes: number; uri?: string }
107
+ export interface Match { word: number; index: number; loose: boolean; wordKey: string; text: string }
108
+ export interface Surah { number: number; ayahCount: number; hasBanner: boolean; hasBasmalah: boolean; place: string; bannerDeco: number; arabic: string; latin: string; english: string }
109
+ export interface Division { kind: DivisionKind; n: number; surah: number; ayah: number; line: number; ayahIdx: number }
110
+ export interface Marker { deco: number; surah: number; ayah: number; line: number; cx: number; cy: number; r: number; ornamentPath: number; numeralPath: number }
111
+ export interface Rosette { deco: number; surah: number; ayah: number; juz: number; hizb: number; nisf: number; rubuAlHizb: number; rubuAlHizbInHizb: number }
112
+ export interface Sajdah { deco: number; surah: number; ayah: number; signPath: number }
113
+ export interface CropBox { x0: number; y0: number; x1: number; y1: number; nWords: number; ayahMarkDeco: number }
114
+ export interface Layout { scale: number; ox: number; oy: number; contentW: number; contentH: number; pitch: number; lineDy: number[]; slots: number[][] }
115
+ export interface Stats { loadMs: number; bytes: number; baseMs: number; overlayMs: number; basePaths: number; overlayPaths: number; bands: number; hitUs: number; animating: boolean; styleHandles: number; highlightHandles: number; engineVersion: number; viewScale: number; layout: Layout | null }
116
+ export interface AtlasSurah { n: number; number: number; page: number; ayahCount: number; place: string; arabic: string; latin: string; english: string }
117
+ export interface AtlasRubuAlHizb { rubuAlHizb: number; surah: number; ayah: number; page: number; ayahKey: string }
118
+
119
+ export interface HighlightStyle { mode?: HighlightMode; ink?: Color; band?: Color; height?: 'pitch' | 'ink'; padX?: number; padY?: number; radius?: number; seam?: number; ms?: number; layer?: number }
120
+ /** One declarative highlight; the native side keeps id → handle and calls highlight / rehighlight / restyleHighlight / unhighlight on diff. */
121
+ export interface Highlight { id: string; target: Target; style?: HighlightStyle }
122
+ /** One declarative style rule: `selector` (→ page.style) or `target` (→ page.styleTarget); `hide` → page.hide(selector). */
123
+ export interface StyleRule { id: string; selector?: Selector; target?: Target; color?: Color; ms?: number; layer?: number; hide?: boolean }
124
+ export interface Theme { ink?: Color; diacritics?: Color; dots?: Color; waqf?: Color; sifr?: Color; ayahMark?: Color; numeral?: Color; headers?: Color; marks?: Record<string, Color>; ms?: number }
125
+ export interface Mask { target: Target; mode?: MaskMode; blockColor?: Color; padX?: number; padY?: number; radius?: number; reverse?: boolean }
126
+ /** Greyed-page reveal: `revealStart(lit, byAyah, grey, ink, ayahMarks, ms)` then `revealGoto(at)` whenever `at` changes; null → revealStop. */
127
+ export interface Reveal { lit?: number; byAyah?: boolean; grey?: Color; ink?: Color; ayahMarks?: boolean; ms?: number; at?: number }
128
+
129
+ // ── the view ─────────────────────────────────────────────────────────────────────────────────
130
+ export interface QvpPageViewProps extends ViewProps {
131
+ /** 'asset://pages/042.qvp' | 'file:///…' | '/abs/path' | 'base64:…' — the package ships no page data. */
132
+ pageUri?: string;
133
+ pageBase64?: string;
134
+ /** optional NNN.words.json sidecar (derived forms), same URI schemes */
135
+ wordsUri?: string;
136
+ padTop?: number; padBottom?: number; padSide?: number;
137
+ lineSpacing?: number; lineGap?: number; fillHeight?: boolean;
138
+ paperColor?: Color; defaultInk?: Color; selectionBand?: Color;
139
+ selectionEnabled?: boolean; zoomEnabled?: boolean; hitMaxDistance?: number;
140
+ theme?: Theme | null;
141
+ styles?: StyleRule[];
142
+ highlights?: Highlight[];
143
+ mask?: Mask | null;
144
+ reveal?: Reveal | null;
145
+ onWordTap?: (e: { word: Word; hit: Hit }) => void;
146
+ onDecoTap?: (e: { deco: Deco; hit: Hit }) => void;
147
+ onEmptyTap?: () => void;
148
+ onSelectionChanged?: (e: SelectionInfo) => void;
149
+ onPageLoad?: (e: PageInfo) => void;
150
+ onRevealChanged?: (e: { steps: number; at: number | null }) => void;
151
+ onError?: (e: { message: string }) => void;
152
+ }
153
+ type NativeProps = ViewProps & Omit<QvpPageViewProps, 'onWordTap' | 'onDecoTap' | 'onEmptyTap' | 'onSelectionChanged' | 'onPageLoad' | 'onRevealChanged' | 'onError'> & {
154
+ onWordTap?: (e: NativeSyntheticEvent<{ word: Word; hit: Hit }>) => void;
155
+ onDecoTap?: (e: NativeSyntheticEvent<{ deco: Deco; hit: Hit }>) => void;
156
+ onEmptyTap?: (e: NativeSyntheticEvent<{}>) => void;
157
+ onSelectionChanged?: (e: NativeSyntheticEvent<SelectionInfo>) => void;
158
+ onPageLoad?: (e: NativeSyntheticEvent<PageInfo>) => void;
159
+ onRevealChanged?: (e: NativeSyntheticEvent<{ steps: number; at: number | null }>) => void;
160
+ onError?: (e: NativeSyntheticEvent<{ message: string }>) => void;
161
+ };
162
+ const NativeQvpPageView = requireNativeComponent<NativeProps>('QvpPageView') as HostComponent<NativeProps>;
163
+
164
+ const colorKeys = ['ink', 'band', 'diacritics', 'dots', 'waqf', 'sifr', 'ayahMark', 'numeral', 'headers', 'color', 'blockColor', 'grey'];
165
+ function normColors<X>(o: X): X {
166
+ if (o == null || typeof o !== 'object') return o;
167
+ if (Array.isArray(o)) return o.map(normColors) as X;
168
+ const out: any = {};
169
+ for (const [k, v] of Object.entries(o as any)) {
170
+ if (colorKeys.includes(k) && (typeof v === 'string' || typeof v === 'number')) out[k] = css(v);
171
+ else if (k === 'marks' && v && typeof v === 'object') out[k] = Object.fromEntries(Object.entries(v as any).map(([m, c]) => [m, css(c as Color)]));
172
+ else if (k === 'style' || k === 'target' || k === 'selector') out[k] = k === 'style' ? normColors(v) : v;
173
+ else out[k] = v;
174
+ }
175
+ return out;
176
+ }
177
+
178
+ /** Imperative handle: the page-bound module API plus the react tag. */
179
+ export type QvpPageViewHandle = PageApi & { tag: () => number };
180
+
181
+ export const QvpPageView = forwardRef<QvpPageViewHandle, QvpPageViewProps>(function QvpPageView(props, ref) {
182
+ const native = useRef<any>(null);
183
+ const { theme, styles, highlights, mask, reveal, paperColor, defaultInk, selectionBand, onWordTap, onDecoTap, onEmptyTap, onSelectionChanged, onPageLoad, onRevealChanged, onError, ...rest } = props;
184
+ const tag = () => findNodeHandle(native.current) ?? -1;
185
+ useImperativeHandle(ref, () => ({ tag, ...bindPage(tag) }), []);
186
+ const nTheme = useMemo(() => (theme ? normColors(theme) : null), [theme]);
187
+ const nStyles = useMemo(() => (styles ? normColors(styles) : []), [styles]);
188
+ const nHighlights = useMemo(() => (highlights ? normColors(highlights) : []), [highlights]);
189
+ const nMask = useMemo(() => (mask ? normColors(mask) : null), [mask]);
190
+ const nReveal = useMemo(() => (reveal ? normColors(reveal) : null), [reveal]);
191
+ return (
192
+ <NativeQvpPageView
193
+ ref={native}
194
+ {...rest}
195
+ paperColor={css(paperColor)}
196
+ defaultInk={css(defaultInk)}
197
+ selectionBand={css(selectionBand)}
198
+ theme={nTheme}
199
+ styles={nStyles}
200
+ highlights={nHighlights}
201
+ mask={nMask}
202
+ reveal={nReveal}
203
+ onWordTap={onWordTap ? e => onWordTap(e.nativeEvent) : undefined}
204
+ onDecoTap={onDecoTap ? e => onDecoTap(e.nativeEvent) : undefined}
205
+ onEmptyTap={onEmptyTap ? () => onEmptyTap() : undefined}
206
+ onSelectionChanged={onSelectionChanged ? e => onSelectionChanged(e.nativeEvent) : undefined}
207
+ onPageLoad={onPageLoad ? e => onPageLoad(e.nativeEvent) : undefined}
208
+ onRevealChanged={onRevealChanged ? e => onRevealChanged(e.nativeEvent) : undefined}
209
+ onError={onError ? e => onError(e.nativeEvent) : undefined}
210
+ />
211
+ );
212
+ });
213
+
214
+ // ── the module ───────────────────────────────────────────────────────────────────────────────
215
+ const M = NativeModules.QvpModule as any;
216
+ if (!M) throw new Error('@quran.ws/qvp-react-native: native module QvpModule not linked (Android only for now; see README)');
217
+
218
+ export interface SearchOptions { form?: Form; mode?: 'includes' | 'exact' | 'prefix'; normalize?: boolean; loose?: boolean; limit?: number }
219
+ export interface TextOptions { form?: Form; wordSep?: string; lineSep?: string }
220
+ export interface CropOptions { pad?: number; keepAyahMarks?: boolean; background?: Color }
221
+ export interface HitOptions { maxDistance?: number; gapBias?: number; exactFirst?: boolean }
222
+
223
+ /** Page-bound queries; `tag` is the react tag of a mounted `<QvpPageView />` (`findNodeHandle`). */
224
+ export const Qvp = {
225
+ info: (tag: number): Promise<PageInfo> => M.info(tag),
226
+ words: (tag: number): Promise<Word[]> => M.words(tag),
227
+ word: (tag: number, idx: number): Promise<Word | null> => M.word(tag, idx),
228
+ ayahs: (tag: number): Promise<Ayah[]> => M.ayahs(tag),
229
+ lines: (tag: number): Promise<Line[]> => M.lines(tag),
230
+ decos: (tag: number): Promise<Deco[]> => M.decos(tag),
231
+ findWord: (tag: number, s: number, a: number, w: number): Promise<number> => M.findWord(tag, s, a, w),
232
+ resolve: (tag: number, target: Target): Promise<number[]> => M.resolve(tag, target),
233
+ wordForm: (tag: number, idx: number, form: Form = 'rasmUthmani'): Promise<string> => M.wordForm(tag, idx, form),
234
+ hasForm: (tag: number, form: Form): Promise<boolean> => M.hasForm(tag, form),
235
+ attachWords: (tag: number, json: string | object): Promise<number> => M.attachWords(tag, typeof json === 'string' ? json : JSON.stringify(json)),
236
+ surahs: (tag: number): Promise<Surah[]> => M.surahs(tag),
237
+ divisions: (tag: number): Promise<Division[]> => M.divisions(tag),
238
+ ayahMarks: (tag: number): Promise<Marker[]> => M.ayahMarks(tag),
239
+ rosettes: (tag: number): Promise<Rosette[]> => M.rosettes(tag),
240
+ sajdahs: (tag: number): Promise<Sajdah[]> => M.sajdahs(tag),
241
+ ayahKeys: (tag: number): Promise<{ surah: number; ayah: number; ayahKey: string }[]> => M.ayahKeys(tag),
242
+ ayahWordCount: (tag: number, s: number, a: number): Promise<{ count: number; complete: boolean }> => M.ayahWordCount(tag, s, a),
243
+ reciteMap: (tag: number, s: number, a: number, nSegments: number): Promise<number[] | null> => M.reciteMap(tag, s, a, nSegments),
244
+ wordLabel: (tag: number, i: number): Promise<string> => M.wordLabel(tag, i),
245
+ ayahLabel: (tag: number, i: number): Promise<string> => M.ayahLabel(tag, i),
246
+ text: (tag: number, target: Target = 'page', opts?: TextOptions): Promise<string> => M.text(tag, target, opts ?? null),
247
+ search: (tag: number, query: string, opts?: SearchOptions): Promise<Match[]> => M.search(tag, query, opts ?? null),
248
+ citation: (tag: number, words: number[]): Promise<string> => M.citation(tag, words),
249
+ hitTestViewEx: (tag: number, x: number, y: number, opts?: HitOptions): Promise<Hit | null> => M.hitTestViewEx(tag, x, y, opts ?? null),
250
+ wordBoxView: (tag: number, i: number): Promise<{ x0: number; y0: number; x1: number; y1: number } | null> => M.wordBoxView(tag, i),
251
+ currentLayout: (tag: number): Promise<Layout | null> => M.currentLayout(tag),
252
+ relayout: (tag: number): Promise<void> => M.relayout(tag),
253
+ resetView: (tag: number): Promise<void> => M.resetView(tag),
254
+ stats: (tag: number): Promise<Stats | null> => M.stats(tag),
255
+ select: (tag: number, anchor: number, focus: number = anchor): Promise<SelectionInfo> => M.select(tag, anchor, focus),
256
+ clearSelection: (tag: number): Promise<void> => M.clearSelection(tag),
257
+ selection: (tag: number): Promise<SelectionInfo> => M.selection(tag),
258
+ selectionText: (tag: number, form: Form = 'rasmUthmani', citation = false): Promise<string> => M.selectionText(tag, form, citation),
259
+ revealNext: (tag: number, n = 1): Promise<number> => M.revealNext(tag, n),
260
+ hideBack: (tag: number, n = 1): Promise<number> => M.hideBack(tag, n),
261
+ revealWord: (tag: number, i: number): Promise<boolean> => M.revealWord(tag, i),
262
+ hideWord: (tag: number, i: number): Promise<boolean> => M.hideWord(tag, i),
263
+ revealAll: (tag: number): Promise<void> => M.revealAll(tag),
264
+ hideAll: (tag: number): Promise<void> => M.hideAll(tag),
265
+ maskHidden: (tag: number): Promise<number[]> => M.maskHidden(tag),
266
+ maskWords: (tag: number): Promise<number[]> => M.maskWords(tag),
267
+ revealSteps: (tag: number): Promise<number> => M.revealSteps(tag),
268
+ revealAt: (tag: number): Promise<number | null> => M.revealAt(tag),
269
+ revealStepOf: (tag: number, i: number): Promise<number> => M.revealStepOf(tag, i),
270
+ cropBox: (tag: number, target: Target, opts?: CropOptions): Promise<CropBox | null> => M.cropBox(tag, target, opts ?? null),
271
+ cropSvg: (tag: number, target: Target, opts?: CropOptions): Promise<string | null> => M.cropSvg(tag, target, opts ? { ...opts, background: css(opts.background) } : null),
272
+
273
+ // engine-wide
274
+ strip: (s: string): Promise<string> => M.arabic('strip', s),
275
+ fold: (s: string): Promise<string> => M.arabic('fold', s),
276
+ normalize: (s: string): Promise<string> => M.arabic('normalize', s),
277
+ looseKey: (s: string): Promise<string> => M.arabic('loose', s),
278
+ arabic: (kind: 'strip' | 'fold' | 'normalize' | 'loose', s: string): Promise<string> => M.arabic(kind, s),
279
+ gapToFill: (pageW: number, pageH: number, lines: number, viewW: number, viewH: number, max = 0): Promise<number> => M.gapToFill(pageW, pageH, lines, viewW, viewH, max),
280
+ wastedFraction: (pageW: number, pageH: number, viewW: number, viewH: number): Promise<number> => M.wastedFraction(pageW, pageH, viewW, viewH),
281
+ markName: (m: number): string => MARKS[m] ?? 'unknown',
282
+ kindName: (k: number): string => KIND_NAMES[k] ?? 'other',
283
+ categoryName: (c: number): string => CATEGORY_NAMES[c] ?? '',
284
+ familyName: (f: number): string => FAMILY_NAMES[f] ?? '',
285
+ version: (M.getConstants?.().version ?? 0) as number,
286
+
287
+ // atlas
288
+ loadAtlas: (uri: string): Promise<number> => M.loadAtlas(uri),
289
+ freeAtlas: (id: number): Promise<void> => M.freeAtlas(id),
290
+ atlasPageOf: (id: number, s: number, a: number): Promise<number | null> => M.atlasPageOf(id, s, a),
291
+ atlasPageRange: (id: number, page: number): Promise<{ first: { surah: number; ayah: number }; last: { surah: number; ayah: number } } | null> => M.atlasPageRange(id, page),
292
+ atlasPages: (id: number): Promise<number> => M.atlasPages(id),
293
+ atlasSurah: (id: number, n: number): Promise<AtlasSurah | null> => M.atlasSurah(id, n),
294
+ atlasSurahs: (id: number): Promise<AtlasSurah[]> => M.atlasSurahs(id),
295
+ atlasPageOfSurah: (id: number, n: number): Promise<number | null> => M.atlasPageOfSurah(id, n),
296
+ atlasDivision: (id: number, kind: DivisionKind, n: number): Promise<AtlasRubuAlHizb | null> => M.atlasDivision(id, kind, n),
297
+ atlasDivisionAt: (id: number, kind: DivisionKind, s: number, a: number): Promise<number | null> => M.atlasDivisionAt(id, kind, s, a),
298
+ atlasJuzAt: (id: number, s: number, a: number): Promise<number | null> => M.atlasJuzAt(id, s, a),
299
+ atlasPagesOfJuz: (id: number, n: number): Promise<[number, number] | null> => M.atlasPagesOfJuz(id, n),
300
+ atlasFindSurah: (id: number, text: string): Promise<AtlasSurah[]> => M.atlasFindSurah(id, text),
301
+ };
302
+
303
+ /** Cross-page lookup (atlas.qva). `const atlas = await QvpAtlas.load('asset://pages/atlas.qva')`. */
304
+ export class QvpAtlas {
305
+ private constructor(public readonly id: number) {}
306
+ static async load(uri: string): Promise<QvpAtlas> { return new QvpAtlas(await Qvp.loadAtlas(uri)); }
307
+ free() { return Qvp.freeAtlas(this.id); }
308
+ pageOf(s: number, a: number) { return Qvp.atlasPageOf(this.id, s, a); }
309
+ pageRange(page: number) { return Qvp.atlasPageRange(this.id, page); }
310
+ pages() { return Qvp.atlasPages(this.id); }
311
+ surah(n: number) { return Qvp.atlasSurah(this.id, n); }
312
+ surahs() { return Qvp.atlasSurahs(this.id); }
313
+ pageOfSurah(n: number) { return Qvp.atlasPageOfSurah(this.id, n); }
314
+ division(kind: DivisionKind, n: number) { return Qvp.atlasDivision(this.id, kind, n); }
315
+ juz(n: number) { return Qvp.atlasDivision(this.id, 'juz', n); }
316
+ hizb(n: number) { return Qvp.atlasDivision(this.id, 'hizb', n); }
317
+ rubuAlHizb(n: number) { return Qvp.atlasDivision(this.id, 'rubuAlHizb', n); }
318
+ divisionAt(kind: DivisionKind, s: number, a: number) { return Qvp.atlasDivisionAt(this.id, kind, s, a); }
319
+ juzAt(s: number, a: number) { return Qvp.atlasJuzAt(this.id, s, a); }
320
+ pagesOfJuz(n: number) { return Qvp.atlasPagesOfJuz(this.id, n); }
321
+ findSurah(text: string) { return Qvp.atlasFindSurah(this.id, text); }
322
+ }
323
+
324
+ type Tail<F> = F extends (tag: number, ...rest: infer R) => infer Ret ? (...rest: R) => Ret : never;
325
+ const pageMethods = ['info', 'words', 'word', 'ayahs', 'lines', 'decos', 'findWord', 'resolve', 'wordForm', 'hasForm', 'attachWords', 'surahs', 'divisions', 'ayahMarks', 'rosettes', 'sajdahs', 'ayahKeys', 'ayahWordCount', 'reciteMap', 'wordLabel', 'ayahLabel', 'text', 'search', 'citation', 'hitTestViewEx', 'wordBoxView', 'currentLayout', 'relayout', 'resetView', 'stats', 'select', 'clearSelection', 'selection', 'selectionText', 'revealNext', 'hideBack', 'revealWord', 'hideWord', 'revealAll', 'hideAll', 'maskHidden', 'maskWords', 'revealSteps', 'revealAt', 'revealStepOf', 'cropBox', 'cropSvg'] as const;
326
+ type PageMethod = (typeof pageMethods)[number];
327
+ export type PageApi = { [K in PageMethod]: Tail<(typeof Qvp)[K]> };
328
+ function bindPage(tag: () => number): PageApi {
329
+ const out: any = {};
330
+ for (const k of pageMethods) out[k] = (...args: any[]) => (Qvp[k] as any)(tag(), ...args);
331
+ return out;
332
+ }
333
+
334
+ /**
335
+ * `const qvp = useQvp(); <QvpPageView ref={qvp.ref} … />; await qvp.search('الله')`
336
+ * The page-bound API without passing a tag around.
337
+ */
338
+ export function useQvp(): PageApi & { ref: React.RefObject<QvpPageViewHandle | null>; tag: () => number } {
339
+ const ref = useRef<QvpPageViewHandle | null>(null);
340
+ return useMemo(() => {
341
+ const tag = () => ref.current?.tag() ?? -1;
342
+ return { ref, tag, ...bindPage(tag) };
343
+ }, []);
344
+ }
345
+
346
+ export default QvpPageView;