@quran.ws/text 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/quran-text.js ADDED
@@ -0,0 +1,861 @@
1
+ /**
2
+ * quran-text — read the muṣḥaf files of the quran-text dataset.
3
+ *
4
+ * import { Mushaf } from "quran-text";
5
+ * const m = await Mushaf.hafs(); // bundled Ḥafṣ
6
+ * const m = await Mushaf.load("data/mushaf/warsh.json"); // another riwāyah, Node
7
+ * const m = Mushaf.fromJson(await (await fetch(url)).json()); // browser
8
+ * m.ayah(2, 255).text
9
+ * m.ayah(2, 255).render({ marks: true, ayahMarks: true })
10
+ * m.page(3).lines
11
+ * m.juz(30).firstAyah.key // "78:1"
12
+ *
13
+ * Everything is a slice of one `words` array. A Span is a slice with `text`
14
+ * and `render()`; Surah, Ayah, Page, Line and Juz are spans that know their
15
+ * place. Positions are 0-based indices into `words`; sūrah, āyah, page, line
16
+ * and juz numbers are 1-based, as printed. Āyah numbers are in this
17
+ * edition's own count; use AyahMap to convert between editions.
18
+ *
19
+ * No dependencies. ES2020.
20
+ */
21
+
22
+ export const AYAH_MARK = "۝";
23
+
24
+ const ARABIC_INDIC = "٠١٢٣٤٥٦٧٨٩";
25
+
26
+ /** The end-of-āyah sign with its number, as the muṣḥaf prints it: ۝٢٥٥ */
27
+ export function ayahMark(number) {
28
+ return AYAH_MARK + String(number).replace(/\d/g, (d) => ARABIC_INDIC[d]);
29
+ }
30
+
31
+ // --- the search fold ---------------------------------------------------------
32
+ // Implements docs/SEARCH-FOLD.md. Three functions, and the order of operations
33
+ // inside each is load-bearing.
34
+
35
+ /** Marks, tatweel, Quranic annotation signs, joiners — everything that is not a
36
+ * letter. A category test, not a codepoint list: nothing to keep in sync when
37
+ * Unicode adds a Quranic mark, and the letters are disjoint from these six. */
38
+ const DROP_CATEGORIES = /[\p{Mn}\p{Me}\p{Lm}\p{Sk}\p{So}\p{Cf}]/gu;
39
+
40
+ const FOLD_LETTERS = new Map([
41
+ ["\u0671", "\u0627"], ["\u0623", "\u0627"], ["\u0625", "\u0627"], ["\u0622", "\u0627"],
42
+ ["\u0649", "\u064A"], ["\u0626", "\u064A"],
43
+ ["\u0624", "\u0648"],
44
+ ["\u0629", "\u0647"],
45
+ ]);
46
+ for (let d = 0; d < 10; d++) {
47
+ FOLD_LETTERS.set(String.fromCharCode(0x0660 + d), String(d));
48
+ FOLD_LETTERS.set(String.fromCharCode(0x06F0 + d), String(d));
49
+ }
50
+
51
+ const squeeze = (t) => t.split(/\s+/u).filter(Boolean).join(" ");
52
+
53
+ /**
54
+ * The stored key: letters only, nothing folded. Feed it the imlāʾī spelling,
55
+ * never the ʿUthmānī — the ʿUthmānī writes long vowels as combining marks, so
56
+ * stripping it deletes them (`ٱلۡعَٰلَمِينَ` → `العلمين`, which nobody types).
57
+ */
58
+ export function searchKey(text) {
59
+ return squeeze(text.normalize("NFC").replace(DROP_CATEGORIES, ""));
60
+ }
61
+
62
+ /**
63
+ * Applied to BOTH the query and the stored key at match time. Strips marks,
64
+ * then unifies the letter forms a user types inconsistently. Stripping must
65
+ * come first: a mark between a bearer and its hamza would block the mapping.
66
+ */
67
+ export function matchFold(text) {
68
+ let out = "";
69
+ for (const ch of text.normalize("NFKC").replace(DROP_CATEGORIES, "")) {
70
+ out += FOLD_LETTERS.get(ch) ?? ch;
71
+ }
72
+ return squeeze(out);
73
+ }
74
+
75
+ /**
76
+ * Fallback only, and only when the strict pass found nothing. Deliberately
77
+ * lossy — it merges كاتب, كتاب and كتب — so results matched this way are
78
+ * flagged `loose` and a UI should say so.
79
+ */
80
+ export function looseKey(text) {
81
+ return matchFold(text).replace(/[\u0627\u0621]/gu, "");
82
+ }
83
+
84
+ /**
85
+ * Every spelling a word might reasonably be typed as, for sources that have no
86
+ * imlāʾī column — the cross-riwāyah word index, and the six riwāyāt other than
87
+ * Ḥafṣ. The ʿUthmānī alone cannot say whether a dagger alif is written out in
88
+ * modern spelling (`مَٰلِكِ` → `مالك`) or not (`ٱلرَّحۡمَٰنِ` → `الرحمن`), so index
89
+ * both, and both hamza conventions with them.
90
+ *
91
+ * Measured against Ḥafṣ, where the true imlāʾī spelling is known: this set
92
+ * contains it for 97.83% of the 77,356 words. The rest differ orthographically
93
+ * in ways no codepoint rule reaches (`ٱلصَّلَوٰةَ` → `الصلاة`) — see
94
+ * docs/SEARCH-FOLD.md §6.
95
+ * @returns {string[]}
96
+ */
97
+ export function searchVariants(text) {
98
+ const out = new Set();
99
+ for (const dagger of [text, text.replace(/\u0670/gu, "\u0627")]) {
100
+ const base = matchFold(dagger);
101
+ out.add(base);
102
+ out.add(base.replace(/\u0621/gu, ""));
103
+ out.add(base.replace(/\u0621/gu, "\u064A"));
104
+ }
105
+ out.delete("");
106
+ return [...out];
107
+ }
108
+
109
+ /**
110
+ * @deprecated Use {@link matchFold}. Kept as an alias so existing callers keep
111
+ * working; it no longer expands the dagger alif into a full alif, which is the
112
+ * bug this replaces — `search("الرحمن")` returned 0 results.
113
+ */
114
+ export const fold = matchFold;
115
+
116
+ /** Index of the unit that contains `position` (-1 before the first). */
117
+ function indexOf(starts, position) {
118
+ let lo = 0, hi = starts.length;
119
+ while (lo < hi) {
120
+ const mid = (lo + hi) >> 1;
121
+ if (starts[mid] <= position) lo = mid + 1; else hi = mid;
122
+ }
123
+ return lo - 1;
124
+ }
125
+
126
+ const ALL_KINDS = new Set(["waqf", "division", "sajdah", "sajdah_line",
127
+ "sah", "raised_dot"]);
128
+
129
+ function markKinds(marks) {
130
+ if (marks === true) return ALL_KINDS;
131
+ if (!marks) return new Set();
132
+ return new Set(marks);
133
+ }
134
+
135
+ // --- records -----------------------------------------------------------------
136
+
137
+ /** A sign printed against a word: kind is waqf, division, sajdah, sajdah_line,
138
+ * sah or raised_dot. */
139
+ export class Mark {
140
+ constructor(kind, side, sign) {
141
+ this.kind = kind;
142
+ this.side = side;
143
+ this.sign = sign;
144
+ Object.freeze(this);
145
+ }
146
+ }
147
+
148
+ /** One printed word and everything the muṣḥaf says about it. */
149
+ export class Word {
150
+ constructor(mushaf, position) {
151
+ if (!(position >= 0 && position < mushaf.words.length)) {
152
+ throw new RangeError(`position ${position} is outside the muṣḥaf`);
153
+ }
154
+ this._m = mushaf;
155
+ this.position = position;
156
+ }
157
+ get text() { return this._m.words[this.position]; }
158
+ /** Plain modern spelling, Ḥafṣ only; null elsewhere. */
159
+ get rasm_imlai() { const c = this._m._doc.rasm_imlai; return c ? c[this.position] : null; }
160
+ get surah() { return this._m.surahAt(this.position); }
161
+ /** The āyah this word is in; null for the unnumbered basmalah. */
162
+ get ayah() { return this._m.ayahAt(this.position); }
163
+ /** 1-based position within the āyah; null when unnumbered. */
164
+ get index() { const a = this.ayah; return a ? this.position - a.start + 1 : null; }
165
+ get page() { return this._m.pageAt(this.position); }
166
+ get line() { return this._m.lineAt(this.position); }
167
+ get juz() { return this._m.juzAt(this.position); }
168
+ /** The shared number: the same word in every riwāyah. */
169
+ get number() { return this._m._numbers[this.position][0]; }
170
+ /** Equal to `number` except where this muṣḥaf writes two numbers as one word. */
171
+ get numberLast() { return this._m._numbers[this.position][1]; }
172
+ /** @returns {Mark[]} */
173
+ get marks() { return this._m._marksAt.get(this.position) ?? []; }
174
+ hasMark(kind) { return this.marks.some((mk) => mk.kind === kind); }
175
+ /** The same word in another riwāyah, by the shared number; null where it does not read it. */
176
+ to(other) { return other.wordByNumber(this.number); }
177
+ /** The word with its signs: ۞ before, waqf, the sajdah line and ۩ after. */
178
+ render(marks = true) {
179
+ const kinds = markKinds(marks);
180
+ let before = "", after = "";
181
+ for (const mk of this.marks) {
182
+ if (!kinds.has(mk.kind)) continue;
183
+ if (mk.side === "before") before += mk.sign + " "; else after += mk.sign;
184
+ }
185
+ return before + this.text + after;
186
+ }
187
+ toString() { return this.text; }
188
+ }
189
+
190
+ /** A run of positions start … end-1 of one muṣḥaf. */
191
+ export class Span {
192
+ constructor(mushaf, start, end) {
193
+ this._m = mushaf;
194
+ this.start = start;
195
+ this.end = end;
196
+ }
197
+ get mushaf() { return this._m; }
198
+ get length() { return this.end - this.start; }
199
+ /** @returns {string[]} */
200
+ get words() { return this._m.words.slice(this.start, this.end); }
201
+ /** @returns {Word[]} */
202
+ get wordList() {
203
+ const out = [];
204
+ for (let p = this.start; p < this.end; p++) out.push(new Word(this._m, p));
205
+ return out;
206
+ }
207
+ /** The words joined with spaces, without any sign. */
208
+ get text() { return this.words.join(" "); }
209
+ /**
210
+ * The text as the muṣḥaf prints it, with what you ask for.
211
+ * @param {{marks?: boolean|string[], ayahMarks?: boolean, lines?: boolean}} options
212
+ * `marks`: true for every sign, or an array of kinds among "waqf", "division",
213
+ * "sajdah", "sajdah_line". `ayahMarks` appends ۝ with the āyah number after each āyah
214
+ * that ends inside the span. `lines` breaks the text where the printed
215
+ * lines break.
216
+ */
217
+ render({ marks = false, ayahMarks = false, lines = false } = {}) {
218
+ const m = this._m;
219
+ const kinds = markKinds(marks);
220
+ const lineStarts = lines ? m._lineStartSet : null;
221
+ let out = "";
222
+ for (let position = this.start; position < this.end; position++) {
223
+ if (lineStarts && position !== this.start && lineStarts.has(position)) out += "\n";
224
+ else if (position !== this.start) out += " ";
225
+ let token = m.words[position];
226
+ const marksHere = m._marksAt.get(position);
227
+ if (marksHere) {
228
+ for (const mk of marksHere) {
229
+ if (!kinds.has(mk.kind)) continue;
230
+ token = mk.side === "before" ? mk.sign + " " + token : token + mk.sign;
231
+ }
232
+ }
233
+ out += token;
234
+ if (ayahMarks) {
235
+ const k = m._ayahEnds.get(position);
236
+ if (k !== undefined) out += " " + ayahMark(m._ayahNumber(k));
237
+ }
238
+ }
239
+ return out;
240
+ }
241
+ /** Every numbered āyah with at least one word in the span. @returns {Ayah[]} */
242
+ get ayahs() {
243
+ const m = this._m;
244
+ const first = Math.max(indexOf(m._doc.ayah_starts, this.start), 0);
245
+ const last = indexOf(m._doc.ayah_starts, this.end - 1);
246
+ const out = [];
247
+ for (let k = first; k <= last; k++) out.push(Ayah._fromIndex(m, k));
248
+ return out;
249
+ }
250
+ get firstAyah() { const a = this.ayahs; return a.length ? a[0] : null; }
251
+ get lastAyah() { const a = this.ayahs; return a.length ? a[a.length - 1] : null; }
252
+ /** @returns {Surah[]} */
253
+ get surahs() {
254
+ const m = this._m;
255
+ const first = indexOf(m._doc.surah_starts, this.start);
256
+ const last = indexOf(m._doc.surah_starts, this.end - 1);
257
+ return m.surahs.slice(first, last + 1);
258
+ }
259
+ /** @returns {Page[]} */
260
+ get pages() {
261
+ const m = this._m;
262
+ const first = indexOf(m._doc.page_starts, this.start);
263
+ const last = indexOf(m._doc.page_starts, this.end - 1);
264
+ const out = [];
265
+ for (let n = first; n <= last; n++) out.push(new Page(m, n + 1));
266
+ return out;
267
+ }
268
+ /** The page the span starts on. */
269
+ get page() { return this._m.pageAt(this.start); }
270
+ get juz() { return this._m.juzAt(this.start); }
271
+ /** Every sign inside the span, with the word it is printed on. @returns {{word: Word, mark: Mark}[]} */
272
+ get marks() {
273
+ const out = [];
274
+ for (let p = this.start; p < this.end; p++) {
275
+ for (const mark of this._m._marksAt.get(p) ?? []) out.push({ word: new Word(this._m, p), mark });
276
+ }
277
+ return out;
278
+ }
279
+ /** The index-th word of the span, 1-based. */
280
+ word(index) {
281
+ if (!(index >= 1 && index <= this.length)) {
282
+ throw new RangeError(`word ${index}: the span has ${this.length} words`);
283
+ }
284
+ return new Word(this._m, this.start + index - 1);
285
+ }
286
+ *[Symbol.iterator]() { for (let p = this.start; p < this.end; p++) yield new Word(this._m, p); }
287
+ }
288
+
289
+ /**
290
+ * Where an āyah falls in another riwāyah. `relation`: same (one āyah, the
291
+ * same words), merged (one āyah holding more), split (several āyāt), shifted
292
+ * (one āyah, boundaries crossing), unnumbered (the basmalah printed without a
293
+ * number), missing (no word of it).
294
+ */
295
+ export class AyahMatch {
296
+ constructor(ayahs, relation) { this.ayahs = ayahs; this.relation = relation; Object.freeze(this); }
297
+ get first() { return this.ayahs[0] ?? null; }
298
+ get last() { return this.ayahs[this.ayahs.length - 1] ?? null; }
299
+ /** "2:253-254" */
300
+ get key() {
301
+ if (!this.ayahs.length) return "";
302
+ const a = this.ayahs[0], b = this.ayahs[this.ayahs.length - 1];
303
+ return a.equals(b) ? a.key : `${a.key}-${b.number}`;
304
+ }
305
+ toString() { return `${this.key || "-"} (${this.relation})`; }
306
+ }
307
+
308
+ /** One numbered āyah, in this edition's own count. */
309
+ export class Ayah extends Span {
310
+ constructor(mushaf, surah, number) {
311
+ const s = mushaf.surah(surah);
312
+ if (!(number >= 1 && number <= s.ayahCount)) {
313
+ throw new RangeError(`${s.nameEn} has ${s.ayahCount} āyāt in ${mushaf.nameEn}, not ${number}`);
314
+ }
315
+ const k = s._firstAyah + number - 1;
316
+ const starts = mushaf._doc.ayah_starts;
317
+ super(mushaf, starts[k], k + 1 < starts.length ? starts[k + 1] : mushaf.words.length);
318
+ this.surah = s;
319
+ this.number = number;
320
+ this._k = k;
321
+ }
322
+ static _fromIndex(mushaf, k) {
323
+ const s = mushaf.surahs[mushaf._surahOfAyahIndex(k)];
324
+ return new Ayah(mushaf, s.number, k - s._firstAyah + 1);
325
+ }
326
+ /** "2:255" */
327
+ get key() { return `${this.surah.number}:${this.number}`; }
328
+ /** 0-based index into ayah_starts: the āyah's ordinal in the muṣḥaf. */
329
+ get index() { return this._k; }
330
+ /** The printed line the āyah starts on. */
331
+ get line() { return this._m.lineAt(this.start); }
332
+ /** Every printed line the āyah touches. @returns {Line[]} */
333
+ get lines() { return this._m._linesBetween(this.start, this.end); }
334
+ get rasm_imlai() { const c = this._m._doc.rasm_imlai; return c ? c.slice(this.start, this.end) : null; }
335
+ get hasSajdah() { return this.marks.some(({ mark }) => mark.kind === "sajdah"); }
336
+ /** ۝٢٥٥ */
337
+ get marker() { return ayahMark(this.number); }
338
+ /** The shared numbers of this āyah's words. @returns {Set<number>} */
339
+ get numbers() {
340
+ const m = this._m, first = m._numbers[this.start][0], last = m._numbers[this.end - 1][1];
341
+ const out = new Set();
342
+ for (let n = first; n <= last; n++) if (!m.missingNumbers.has(n)) out.add(n);
343
+ return out;
344
+ }
345
+ /**
346
+ * This āyah in another riwāyah: `hafs.ayah(2, 255).to(warsh)` → 2:253-254, split.
347
+ * Computed from the shared numbering, so it works between any two riwāyāt.
348
+ * @returns {AyahMatch}
349
+ */
350
+ to(other) {
351
+ const mine = this.numbers, hits = [];
352
+ let unnumbered = false;
353
+ for (const n of [...mine].sort((a, b) => a - b)) {
354
+ const w = other.wordByNumber(n);
355
+ if (!w) continue;
356
+ const a = w.ayah;
357
+ if (!a) unnumbered = true;
358
+ else if (!hits.length || !hits[hits.length - 1].equals(a)) hits.push(a);
359
+ }
360
+ if (!hits.length) return new AyahMatch([], unnumbered ? "unnumbered" : "missing");
361
+ if (hits.length > 1) return new AyahMatch(hits, "split");
362
+ const theirs = hits[0].numbers;
363
+ const same = theirs.size === mine.size && [...mine].every((n) => theirs.has(n));
364
+ const superset = [...mine].every((n) => theirs.has(n));
365
+ return new AyahMatch(hits, same ? "same" : superset ? "merged" : "shifted");
366
+ }
367
+ next() { const k = this._k + 1; return k < this._m.ayahCount ? Ayah._fromIndex(this._m, k) : null; }
368
+ previous() { const k = this._k - 1; return k >= 0 ? Ayah._fromIndex(this._m, k) : null; }
369
+ equals(other) { return other instanceof Ayah && other._m === this._m && other._k === this._k; }
370
+ toString() { return this.key; }
371
+ }
372
+
373
+ export class Surah extends Span {
374
+ constructor(mushaf, number) {
375
+ if (!(number >= 1 && number <= 114)) throw new RangeError(`sūrah ${number}: there are 114`);
376
+ const info = mushaf._doc.surahs[number - 1];
377
+ const starts = mushaf._doc.surah_starts;
378
+ super(mushaf, starts[number - 1], number < starts.length ? starts[number] : mushaf.words.length);
379
+ this.number = number;
380
+ this.nameAr = info.name_ar;
381
+ this.nameEn = info.name_en;
382
+ this.revelation = info.revelation;
383
+ this.hasBasmalah = info.has_basmalah;
384
+ this.ayahCount = info.ayah_count;
385
+ this._firstAyah = info.first_ayah;
386
+ }
387
+ /** @returns {Ayah[]} */
388
+ get ayahs() {
389
+ const out = [];
390
+ for (let n = 1; n <= this.ayahCount; n++) out.push(new Ayah(this._m, this.number, n));
391
+ return out;
392
+ }
393
+ ayah(number) { return new Ayah(this._m, this.number, number); }
394
+ /**
395
+ * The basmalah where it is printed unnumbered before āyah 1 (Warsh, Qālūn,
396
+ * Dūrī, Sūsī at al-Fātiḥah); null otherwise.
397
+ */
398
+ get basmalah() {
399
+ const first = this._m._doc.ayah_starts[this._firstAyah];
400
+ return first > this.start ? new Span(this._m, this.start, first) : null;
401
+ }
402
+ get firstPage() { return this.page; }
403
+ get lastPage() { return this._m.pageAt(this.end - 1); }
404
+ toString() { return `${this.number} ${this.nameEn}`; }
405
+ }
406
+
407
+ export class Page extends Span {
408
+ constructor(mushaf, number) {
409
+ const starts = mushaf._doc.page_starts;
410
+ if (!(number >= 1 && number <= starts.length)) {
411
+ throw new RangeError(`page ${number}: ${mushaf.nameEn} has ${starts.length} pages`);
412
+ }
413
+ super(mushaf, starts[number - 1], number < starts.length ? starts[number] : mushaf.words.length);
414
+ this.number = number;
415
+ }
416
+ /** @returns {Line[]} */
417
+ get lines() { return this._m._linesBetween(this.start, this.end); }
418
+ line(number) {
419
+ const lines = this.lines;
420
+ if (!(number >= 1 && number <= lines.length)) {
421
+ throw new RangeError(`line ${number}: page ${this.number} has ${lines.length} lines`);
422
+ }
423
+ return lines[number - 1];
424
+ }
425
+ next() { const n = this.number + 1; return n <= this._m.pageCount ? new Page(this._m, n) : null; }
426
+ previous() { const n = this.number - 1; return n >= 1 ? new Page(this._m, n) : null; }
427
+ toString() { return `page ${this.number}`; }
428
+ }
429
+
430
+ /** One printed line. Reconstructed, not read: see layers.derived.line. */
431
+ export class Line extends Span {
432
+ constructor(mushaf, page, number, index) {
433
+ const starts = mushaf._doc.line_starts;
434
+ super(mushaf, starts[index], index + 1 < starts.length ? starts[index + 1] : mushaf.words.length);
435
+ this._page = page;
436
+ this.number = number; // within the page, 1-based
437
+ this.index = index; // within the muṣḥaf, 0-based
438
+ }
439
+ static _fromIndex(mushaf, index) {
440
+ const starts = mushaf._doc.line_starts;
441
+ const page = mushaf.pageAt(starts[index]);
442
+ const first = indexOf(starts, page.start);
443
+ return new Line(mushaf, page, index - first + 1, index);
444
+ }
445
+ get page() { return this._page; }
446
+ toString() { return `page ${this._page.number}, line ${this.number}`; }
447
+ }
448
+
449
+ export class Juz extends Span {
450
+ constructor(mushaf, number) {
451
+ const starts = mushaf._doc.juz_starts;
452
+ if (!starts) throw new Error(mushaf._absent("juz"));
453
+ if (!(number >= 1 && number <= starts.length)) throw new RangeError(`juz ${number}: there are ${starts.length}`);
454
+ super(mushaf, starts[number - 1], number < starts.length ? starts[number] : mushaf.words.length);
455
+ this.number = number;
456
+ }
457
+ toString() { return `juz ${this.number}`; }
458
+ }
459
+
460
+ // --- the muṣḥaf ---------------------------------------------------------------
461
+
462
+ /** One muṣḥaf file, data/mushaf/<key>.json. */
463
+ export class Mushaf {
464
+ /** Where {@link Mushaf#checkForUpdate} looks by default. */
465
+ static VERSION_URL = "https://quran.ws/version";
466
+
467
+ constructor(doc) {
468
+ if (doc?.format !== "quran-mushaf") throw new TypeError("not a quran-mushaf file");
469
+ this._doc = doc;
470
+ /** @type {string[]} */
471
+ this.words = doc.words;
472
+ this.key = doc.mushaf.key;
473
+ this.nameEn = doc.mushaf.name_en;
474
+ this.nameAr = doc.mushaf.name_ar;
475
+ this.qiraahEn = doc.mushaf.qiraah_en ?? null;
476
+ this.qiraahAr = doc.mushaf.qiraah_ar ?? null;
477
+ this.countingSystem = doc.counting.system;
478
+ // The system the qāriʾ is associated with, beside the one this edition
479
+ // measures onto. For Dūrī and Sūsī the two differ.
480
+ this.countingSystemAssociatedWithQari = doc.counting.system_associated_with_qari;
481
+ this.basmalahCounted = doc.counting.basmalah_counted;
482
+ /** @type {Surah[]} */
483
+ this.surahs = [];
484
+ for (let n = 1; n <= 114; n++) this.surahs.push(new Surah(this, n));
485
+ this._surahFirstAyah = this.surahs.map((s) => s._firstAyah);
486
+ this._ayahEnds = new Map();
487
+ const starts = doc.ayah_starts;
488
+ for (let k = 0; k < starts.length; k++) {
489
+ const end = k + 1 < starts.length ? starts[k + 1] : this.words.length;
490
+ this._ayahEnds.set(end - 1, k);
491
+ }
492
+ const types = doc.mark_types.map((t) => new Mark(t.kind, t.side, t.sign));
493
+ this._marksAt = new Map();
494
+ for (const [position, t] of doc.marks) {
495
+ if (!this._marksAt.has(position)) this._marksAt.set(position, []);
496
+ this._marksAt.get(position).push(types[t]);
497
+ }
498
+ this._lineStartSet = new Set(doc.line_starts ?? []);
499
+ this._numbersCache = null;
500
+ this._keyCache = null;
501
+ this._looseCache = null;
502
+ }
503
+
504
+ // -- loading --
505
+
506
+ /** Ḥafṣ, the riwāyah nearly every app uses, bundled with the package together with its font. */
507
+ static async hafs() {
508
+ const url = new URL("./data/hafs.json", import.meta.url);
509
+ let m;
510
+ if (url.protocol === "file:" && typeof process !== "undefined" && process.versions?.node) {
511
+ const { readFile } = await import("node:fs/promises");
512
+ m = new Mushaf(JSON.parse(await readFile(url, "utf8")));
513
+ } else {
514
+ m = new Mushaf(await (await fetch(url)).json());
515
+ }
516
+ m._fontUrl = new URL("./data/UthmanicHafs-v-3.0.ttf", import.meta.url).href;
517
+ return m;
518
+ }
519
+ /**
520
+ * The KFGQPC font this text is set in — the only one guaranteed to draw every
521
+ * codepoint the words use. `url` is the file when the package has it
522
+ * (bundled for Ḥafṣ); otherwise take `file` from `data/fonts/`.
523
+ * @returns {{family: string, file: string, sha256: string, publisher: string, url: string | null}}
524
+ */
525
+ get font() {
526
+ const f = this._doc.font;
527
+ return { family: f.family, file: f.file, sha256: f.sha256, publisher: f.publisher, url: this._fontUrl ?? null };
528
+ }
529
+ /** A CSS `@font-face` rule for this muṣḥaf's font, from `url` or the one you pass. */
530
+ fontFace(url = this.font.url) {
531
+ return `@font-face { font-family: "${this.font.family}"; src: url("${url}") format("truetype"); }`;
532
+ }
533
+ /** Any of the seven riwāyāt: read `data/mushaf/<key>.json` (Node only). In the browser use `Mushaf.fromJson(await res.json())`. */
534
+ static async load(path) {
535
+ const { readFile } = await import("node:fs/promises");
536
+ return new Mushaf(JSON.parse(await readFile(path, "utf8")));
537
+ }
538
+ static fromJson(data) { return new Mushaf(typeof data === "string" ? JSON.parse(data) : data); }
539
+
540
+ // -- what the file carries --
541
+
542
+ /** @returns {string[]} */
543
+ get layers() { return [...this._doc.layers.present]; }
544
+ /** has("juz"), has("rasm_imlai"), has("lines") … */
545
+ has(layer) { return this._doc.layers.present.includes(layer); }
546
+ _absent(layer) {
547
+ const why = this._doc.layers.absent?.[layer] ?? "not in this file";
548
+ return `${this.nameEn} has no ${layer} layer: ${why}`;
549
+ }
550
+ get counting() { return this._doc.counting; }
551
+ get provenance() { return this._doc.provenance; }
552
+
553
+ /**
554
+ * Ask whether a newer build of this riwāyah has been published.
555
+ *
556
+ * The library never reaches the network on its own — nothing calls this for
557
+ * you. Run it when it suits your app: at start-up without awaiting it, behind
558
+ * a "check for updates" control, or on a timer. It resolves to `null` when
559
+ * the check could not be made (offline, timeout, unexpected body), and never
560
+ * rejects: a text this stable is not worth failing an app over.
561
+ *
562
+ * @param {string} [url] where to look; point at your own mirror if you host one
563
+ * @param {number} [timeoutMs=5000]
564
+ * @returns {Promise<?{edition: string, upToDate: boolean, localSource: ?string,
565
+ * latestSource: ?string, dataset: ?string, downloadUrl: string}>}
566
+ */
567
+ async checkForUpdate(url = Mushaf.VERSION_URL, timeoutMs = 5000) {
568
+ let doc;
569
+ try {
570
+ const ctl = new AbortController();
571
+ const t = setTimeout(() => ctl.abort(), timeoutMs);
572
+ try {
573
+ const r = await fetch(url, { signal: ctl.signal });
574
+ if (!r.ok) return null;
575
+ doc = await r.json();
576
+ } finally { clearTimeout(t); }
577
+ } catch { return null; }
578
+ if (doc?.format !== "quran-version") return null;
579
+ const latest = doc.editions?.[this.key];
580
+ if (!latest) return null;
581
+ const mine = this._doc.provenance?.text ?? {};
582
+ return {
583
+ edition: this.key,
584
+ upToDate: mine.sha256 === latest.source_sha256,
585
+ localSource: mine.package ?? null,
586
+ latestSource: latest.source ?? null,
587
+ dataset: doc.dataset ?? null,
588
+ downloadUrl: `https://quran.ws/files/${latest.file}`,
589
+ };
590
+ }
591
+ get wordCount() { return this.words.length; }
592
+ get ayahCount() { return this._doc.ayah_starts.length; }
593
+ get pageCount() { return this._doc.page_starts.length; }
594
+ get lineCount() { return (this._doc.line_starts ?? []).length; }
595
+ get juzCount() { return (this._doc.juz_starts ?? []).length; }
596
+
597
+ // -- units by number --
598
+
599
+ surah(number) {
600
+ if (!(number >= 1 && number <= 114)) throw new RangeError(`sūrah ${number}: there are 114`);
601
+ return this.surahs[number - 1];
602
+ }
603
+ /** Āyah `number` of `surah` in this edition's own count. */
604
+ ayah(surah, number) { return new Ayah(this, surah, number); }
605
+ page(number) { return new Page(this, number); }
606
+ juz(number) { return new Juz(this, number); }
607
+ line(page, number) { return new Page(this, page).line(number); }
608
+ /** Word `index` (1-based) of an āyah. */
609
+ word(surah, ayah, index) { return new Ayah(this, surah, ayah).word(index); }
610
+ /** Any run of positions, e.g. to render a selection. */
611
+ span(start, end) {
612
+ if (!(start >= 0 && start < end && end <= this.words.length)) {
613
+ throw new RangeError(`span ${start}:${end} is outside the muṣḥaf`);
614
+ }
615
+ return new Span(this, start, end);
616
+ }
617
+ get all() { return new Span(this, 0, this.words.length); }
618
+ /** @returns {Ayah[]} */
619
+ get ayahs() { const out = []; for (let k = 0; k < this.ayahCount; k++) out.push(Ayah._fromIndex(this, k)); return out; }
620
+ /** @returns {Page[]} */
621
+ get pages() { const out = []; for (let n = 1; n <= this.pageCount; n++) out.push(new Page(this, n)); return out; }
622
+ /** @returns {Juz[]} */
623
+ get ajza() { const out = []; for (let n = 1; n <= this.juzCount; n++) out.push(new Juz(this, n)); return out; }
624
+
625
+ // -- units by position --
626
+
627
+ wordAt(position) { return new Word(this, position); }
628
+ ayahAt(position) { const k = indexOf(this._doc.ayah_starts, position); return k >= 0 ? Ayah._fromIndex(this, k) : null; }
629
+ surahAt(position) { return this.surahs[indexOf(this._doc.surah_starts, position)]; }
630
+ pageAt(position) { return new Page(this, indexOf(this._doc.page_starts, position) + 1); }
631
+ lineAt(position) {
632
+ const starts = this._doc.line_starts;
633
+ return starts ? Line._fromIndex(this, indexOf(starts, position)) : null;
634
+ }
635
+ juzAt(position) {
636
+ const starts = this._doc.juz_starts;
637
+ return starts ? new Juz(this, indexOf(starts, position) + 1) : null;
638
+ }
639
+ _linesBetween(start, end) {
640
+ const starts = this._doc.line_starts;
641
+ if (!starts) return [];
642
+ const first = indexOf(starts, start), last = indexOf(starts, end - 1);
643
+ const out = [];
644
+ for (let i = first; i <= last; i++) out.push(Line._fromIndex(this, i));
645
+ return out;
646
+ }
647
+
648
+ // -- the shared numbering --
649
+
650
+ get _numbers() {
651
+ if (!this._numbersCache) {
652
+ const block = this._doc.numbering;
653
+ const missing = new Set(block.missing);
654
+ const joined = new Map(block.written_joined.map((j) => [j.position, j.numbers]));
655
+ const runs = [];
656
+ let n = 1;
657
+ for (let position = 0; position < this.words.length; position++) {
658
+ while (missing.has(n)) n++;
659
+ const [first, last] = joined.get(position) ?? [n, n];
660
+ runs.push([first, last]);
661
+ n = last + 1;
662
+ }
663
+ this._numbersCache = runs;
664
+ }
665
+ return this._numbersCache;
666
+ }
667
+ /** The shared numbers this riwāyah does not read. @returns {Set<number>} */
668
+ get missingNumbers() { return (this._missing ??= new Set(this._doc.numbering.missing)); }
669
+ /** The shared number of the word at `position`. */
670
+ numberAt(position) { return this._numbers[position][0]; }
671
+ /** The printed word carrying a shared number; null where this muṣḥaf does not read it. */
672
+ wordByNumber(number) {
673
+ const runs = this._numbers;
674
+ let lo = 0, hi = runs.length;
675
+ while (lo < hi) { const mid = (lo + hi) >> 1; if (runs[mid][0] <= number) lo = mid + 1; else hi = mid; }
676
+ const i = lo - 1;
677
+ return i >= 0 && runs[i][0] <= number && number <= runs[i][1] ? new Word(this, i) : null;
678
+ }
679
+
680
+ // -- signs and search --
681
+
682
+ /** Every āyah printed with ۩. @returns {Ayah[]} */
683
+ sajdat() { return this._positionsWith("sajdah").map((p) => this.ayahAt(p)); }
684
+ /** Every word printed with ۞ before it, as the release prints them. @returns {Word[]} */
685
+ divisionMarks() { return this._positionsWith("division").map((p) => new Word(this, p)); }
686
+ _positionsWith(kind) {
687
+ return [...this._marksAt.entries()]
688
+ .filter(([, ms]) => ms.some((mk) => mk.kind === kind))
689
+ .map(([p]) => p)
690
+ .sort((a, b) => a - b);
691
+ }
692
+ /**
693
+ * Every place the words of `text` occur in sequence, matched on fold():
694
+ * harakah and hamzah forms do not matter.
695
+ * @returns {Span[]}
696
+ */
697
+ /**
698
+ * Every place the words of `text` occur in sequence.
699
+ *
700
+ * Matched on {@link matchFold} against the imlāʾī spelling where the muṣḥaf
701
+ * has one, so `الرحمن` — what a phone keyboard produces — finds `ٱلرَّحۡمَٰنِ`.
702
+ * When the strict pass finds nothing and `loose` is set, retries on
703
+ * {@link looseKey} and marks every span it returns `loose = true`.
704
+ * @returns {Span[]}
705
+ */
706
+ search(text, { loose = true } = {}) {
707
+ const strict = matchFold(text).split(" ").filter(Boolean);
708
+ if (!strict.length) return [];
709
+ if (!this._keyCache) {
710
+ const imlai = this._doc.rasm_imlai;
711
+ // An absent imlāʾī spelling falls back to the ʿUthmānī, which is an
712
+ // approximation for that word — see docs/SEARCH-FOLD.md §6.
713
+ this._keyCache = this.words.map((w, i) => matchFold((imlai && imlai[i]) || w));
714
+ }
715
+ const run = (query, keys, isLoose) => {
716
+ const n = query.length, out = [];
717
+ for (let i = 0; i + n <= keys.length; i++) {
718
+ let ok = true;
719
+ for (let j = 0; j < n; j++) if (keys[i + j] !== query[j]) { ok = false; break; }
720
+ if (ok) { const sp = new Span(this, i, i + n); sp.loose = isLoose; out.push(sp); }
721
+ }
722
+ return out;
723
+ };
724
+ const hits = run(strict, this._keyCache, false);
725
+ if (hits.length || !loose) return hits;
726
+ const lq = looseKey(text).split(" ").filter(Boolean);
727
+ if (!lq.length) return hits;
728
+ if (!this._looseCache) this._looseCache = this._keyCache.map(looseKey);
729
+ return run(lq, this._looseCache, true);
730
+ }
731
+
732
+ // -- internals --
733
+
734
+ _surahOfAyahIndex(k) { return indexOf(this._surahFirstAyah, k); }
735
+ _ayahNumber(k) { return k - this._surahFirstAyah[this._surahOfAyahIndex(k)] + 1; }
736
+ toString() { return `Mushaf(${this.key})`; }
737
+ }
738
+
739
+ // --- āyah map ----------------------------------------------------------------
740
+
741
+ /**
742
+ * Where a Kūfī āyah falls in one edition. `relation` is same, merged, split
743
+ * (then `ayahLast` is set), shifted or unnumbered (`ayah` is 0).
744
+ */
745
+ export class MappedAyah {
746
+ constructor(surah, ayah, relation, ayahLast = null) {
747
+ this.surah = surah; this.ayah = ayah; this.relation = relation; this.ayahLast = ayahLast;
748
+ Object.freeze(this);
749
+ }
750
+ get key() { return this.ayahLast ? `${this.surah}:${this.ayah}-${this.ayahLast}` : `${this.surah}:${this.ayah}`; }
751
+ toString() { return this.key; }
752
+ }
753
+
754
+ /** data/ayah-map.json: what a Ḥafṣ (Kūfī) reference is in every edition. */
755
+ export class AyahMap {
756
+ constructor(doc) {
757
+ if (doc?.format !== "quran-ayah-map") throw new TypeError("not a quran-ayah-map file");
758
+ /** @type {string[]} */
759
+ this.editions = doc.editions;
760
+ this._rows = new Map(doc.ayahs.map((r) => [`${r.surah}:${r.ayah}`, r]));
761
+ }
762
+ static async load(path) {
763
+ const { readFile } = await import("node:fs/promises");
764
+ return new AyahMap(JSON.parse(await readFile(path, "utf8")));
765
+ }
766
+ static fromJson(data) { return new AyahMap(typeof data === "string" ? JSON.parse(data) : data); }
767
+ /** convert(2, 255, "warsh") → MappedAyah { surah: 2, ayah: 253, relation: "split", ayahLast: 254 } */
768
+ convert(surah, ayah, to) {
769
+ const row = this._rows.get(`${surah}:${ayah}`);
770
+ if (!row) throw new RangeError(`${surah}:${ayah} is not a Kūfī āyah`);
771
+ const r = row[to];
772
+ if (!r) throw new RangeError(`no edition "${to}"; editions are ${this.editions.join(", ")}`);
773
+ return new MappedAyah(r.surah, r.ayah, r.relation, r.ayah_last ?? null);
774
+ }
775
+ /** The reference in every edition. @returns {Record<string, MappedAyah>} */
776
+ all(surah, ayah) {
777
+ return Object.fromEntries(this.editions.map((e) => [e, this.convert(surah, ayah, e)]));
778
+ }
779
+ }
780
+
781
+ // --- word index --------------------------------------------------------------
782
+
783
+ /** One record of data/word-index.json: a shared number and what it is. */
784
+ export class IndexedWord {
785
+ constructor(record) { this._r = record; }
786
+ get number() { return this._r.number; }
787
+ get surah() { return this._r.surah; }
788
+ get index() { return this._r.index; }
789
+ get key() { return this._r.key; }
790
+ get rasm_uthmani() { return this._r.rasm_uthmani; }
791
+ get plain() { return this._r.plain; }
792
+ get rasm() { return this._r.rasm; }
793
+ get pointed() { return this._r.pointed; }
794
+ get status() { return this._r.status; }
795
+ /** {surah, ayah, position} in the Kūfī count, or null where Ḥafṣ lacks the word. */
796
+ get hafs() { return this._r.hafs; }
797
+ /** Āyah number per riwāyah. */
798
+ get ayah() { return this._r.ayah; }
799
+ /** Each riwāyah's own spelling; absent where it does not read the word. */
800
+ get forms() { return this._r.forms; }
801
+ get groups() { return this._r.groups ?? []; }
802
+ get missing() { return this._r.missing ?? []; }
803
+ get writtenJoined() { return this._r.written_joined ?? []; }
804
+ /** How one riwāyah spells it; null where it does not read the word. */
805
+ form(riwayah) { return this._r.forms[riwayah] ?? null; }
806
+ get raw() { return this._r; }
807
+ toString() { return `${this.number} ${this.rasm_uthmani}`; }
808
+ }
809
+
810
+ /** data/word-index.json: the numbering shared by all seven muṣḥafs. */
811
+ export class WordIndex {
812
+ constructor(doc) {
813
+ if (doc?.format !== "quran-word-index") throw new TypeError("not a quran-word-index file");
814
+ /** @type {string[]} */
815
+ this.mushafs = doc.mushafs;
816
+ this.total = doc.total;
817
+ this._words = doc.words;
818
+ this._byHafs = null;
819
+ this._byPlain = null;
820
+ }
821
+ static async load(path) {
822
+ const { readFile } = await import("node:fs/promises");
823
+ return new WordIndex(JSON.parse(await readFile(path, "utf8")));
824
+ }
825
+ static fromJson(data) { return new WordIndex(typeof data === "string" ? JSON.parse(data) : data); }
826
+ word(number) {
827
+ if (!(number >= 1 && number <= this.total)) throw new RangeError(`number ${number}: the numbering is 1 … ${this.total}`);
828
+ return new IndexedWord(this._words[number - 1]);
829
+ }
830
+ /** By Ḥafṣ coordinates: sūrah, āyah in the Kūfī count, 1-based word. */
831
+ find(surah, ayah, index) {
832
+ if (!this._byHafs) {
833
+ this._byHafs = new Map();
834
+ for (const r of this._words) {
835
+ const h = r.hafs;
836
+ if (h) { const k = `${h.surah}:${h.ayah}:${h.position}`; if (!this._byHafs.has(k)) this._byHafs.set(k, r); }
837
+ }
838
+ }
839
+ const r = this._byHafs.get(`${surah}:${ayah}:${index}`);
840
+ return r ? new IndexedWord(r) : null;
841
+ }
842
+ /** Every number whose folded spelling equals `text`, folded. @returns {IndexedWord[]} */
843
+ search(text) {
844
+ if (!this._byPlain) {
845
+ this._byPlain = new Map();
846
+ for (const r of this._words) {
847
+ // No imlāʾī here — this index is cross-riwāyah — so every plausible
848
+ // spelling of the word is a key. See searchVariants().
849
+ for (const k of searchVariants(r.rasm_uthmani)) {
850
+ if (!this._byPlain.has(k)) this._byPlain.set(k, []);
851
+ if (this._byPlain.get(k).at(-1) !== r) this._byPlain.get(k).push(r);
852
+ }
853
+ }
854
+ }
855
+ return (this._byPlain.get(matchFold(text)) ?? []).map((r) => new IndexedWord(r));
856
+ }
857
+ /** Every number the riwāyāt spell in more than one way. @returns {IndexedWord[]} */
858
+ differing() { return this._words.filter((r) => r.groups).map((r) => new IndexedWord(r)); }
859
+ get length() { return this.total; }
860
+ *[Symbol.iterator]() { for (const r of this._words) yield new IndexedWord(r); }
861
+ }