@quran.ws/engine 0.1.0 → 0.3.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/web/qvp.js CHANGED
@@ -6,14 +6,26 @@
6
6
  'use strict';
7
7
 
8
8
  const NONE = 0xffffffff;
9
+ // the reader's zoom control: what a pinch does to the page
10
+ const ZOOM_MODE = { stepped: 0, continuous: 1, magnify: 2 };
11
+ const ZOOM_MODE_NAME = ['stepped', 'continuous', 'magnify'];
12
+ /** The defaults every wrapper shares (QVP_DEFAULT_* in qvp.h; the parity check compares them). */
13
+ const DEFAULTS = { INK: 0x231f20ff, HIGHLIGHT_INK: 0x1a73e8ff, HIGHLIGHT_BAND: 0xd6a3264d, HIGHLIGHT_PAD_X: 1.2, HIGHLIGHT_PAD_Y: 0, HIGHLIGHT_SEAM: 0.25,
14
+ SELECTION_BAND: 0x2d6fd640, GAP_BIAS: 0.6, TAP_DISTANCE: 6, GRID_LINES: 15, ASPECT_SLACK: 1.15, MASK_BLOCK: 0xd9d4c8ff, MASK_PAD: 0.6, MASK_RADIUS: 0.8,
15
+ REVEAL_LIT: 1, REVEAL_GREY: 0xc9c4b8ff, CROP_PAD: 2,
16
+ MIN_ZOOM: 0.5, MAX_ZOOM: 12, ZOOMED_THRESHOLD: 1.02, SWIPE_AXIS_RATIO: 1.5, SWIPE_DISTANCE: 40, SWIPE_VELOCITY: 500,
17
+ ZOOM_SNAP_HYSTERESIS: 0.03, ZOOM_QUANTUM: 0.01 };
9
18
  const KIND = { BODY: 0, MARK: 1, AYAH_NUMBER: 2, AYAH_MARK_ORNAMENT: 3, HEADER_INK: 4, ORNAMENT: 5, PAGE_NUMBER: 6, RUNNING_HEAD: 7, OTHER: 255 };
10
19
  const FAMILY = { NONE: 0, DIACRITIC: 1, TANWIN: 2, DOTS: 3, WAQF: 4, SIFR: 5, SAJDAH: 6, READING_SIGN: 7 };
11
20
  const CATEGORY = { NONE: 0, HARAKAH: 1, TANWIN: 2, LETTER_DOT: 3, ORTHOGRAPHIC: 4, DABT: 5, WAQF: 6, READING_SIGN: 7, STANDALONE: 8 };
12
- const DECO = { AYAH_MARK: 0, SURAH_NAME: 1, BASMALAH: 2, DIVISION_MARK: 3, SAJDAH_MARK: 4, PAGE_NUMBER: 5, RUNNING_HEAD: 6, OTHER: 255 };
21
+ const DECORATION = { AYAH_MARK: 0, SURAH_NAME: 1, BASMALAH: 2, DIVISION_MARK: 3, SAJDAH_MARK: 4, PAGE_NUMBER: 5, RUNNING_HEAD: 6, OTHER: 255 };
13
22
  const FORM = { rasm_uthmani: 0, rasm_imlai: 1, qpc: 2, rasm: 3, search: 4 };
14
23
  const LAYER = { BASE: 0, THEME: 10, HIGHLIGHT: 50, SELECTION: 60, TOP: 100 };
15
- 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'];
16
- const MARK = Object.fromEntries(MARKS.map((n, i) => [n, i]).filter(([n]) => n));
24
+ // The engine's name tables (QVP_NAMES_*), read from the engine when it is initialised.
25
+ const NAMES_TABLE = { mark: 0, kind: 1, family: 2, category: 3, decoration: 4, division: 5, place: 6 };
26
+ let NAMES = null;
27
+ const names = table => { if (!NAMES) throw new Error('QvpEngine.init() first: names come from the engine'); return NAMES[table]; };
28
+ const idOf = (table, v) => { if (typeof v !== 'string') return v; const i = names(table).indexOf(v); return i < 0 ? 255 : i; };
17
29
 
18
30
  // colours: numbers are 0xRRGGBBAA; strings '#rgb', '#rrggbb', '#rrggbbaa'
19
31
  function rgba(c, alpha) {
@@ -28,35 +40,35 @@
28
40
 
29
41
  // Selectors (what a style rule applies to)
30
42
  const Sel = {
31
- page: () => ({ kind: 0 }),
32
- path: i => ({ kind: 1, a: i }),
33
- wordPath: (w, n) => ({ kind: 2, a: w, b: n }),
34
- wordMark: (w, n) => ({ kind: 3, a: w, b: n }), // nth mark of the word (0-based)
35
- wordMarkNamed: (w, mark, n = 0) => ({ kind: 4, a: w, b: markId(mark), c: n }),
36
- wordBody: w => ({ kind: 5, a: w }),
37
- wordMarks: w => ({ kind: 6, a: w }),
38
- word: w => ({ kind: 7, a: w }),
39
- ayah: (s, a) => ({ kind: 8, a: s, b: a }),
40
- line: n => ({ kind: 9, a: n }),
41
- mark: m => ({ kind: 10, a: markId(m) }),
42
- category: c => ({ kind: 11, a: typeof c === 'string' ? CATEGORY[c.toUpperCase().replace('-', '_')] : c }),
43
- family: f => ({ kind: 12, a: typeof f === 'string' ? FAMILY[f.toUpperCase().replace('-', '_')] : f }),
44
- kind: k => ({ kind: 13, a: typeof k === 'string' ? KIND[k.toUpperCase()] : k }),
45
- deco: k => ({ kind: 14, a: typeof k === 'string' ? DECO[k.toUpperCase().replace('-', '_')] : k }),
46
- decoIdx: i => ({ kind: 15, a: i }),
43
+ page: () => ({ selector: 0 }),
44
+ path: i => ({ selector: 1, a: i }),
45
+ wordPath: (w, n) => ({ selector: 2, a: w, b: n }),
46
+ wordMark: (w, n) => ({ selector: 3, a: w, b: n }), // nth mark of the word (0-based)
47
+ wordMarkNamed: (w, mark, n = 0) => ({ selector: 4, a: w, b: markId(mark), c: n }),
48
+ wordBody: w => ({ selector: 5, a: w }),
49
+ wordMarks: w => ({ selector: 6, a: w }),
50
+ word: w => ({ selector: 7, a: w }),
51
+ ayah: (s, a) => ({ selector: 8, a: s, b: a }),
52
+ line: n => ({ selector: 9, a: n }),
53
+ mark: m => ({ selector: 10, a: markId(m) }),
54
+ category: c => ({ selector: 11, a: idOf('category', c) }),
55
+ family: f => ({ selector: 12, a: idOf('family', f) }),
56
+ kind: k => ({ selector: 13, a: idOf('kind', k) }),
57
+ decoration: k => ({ selector: 14, a: idOf('decoration', k) }),
58
+ decorationIndex: i => ({ selector: 15, a: i }),
47
59
  };
48
- const markId = m => (typeof m === 'string' ? (MARK[m] ?? 255) : m);
60
+ const markId = m => idOf('mark', m);
49
61
 
50
62
  // Targets (what resolves to a word list). Strings: 'page', '2:255', '2:255:3', '2:255-257', 'line:7', 'surah:2'
51
63
  const T = {
52
- page: () => ({ kind: 0 }),
53
- word: i => ({ kind: 1, a: i }),
54
- words: ws => ({ kind: 2, words: ws }),
55
- ayah: (s, a) => ({ kind: 3, a: s, b: a }),
56
- ayahRange: (s, a, b) => ({ kind: 4, a: s, b: a, c: b }),
57
- line: n => ({ kind: 5, a: n }),
58
- surah: s => ({ kind: 6, a: s }),
59
- range: (a, b) => ({ kind: 7, a, b }),
64
+ page: () => ({ target: 0 }),
65
+ word: i => ({ target: 1, a: i }),
66
+ words: ws => ({ target: 2, words: ws }),
67
+ ayah: (s, a) => ({ target: 3, a: s, b: a }),
68
+ ayahRange: (s, a, b) => ({ target: 4, a: s, b: a, c: b }),
69
+ line: n => ({ target: 5, a: n }),
70
+ surah: s => ({ target: 6, a: s }),
71
+ range: (a, b) => ({ target: 7, a, b }),
60
72
  };
61
73
 
62
74
  class QvpEngine {
@@ -69,27 +81,63 @@
69
81
  this.mem = this.ex.memory;
70
82
  this.scratch = this.ex.qvp_alloc(1 << 16); // 64 KB for outputs
71
83
  this.scratch2 = this.ex.qvp_alloc(1 << 16); // 64 KB for inputs
84
+ this._buf = 0; this._bufLen = 0; // grown on demand for the bigger reads
72
85
  this.td = new TextDecoder();
73
86
  this.te = new TextEncoder();
87
+ // Load every name table from the engine once; Sel and markId read them.
88
+ NAMES = Object.fromEntries(Object.entries(NAMES_TABLE).map(([k, t]) => [k, this.names(t)]));
74
89
  }
90
+ /** All names of a table (QVP_NAMES_* id or key), index = id. */
91
+ names(table) { const t = typeof table === 'string' ? NAMES_TABLE[table] : table; const n = this.ex.qvp_name_count(t); return Array.from({ length: n }, (_, i) => this.name(t, i)); }
92
+ nameCount(table) { return this.ex.qvp_name_count(typeof table === 'string' ? NAMES_TABLE[table] : table); }
93
+ name(table, id) { this.ex.qvp_name(typeof table === 'string' ? NAMES_TABLE[table] : table, id, this.scratch); return this.qstr(this.scratch); }
94
+ nameId(table, s) { const [p, n] = this.putStr(s); return this.ex.qvp_name_id(typeof table === 'string' ? NAMES_TABLE[table] : table, p, n); }
75
95
  dv() { return new DataView(this.mem.buffer); }
76
96
  str(ptr, len) { return this.td.decode(new Uint8Array(this.mem.buffer, ptr, len)); }
77
97
  qstr(at) { const d = this.dv(); return this.str(d.getUint32(at, true), d.getUint32(at + 4, true)); }
98
+ /** the reader's pan and zoom: {scale, offsetX, offsetY} in, the same out. The engine owns
99
+ * the arithmetic so every platform pinches alike; the host owns only the gesture. */
100
+ _putView(v, at) { const d = this.dv(); d.setFloat32(at, v.scale, true); d.setFloat32(at + 4, v.offsetX, true); d.setFloat32(at + 8, v.offsetY, true); return at; }
101
+ _getView(at) { const d = this.dv(); return { scale: d.getFloat32(at, true), offsetX: d.getFloat32(at + 4, true), offsetY: d.getFloat32(at + 8, true) }; }
102
+ /** zoom about a point of the viewport (the point between two fingers), clamped */
103
+ viewZoomAbout(view, focalX, focalY, factor, { min = 0, max = 0 } = {}) {
104
+ const i = this._putView(view, this.scratch2 + 32768);
105
+ this.ex.qvp_view_zoom_about(i, focalX, focalY, factor, min, max, this.scratch);
106
+ return this._getView(this.scratch);
107
+ }
108
+ /** move by a drag, in viewport px */
109
+ viewPan(view, dx, dy) { const i = this._putView(view, this.scratch2 + 32768); this.ex.qvp_view_pan(i, dx, dy, this.scratch); return this._getView(this.scratch); }
110
+ /** hold the content against the viewport: centre what does not fill it, cover what does */
111
+ viewClamp(view, contentW, contentH, viewportW, viewportH) { const i = this._putView(view, this.scratch2 + 32768); this.ex.qvp_view_clamp(i, contentW, contentH, viewportW, viewportH, this.scratch); return this._getView(this.scratch); }
112
+ /** +1 or -1 when a released drag is a page swipe, 0 when it is not */
113
+ viewSwipe(dx, dy, vx = 0, vy = 0) { return this.ex.qvp_view_swipe(dx, dy, vx, vy); }
114
+ /** how many pages a released drag turns, in reading order: a mushaf runs right to left, so a
115
+ * flick to the right is the next page. Add it to the page you are on. */
116
+ swipePages(dx, dy, vx = 0, vy = 0) { return this.ex.qvp_swipe_pages(dx, dy, vx, vy); }
117
+ /** a scratch buffer of at least `len` bytes, kept between calls */
118
+ buf(len) { if (len > this._bufLen) { this._buf = this.ex.qvp_alloc(len); this._bufLen = len; } return this._buf; }
78
119
  /** write a JS string into scratch2 at offset; returns [ptr, len] */
79
120
  putStr(s, off = 0) { const b = this.te.encode(s); new Uint8Array(this.mem.buffer, this.scratch2 + off, b.length).set(b); return [this.scratch2 + off, b.length]; }
80
121
  putU32(arr, off = 0) { const p = this.scratch2 + off; new Uint32Array(this.mem.buffer, p, arr.length).set(arr); return p; }
81
122
  nameOf(fn, v) { this.ex[fn](v, this.scratch); return this.qstr(this.scratch); }
82
- markName(m) { return MARKS[m] || 'unknown'; }
123
+ markName(m) { return this.nameOf('qvp_mark_name', m); }
83
124
  familyName(f) { return this.nameOf('qvp_family_name', f); }
84
125
  kindName(k) { return this.nameOf('qvp_kind_name', k); }
85
126
  categoryName(c) { return this.nameOf('qvp_category_name', c); }
86
127
  markCategory(m) { return this.ex.qvp_mark_category(markId(m)); }
128
+ markFromName(s) { const [p, n] = this.putStr(s); return this.ex.qvp_mark_from_name(p, n); }
129
+ /** The engine version, e.g. '0.2.0'. */
130
+ version() { this.ex.qvp_version(this.scratch); return this.qstr(this.scratch); }
131
+ /** The page format version the engine reads. */
132
+ formatVersion() { return this.ex.qvp_format_version(); }
133
+ /** The engine's name, `qvp` (a NUL-terminated C string in wasm memory). */
134
+ engineName() { const p = this.ex.qvp_engine_name(), u = new Uint8Array(this.mem.buffer); let n = 0; while (u[p + n]) n++; return this.str(p, n); }
87
135
  /** Arabic text tools */
88
136
  strip(s) { return this._arabic(0, s); }
89
137
  fold(s) { return this._arabic(1, s); }
90
138
  normalize(s) { return this._arabic(2, s); }
91
139
  looseKey(s) { return this._arabic(3, s); }
92
- _arabic(kind, s) { const [p, n] = this.putStr(s); this.ex.qvp_arabic(kind, p, n, this.scratch); return this.qstr(this.scratch); }
140
+ _arabic(op, s) { const [p, n] = this.putStr(s); this.ex.qvp_arabic(op, p, n, this.scratch); return this.qstr(this.scratch); }
93
141
  loadPage(bytes) {
94
142
  const p = this.ex.qvp_alloc(bytes.length);
95
143
  new Uint8Array(this.mem.buffer, p, bytes.length).set(bytes);
@@ -106,15 +154,13 @@
106
154
  if (!h) throw new Error('qvp_atlas_load failed');
107
155
  return new QvpAtlas(this, h);
108
156
  }
109
- gapToFill(pageW, pageH, lines, viewW, viewH, max = 0) { return this.ex.qvp_gap_to_fill(pageW, pageH, lines, viewW, viewH, max); }
110
- wastedFraction(pageW, pageH, viewW, viewH) { return this.ex.qvp_wasted_fraction(pageW, pageH, viewW, viewH); }
111
157
  }
112
158
 
113
159
  // struct writers (wasm32 layouts, see qvp.h)
114
160
  function writeTarget(e, at, t) {
115
161
  if (typeof t === 'string') t = parseTarget(t);
116
162
  const d = e.dv();
117
- d.setUint8(at, t.kind); d.setUint32(at + 4, t.a >>> 0 || 0, true); d.setUint32(at + 8, t.b >>> 0 || 0, true); d.setUint32(at + 12, t.c >>> 0 || 0, true);
163
+ d.setUint8(at, t.target); d.setUint32(at + 4, t.a >>> 0 || 0, true); d.setUint32(at + 8, t.b >>> 0 || 0, true); d.setUint32(at + 12, t.c >>> 0 || 0, true);
118
164
  let wp = 0, wn = 0;
119
165
  if (t.words) { wp = e.putU32(Uint32Array.from(t.words), 1024); wn = t.words.length; }
120
166
  d.setUint32(at + 16, wp, true); d.setUint32(at + 20, wn, true);
@@ -126,16 +172,16 @@
126
172
  if ((m = /^line:(\d+)$/.exec(s))) return T.line(+m[1]);
127
173
  if ((m = /^surah:(\d+)$/.exec(s))) return T.surah(+m[1]);
128
174
  if ((m = /^(\d+):(\d+)-(\d+)$/.exec(s))) return T.ayahRange(+m[1], +m[2], +m[3]);
129
- if ((m = /^(\d+):(\d+):(\d+)$/.exec(s))) return { kind: 1, wordKey: [+m[1], +m[2], +m[3]] };
175
+ if ((m = /^(\d+):(\d+):(\d+)$/.exec(s))) return { target: 1, wordKey: [+m[1], +m[2], +m[3]] };
130
176
  if ((m = /^(\d+):(\d+)$/.exec(s))) return T.ayah(+m[1], +m[2]);
131
177
  throw new Error('bad target ' + s);
132
178
  }
133
179
  function writeSel(e, at, s) {
134
180
  const d = e.dv();
135
- d.setUint8(at, s.kind); d.setUint32(at + 4, s.a >>> 0 || 0, true); d.setUint32(at + 8, s.b >>> 0 || 0, true); d.setUint32(at + 12, s.c >>> 0 || 0, true);
181
+ d.setUint8(at, s.selector); d.setUint32(at + 4, s.a >>> 0 || 0, true); d.setUint32(at + 8, s.b >>> 0 || 0, true); d.setUint32(at + 12, s.c >>> 0 || 0, true);
136
182
  return at;
137
183
  }
138
- const HL_DEFAULT = { mode: 'band', height: 'pitch', ink: '#1a73e8', band: rgba('#d6a326', 0.3), padX: 1.2, padY: 0, radius: 0, seam: 0.25, ms: 0, layer: LAYER.HIGHLIGHT };
184
+ const HL_DEFAULT = { mode: 'band', height: 'lineSpacing', ink: DEFAULTS.HIGHLIGHT_INK, band: DEFAULTS.HIGHLIGHT_BAND, padX: DEFAULTS.HIGHLIGHT_PAD_X, padY: DEFAULTS.HIGHLIGHT_PAD_Y, radius: 0, seam: DEFAULTS.HIGHLIGHT_SEAM, ms: 0, layer: LAYER.HIGHLIGHT };
139
185
  function writeHl(e, at, st) {
140
186
  st = { ...HL_DEFAULT, ...st };
141
187
  const d = e.dv();
@@ -158,7 +204,7 @@
158
204
  ex.qvp_page_info(handle, s);
159
205
  let d = engine.dv();
160
206
  this.width = d.getFloat32(s, true); this.height = d.getFloat32(s + 4, true); this.page = d.getUint32(s + 8, true);
161
- this.nLines = d.getUint32(s + 12, true); this.nAyahs = d.getUint32(s + 16, true); this.nWords = d.getUint32(s + 20, true); this.nPaths = d.getUint32(s + 24, true); this.nDecos = d.getUint32(s + 28, true);
207
+ this.nLines = d.getUint32(s + 12, true); this.nAyahs = d.getUint32(s + 16, true); this.nWords = d.getUint32(s + 20, true); this.nPaths = d.getUint32(s + 24, true); this.nDecorations = d.getUint32(s + 28, true);
162
208
  ex.qvp_geometry(handle, s);
163
209
  d = engine.dv();
164
210
  const opsPtr = d.getUint32(s, true), opsLen = d.getUint32(s + 4, true), ptsPtr = d.getUint32(s + 8, true), ptsLen = d.getUint32(s + 12, true), tabPtr = d.getUint32(s + 16, true), nPaths = d.getUint32(s + 20, true);
@@ -169,10 +215,10 @@
169
215
  this.words = Array.from({ length: this.nWords }, (_, i) => this._word(i));
170
216
  this.ayahs = Array.from({ length: this.nAyahs }, (_, i) => this._ayah(i));
171
217
  this.lines = Array.from({ length: this.nLines }, (_, i) => this._line(i));
172
- this.decos = Array.from({ length: this.nDecos }, (_, i) => this._deco(i));
173
- this.naturalPitch = ex.qvp_natural_pitch(handle);
218
+ this.decorations = Array.from({ length: this.nDecorations }, (_, i) => this._deco(i));
219
+ this.lineSpacing = ex.qvp_page_line_spacing(handle);
174
220
  this.currentLayout = null;
175
- this._defaultInk = 0x231f20ff;
221
+ this._defaultInk = DEFAULTS.INK;
176
222
  }
177
223
  free() { this.e.ex.qvp_page_free(this.h); this.h = 0; }
178
224
 
@@ -211,25 +257,25 @@
211
257
  _word(i) {
212
258
  const ex = this.e.ex, s = this.e.scratch; if (!ex.qvp_word_info(this.h, i, s)) return null;
213
259
  const d = this.e.dv();
214
- return { idx: i, surah: d.getUint16(s, true), ayah: d.getUint16(s + 2, true), word: d.getUint16(s + 4, true), line: d.getUint16(s + 6, true), ayahIdx: d.getUint32(s + 8, true), lineIdx: d.getUint32(s + 12, true),
260
+ return { index: i, surah: d.getUint16(s, true), ayah: d.getUint16(s + 2, true), word: d.getUint16(s + 4, true), line: d.getUint16(s + 6, true), ayahIndex: d.getUint32(s + 8, true), lineIndex: d.getUint32(s + 12, true),
215
261
  x0: d.getFloat32(s + 16, true), y0: d.getFloat32(s + 20, true), x1: d.getFloat32(s + 24, true), y1: d.getFloat32(s + 28, true), text: this.e.qstr(s + 32), firstPath: d.getUint32(s + 40, true), nPaths: d.getUint32(s + 44, true) };
216
262
  }
217
263
  _ayah(i) {
218
264
  const ex = this.e.ex, s = this.e.scratch; if (!ex.qvp_ayah_info(this.h, i, s)) return null;
219
265
  const d = this.e.dv();
220
- return { idx: i, surah: d.getUint16(s, true), ayah: d.getUint16(s + 2, true), fragment: d.getUint8(s + 4), fragments: d.getUint8(s + 5), flags: d.getUint8(s + 6), rubuAlHizb: d.getUint16(s + 8, true),
221
- firstWord: d.getUint32(s + 12, true), nWords: d.getUint32(s + 16, true), ayahMarkDeco: d.getUint32(s + 20, true), x0: d.getFloat32(s + 24, true), y0: d.getFloat32(s + 28, true), x1: d.getFloat32(s + 32, true), y1: d.getFloat32(s + 36, true) };
266
+ return { index: i, surah: d.getUint16(s, true), ayah: d.getUint16(s + 2, true), fragment: d.getUint8(s + 4), fragments: d.getUint8(s + 5), flags: d.getUint8(s + 6), rubuAlHizb: d.getUint16(s + 8, true),
267
+ firstWord: d.getUint32(s + 12, true), nWords: d.getUint32(s + 16, true), ayahMarkDecoration: d.getUint32(s + 20, true), x0: d.getFloat32(s + 24, true), y0: d.getFloat32(s + 28, true), x1: d.getFloat32(s + 32, true), y1: d.getFloat32(s + 36, true) };
222
268
  }
223
269
  _line(i) {
224
270
  const ex = this.e.ex, s = this.e.scratch; if (!ex.qvp_line_info(this.h, i, s)) return null;
225
271
  const d = this.e.dv();
226
- return { idx: i, lineNo: d.getUint8(s), isHeader: !!d.getUint8(s + 1), firstWord: d.getUint32(s + 4, true), nWords: d.getUint32(s + 8, true), x0: d.getFloat32(s + 12, true), y0: d.getFloat32(s + 16, true), x1: d.getFloat32(s + 20, true), y1: d.getFloat32(s + 24, true),
272
+ return { index: i, lineNumber: d.getUint8(s), isHeader: !!d.getUint8(s + 1), firstWord: d.getUint32(s + 4, true), nWords: d.getUint32(s + 8, true), x0: d.getFloat32(s + 12, true), y0: d.getFloat32(s + 16, true), x1: d.getFloat32(s + 20, true), y1: d.getFloat32(s + 24, true),
227
273
  bandY0: d.getFloat32(s + 28, true), bandY1: d.getFloat32(s + 32, true), centre: d.getFloat32(s + 36, true) };
228
274
  }
229
275
  _deco(i) {
230
- const ex = this.e.ex, s = this.e.scratch; if (!ex.qvp_deco_info(this.h, i, s)) return null;
276
+ const ex = this.e.ex, s = this.e.scratch; if (!ex.qvp_decoration_info(this.h, i, s)) return null;
231
277
  const d = this.e.dv();
232
- return { idx: i, kind: d.getUint8(s), surah: d.getUint16(s + 2, true), ayah: d.getUint16(s + 4, true), line: d.getUint32(s + 8, true), x0: d.getFloat32(s + 12, true), y0: d.getFloat32(s + 16, true), x1: d.getFloat32(s + 20, true), y1: d.getFloat32(s + 24, true),
278
+ return { index: i, decoration: d.getUint8(s), surah: d.getUint16(s + 2, true), ayah: d.getUint16(s + 4, true), line: d.getUint32(s + 8, true), x0: d.getFloat32(s + 12, true), y0: d.getFloat32(s + 16, true), x1: d.getFloat32(s + 20, true), y1: d.getFloat32(s + 24, true),
233
279
  text: this.e.qstr(s + 28), firstPath: d.getUint32(s + 36, true), nPaths: d.getUint32(s + 40, true) };
234
280
  }
235
281
  wordForm(i, form = 'rasm_uthmani') { const s = this.e.scratch; if (!this.e.ex.qvp_word_form(this.h, i, FORM[form] ?? 0, s)) return ''; return this.e.qstr(s); }
@@ -240,37 +286,55 @@
240
286
  if (t instanceof Array) t = T.words(t);
241
287
  return writeTarget(this.e, this.e.scratch2 + 4096, t);
242
288
  }
243
- resolve(t) { const n = this.e.ex.qvp_resolve(this.h, this._target(t), this.e.scratch, 16384); return Array.from(new Uint32Array(this.e.mem.buffer, this.e.scratch, Math.min(n, 16384))); }
289
+ targetWords(t) { const n = this.e.ex.qvp_target_words(this.h, this._target(t), this.e.scratch, 16384); return Array.from(new Uint32Array(this.e.mem.buffer, this.e.scratch, Math.min(n, 16384))); }
244
290
 
245
291
  // ── metadata ──
246
292
  surahs() {
247
- const ex = this.e.ex, s = this.e.scratch, n = ex.qvp_surahs_count(this.h), out = [];
293
+ const ex = this.e.ex, s = this.e.scratch, n = ex.qvp_surah_count(this.h), out = [];
248
294
  for (let i = 0; i < n; i++) { ex.qvp_surah_at(this.h, i, s); const d = this.e.dv();
249
- out.push({ number: d.getUint16(s, true), ayahCount: d.getUint16(s + 2, true), hasBanner: !!d.getUint8(s + 4), hasBasmalah: !!d.getUint8(s + 5), place: ['makkah', 'madinah'][d.getUint8(s + 6)] || '', bannerDeco: d.getUint32(s + 8, true), arabic: this.e.qstr(s + 12), latin: this.e.qstr(s + 20), english: this.e.qstr(s + 28) }); }
295
+ out.push({ number: d.getUint16(s, true), ayahCount: d.getUint16(s + 2, true), hasBanner: !!d.getUint8(s + 4), hasBasmalah: !!d.getUint8(s + 5), place: names('place')[d.getUint8(s + 6)] || '', bannerDecoration: d.getUint32(s + 8, true), arabic: this.e.qstr(s + 12), latin: this.e.qstr(s + 20), english: this.e.qstr(s + 28) }); }
250
296
  return out;
251
297
  }
252
298
  divisions() {
253
299
  const ex = this.e.ex, s = this.e.scratch, n = ex.qvp_divisions(this.h, s, 64), d = this.e.dv(), out = [];
254
- for (let i = 0; i < Math.min(n, 64); i++) { const o = s + i * 12; out.push({ kind: ['juz', 'hizb', 'nisf', 'rubu_al_hizb'][d.getUint8(o)], line: d.getUint8(o + 1), n: d.getUint16(o + 2, true), surah: d.getUint16(o + 4, true), ayah: d.getUint16(o + 6, true), ayahIdx: d.getUint32(o + 8, true) }); }
300
+ for (let i = 0; i < Math.min(n, 64); i++) { const o = s + i * 12; out.push({ division: names('division')[d.getUint8(o)], line: d.getUint8(o + 1), number: d.getUint16(o + 2, true), surah: d.getUint16(o + 4, true), ayah: d.getUint16(o + 6, true), ayahIndex: d.getUint32(o + 8, true) }); }
301
+ return out;
302
+ }
303
+ /** the surah headings in page units: the box a frame fills, and the title ink in it */
304
+ surahHeaders() {
305
+ const ex = this.e.ex, s = this.e.scratch, n = ex.qvp_surah_headers(this.h, s, 32), d = this.e.dv(), out = [];
306
+ for (let i = 0; i < Math.min(n, 32); i++) { const o = s + i * 44; out.push({ decoration: d.getUint32(o, true), surah: d.getUint16(o + 4, true), line: d.getUint32(o + 8, true), x0: d.getFloat32(o + 12, true), y0: d.getFloat32(o + 16, true), x1: d.getFloat32(o + 20, true), y1: d.getFloat32(o + 24, true), titleX0: d.getFloat32(o + 28, true), titleY0: d.getFloat32(o + 32, true), titleX1: d.getFloat32(o + 36, true), titleY1: d.getFloat32(o + 40, true) }); }
307
+ return out;
308
+ }
309
+ /** the same, in viewport px through the current layout: what a host draws its own frame around */
310
+ surahHeadersView() {
311
+ const ex = this.e.ex, s = this.e.scratch, n = ex.qvp_surah_headers_view(this.h, s, 32), d = this.e.dv(), out = [];
312
+ for (let i = 0; i < Math.min(n, 32); i++) { const o = s + i * 44; out.push({ decoration: d.getUint32(o, true), surah: d.getUint16(o + 4, true), line: d.getUint32(o + 8, true), x0: d.getFloat32(o + 12, true), y0: d.getFloat32(o + 16, true), x1: d.getFloat32(o + 20, true), y1: d.getFloat32(o + 24, true), titleX0: d.getFloat32(o + 28, true), titleY0: d.getFloat32(o + 32, true), titleX1: d.getFloat32(o + 36, true), titleY1: d.getFloat32(o + 40, true) }); }
255
313
  return out;
256
314
  }
257
315
  ayahMarks() {
258
316
  const ex = this.e.ex, s = this.e.scratch, n = ex.qvp_ayah_marks(this.h, s, 128), d = this.e.dv(), out = [];
259
- for (let i = 0; i < Math.min(n, 128); i++) { const o = s + i * 32; out.push({ deco: d.getUint32(o, true), surah: d.getUint16(o + 4, true), ayah: d.getUint16(o + 6, true), line: d.getUint32(o + 8, true), cx: d.getFloat32(o + 12, true), cy: d.getFloat32(o + 16, true), r: d.getFloat32(o + 20, true), ornamentPath: d.getUint32(o + 24, true), numeralPath: d.getUint32(o + 28, true) }); }
317
+ for (let i = 0; i < Math.min(n, 128); i++) { const o = s + i * 32; out.push({ decoration: d.getUint32(o, true), surah: d.getUint16(o + 4, true), ayah: d.getUint16(o + 6, true), line: d.getUint32(o + 8, true), cx: d.getFloat32(o + 12, true), cy: d.getFloat32(o + 16, true), r: d.getFloat32(o + 20, true), ornamentPath: d.getUint32(o + 24, true), numeralPath: d.getUint32(o + 28, true) }); }
318
+ return out;
319
+ }
320
+ /** the ayah medallions in viewport px through the current layout: where each is drawn */
321
+ ayahMarksView() {
322
+ const ex = this.e.ex, s = this.e.scratch, n = ex.qvp_ayah_marks_view(this.h, s, 128), d = this.e.dv(), out = [];
323
+ for (let i = 0; i < Math.min(n, 128); i++) { const o = s + i * 32; out.push({ decoration: d.getUint32(o, true), surah: d.getUint16(o + 4, true), ayah: d.getUint16(o + 6, true), line: d.getUint32(o + 8, true), cx: d.getFloat32(o + 12, true), cy: d.getFloat32(o + 16, true), r: d.getFloat32(o + 20, true), ornamentPath: d.getUint32(o + 24, true), numeralPath: d.getUint32(o + 28, true) }); }
260
324
  return out;
261
325
  }
262
326
  rosettes() {
263
327
  const ex = this.e.ex, s = this.e.scratch, n = ex.qvp_rosettes(this.h, s, 32), d = this.e.dv(), out = [];
264
- for (let i = 0; i < Math.min(n, 32); i++) { const o = s + i * 20; out.push({ deco: d.getUint32(o, true), surah: d.getUint16(o + 4, true), ayah: d.getUint16(o + 6, true), juz: d.getUint16(o + 8, true), hizb: d.getUint16(o + 10, true), nisf: d.getUint16(o + 12, true), rubuAlHizb: d.getUint16(o + 14, true), rubuAlHizbInHizb: d.getUint16(o + 16, true) }); }
328
+ for (let i = 0; i < Math.min(n, 32); i++) { const o = s + i * 20; out.push({ decoration: d.getUint32(o, true), surah: d.getUint16(o + 4, true), ayah: d.getUint16(o + 6, true), juz: d.getUint16(o + 8, true), hizb: d.getUint16(o + 10, true), nisf: d.getUint16(o + 12, true), rubuAlHizb: d.getUint16(o + 14, true), rubuAlHizbInHizb: d.getUint16(o + 16, true) }); }
265
329
  return out;
266
330
  }
267
331
  sajdahs() {
268
332
  const ex = this.e.ex, s = this.e.scratch, n = ex.qvp_sajdahs(this.h, s, 16), d = this.e.dv(), out = [];
269
- for (let i = 0; i < Math.min(n, 16); i++) { const o = s + i * 12; out.push({ deco: d.getUint32(o, true), surah: d.getUint16(o + 4, true), ayah: d.getUint16(o + 6, true), signPath: d.getUint32(o + 8, true) }); }
333
+ for (let i = 0; i < Math.min(n, 16); i++) { const o = s + i * 12; out.push({ decoration: d.getUint32(o, true), surah: d.getUint16(o + 4, true), ayah: d.getUint16(o + 6, true), signPath: d.getUint32(o + 8, true) }); }
270
334
  return out;
271
335
  }
272
336
  ayahKeys() { const n = this.e.ex.qvp_ayah_keys(this.h, this.e.scratch, 256); const v = new Uint32Array(this.e.mem.buffer, this.e.scratch, Math.min(n, 256)); return Array.from(v, k => [k >>> 16, k & 0xffff]); }
273
- ayahWordCount(surah, ayah) { const n = this.e.ex.qvp_ayah_word_count(this.h, surah, ayah, this.e.scratch); return { count: n, complete: !!this.e.dv().getUint32(this.e.scratch, true) }; }
337
+ ayahWordCount(surah, ayah) { const n = this.e.ex.qvp_ayah_word_count(this.h, surah, ayah, this.e.scratch); return { count: n, isComplete: !!this.e.dv().getUint8(this.e.scratch) }; }
274
338
  /** words for n recitation segments, or null when the counts disagree (follow the ayah whole) */
275
339
  reciteMap(surah, ayah, nSegments) { const n = this.e.ex.qvp_recite_map(this.h, surah, ayah, nSegments, this.e.scratch, 4096); return n < 0 ? null : Array.from(new Uint32Array(this.e.mem.buffer, this.e.scratch, n)); }
276
340
  wordLabel(i) { this.e.ex.qvp_word_label(this.h, i, this.e.scratch); return this.e.qstr(this.e.scratch); }
@@ -279,14 +343,14 @@
279
343
  // ── text & search ──
280
344
  text(target = 'page', { form = 'rasm_uthmani', wordSep = ' ', lineSep = '\n' } = {}) {
281
345
  const [wp, wn] = this.e.putStr(wordSep, 0), [lp, ln] = this.e.putStr(lineSep, 256);
282
- this.e.ex.qvp_text_target(this.h, this._target(target), FORM[form] ?? 0, wp, wn, lp, ln, this.e.scratch);
346
+ this.e.ex.qvp_text(this.h, this._target(target), FORM[form] ?? 0, wp, wn, lp, ln, this.e.scratch);
283
347
  return this.e.qstr(this.e.scratch);
284
348
  }
285
- search(query, { form = 'search', mode = 'includes', normalize = true, loose = true, limit = 0 } = {}) {
349
+ search(query, { form = 'search', mode = 'includes', normalize = true, looseMatch = true, limit = 0 } = {}) {
286
350
  const [qp, qn] = this.e.putStr(query);
287
- const n = this.e.ex.qvp_search(this.h, qp, qn, FORM[form] ?? 4, { includes: 0, exact: 1, prefix: 2 }[mode] ?? 0, normalize ? 1 : 0, loose ? 1 : 0, limit, this.e.scratch, 1024);
351
+ const n = this.e.ex.qvp_search(this.h, qp, qn, FORM[form] ?? 4, { includes: 0, exact: 1, prefix: 2 }[mode] ?? 0, normalize ? 1 : 0, looseMatch ? 1 : 0, limit, this.e.scratch, 1024);
288
352
  const d = this.e.dv(), out = [];
289
- for (let i = 0; i < Math.min(n, 1024); i++) { const o = this.e.scratch + i * 12; const w = d.getUint32(o, true); out.push({ word: w, index: d.getUint32(o + 4, true), loose: !!d.getUint32(o + 8, true), wordKey: this.wordKey(w), text: this.words[w].text }); }
353
+ for (let i = 0; i < Math.min(n, 1024); i++) { const o = this.e.scratch + i * 12; const w = d.getUint32(o, true); out.push({ word: w, index: d.getUint32(o + 4, true), isLooseMatch: !!d.getUint8(o + 8), wordKey: this.wordKey(w), text: this.words[w].text }); }
290
354
  return out;
291
355
  }
292
356
  wordKey(i) { const w = this.words[i]; return `${w.surah}:${w.ayah}:${w.word}`; }
@@ -296,42 +360,245 @@
296
360
  citation(words) { const p = this.e.putU32(Uint32Array.from(words)); this.e.ex.qvp_citation(this.h, p, words.length, this.e.scratch); return this.e.qstr(this.e.scratch); }
297
361
 
298
362
  // ── hit testing ──
299
- _hit(s) { const d = this.e.dv(); const f = v => (v === NONE ? -1 : v); return { word: f(d.getUint32(s, true)), path: f(d.getUint32(s + 4, true)), deco: f(d.getUint32(s + 8, true)) }; }
300
- hitTest(x, y) { return this.e.ex.qvp_hit_test(this.h, x, y, this.e.scratch) ? this._hit(this.e.scratch) : null; }
301
- hitTestView(vx, vy) { return this.e.ex.qvp_hit_test_view(this.h, vx, vy, this.e.scratch) ? this._hit(this.e.scratch) : null; }
302
- _hitOpt(o) { const d = this.e.dv(), at = this.e.scratch2 + 8192; d.setFloat32(at, o.maxDistance ?? 0, true); d.setFloat32(at + 4, o.gapBias ?? 0.6, true); d.setUint32(at + 8, o.exactFirst === false ? 0 : 1, true); return at; }
303
- _hitEx(s) { const d = this.e.dv(); const f = v => (v === NONE ? -1 : v); const h = { word: f(d.getUint32(s, true)), path: f(d.getUint32(s + 4, true)), deco: f(d.getUint32(s + 8, true)), line: d.getUint32(s + 12, true), distance: d.getFloat32(s + 16, true), exact: !!d.getUint32(s + 20, true) }; if (h.word >= 0) { h.wordKey = this.wordKey(h.word); const w = this.words[h.word]; h.ayahKey = `${w.surah}:${w.ayah}`; } return h; }
363
+ hitTestExact(x, y) { return this.e.ex.qvp_hit_test_exact(this.h, x, y, this.e.scratch) ? this._hit(this.e.scratch) : null; }
364
+ hitTestExactView(viewX, viewY) { return this.e.ex.qvp_hit_test_exact_view(this.h, viewX, viewY, this.e.scratch) ? this._hit(this.e.scratch) : null; }
365
+ _hitOpt(o) { const d = this.e.dv(), at = this.e.scratch2 + 8192; d.setFloat32(at, o.maxDistance ?? 0, true); d.setFloat32(at + 4, o.gapBias ?? 0.6, true); d.setUint8(at + 8, o.preferExact === false ? 0 : 1); return at; }
366
+ /** the one hit shape for every hit test; the exact variants report distance 0 and isExact */
367
+ _hit(s) { const d = this.e.dv(); const f = v => (v === NONE ? -1 : v); const h = { word: f(d.getUint32(s, true)), path: f(d.getUint32(s + 4, true)), decoration: f(d.getUint32(s + 8, true)), line: f(d.getUint32(s + 12, true)), distance: d.getFloat32(s + 16, true), isExact: !!d.getUint8(s + 20) }; if (h.word >= 0) { h.wordKey = this.wordKey(h.word); const w = this.words[h.word]; h.ayahKey = `${w.surah}:${w.ayah}`; } return h; }
304
368
  /** gap-aware: every point on a printed line resolves to the word the user meant */
305
- hitTestEx(x, y, opt = {}) { return this.e.ex.qvp_hit_test_ex(this.h, x, y, this._hitOpt(opt), this.e.scratch) ? this._hitEx(this.e.scratch) : null; }
306
- hitTestViewEx(vx, vy, opt = {}) { return this.e.ex.qvp_hit_test_view_ex(this.h, vx, vy, this._hitOpt(opt), this.e.scratch) ? this._hitEx(this.e.scratch) : null; }
307
- lineBands() { const n = this.e.ex.qvp_line_bands(this.h, this.e.scratch, 64), d = this.e.dv(), out = []; for (let i = 0; i < Math.min(n, 64); i++) { const o = this.e.scratch + i * 28; out.push({ line: d.getUint32(o, true), lineNo: d.getUint32(o + 4, true), y0: d.getFloat32(o + 8, true), y1: d.getFloat32(o + 12, true), mid: d.getFloat32(o + 16, true), inkY0: d.getFloat32(o + 20, true), inkY1: d.getFloat32(o + 24, true) }); } return out; }
308
- hitBoxes(gapBias = 0.6) { const n = this.e.ex.qvp_hit_boxes(this.h, gapBias, this.e.scratch, 1024), d = this.e.dv(), out = []; for (let i = 0; i < Math.min(n, 1024); i++) { const o = this.e.scratch + i * 40; out.push({ word: d.getUint32(o, true), line: d.getUint32(o + 4, true), x0: d.getFloat32(o + 8, true), y0: d.getFloat32(o + 12, true), x1: d.getFloat32(o + 16, true), y1: d.getFloat32(o + 20, true), inkX0: d.getFloat32(o + 24, true), inkY0: d.getFloat32(o + 28, true), inkX1: d.getFloat32(o + 32, true), inkY1: d.getFloat32(o + 36, true) }); } return out; }
369
+ hitTest(x, y, opt = {}) { return this.e.ex.qvp_hit_test(this.h, x, y, this._hitOpt(opt), this.e.scratch) ? this._hit(this.e.scratch) : null; }
370
+ hitTestView(viewX, viewY, opt = {}) { return this.e.ex.qvp_hit_test_view(this.h, viewX, viewY, this._hitOpt(opt), this.e.scratch) ? this._hit(this.e.scratch) : null; }
371
+ /** the hit boxes in viewport px through the current layout: what a tap is resolved with */
372
+ hitAreasView(gapBias = DEFAULTS.GAP_BIAS) { const n = this.e.ex.qvp_hit_areas_view(this.h, gapBias, this.e.scratch, 1024), d = this.e.dv(), out = []; for (let i = 0; i < Math.min(n, 1024); i++) { const o = this.e.scratch + i * 40; out.push({ word: d.getUint32(o, true), line: d.getUint32(o + 4, true), x0: d.getFloat32(o + 8, true), y0: d.getFloat32(o + 12, true), x1: d.getFloat32(o + 16, true), y1: d.getFloat32(o + 20, true), inkX0: d.getFloat32(o + 24, true), inkY0: d.getFloat32(o + 28, true), inkX1: d.getFloat32(o + 32, true), inkY1: d.getFloat32(o + 36, true) }); } return out; }
373
+ lineBands() { const n = this.e.ex.qvp_line_bands(this.h, this.e.scratch, 64), d = this.e.dv(), out = []; for (let i = 0; i < Math.min(n, 64); i++) { const o = this.e.scratch + i * 28; out.push({ line: d.getUint32(o, true), lineNumber: d.getUint32(o + 4, true), y0: d.getFloat32(o + 8, true), y1: d.getFloat32(o + 12, true), mid: d.getFloat32(o + 16, true), inkY0: d.getFloat32(o + 20, true), inkY1: d.getFloat32(o + 24, true) }); } return out; }
374
+ hitAreas(gapBias = DEFAULTS.GAP_BIAS) { const n = this.e.ex.qvp_hit_areas(this.h, gapBias, this.e.scratch, 1024), d = this.e.dv(), out = []; for (let i = 0; i < Math.min(n, 1024); i++) { const o = this.e.scratch + i * 40; out.push({ word: d.getUint32(o, true), line: d.getUint32(o + 4, true), x0: d.getFloat32(o + 8, true), y0: d.getFloat32(o + 12, true), x1: d.getFloat32(o + 16, true), y1: d.getFloat32(o + 20, true), inkX0: d.getFloat32(o + 24, true), inkY0: d.getFloat32(o + 28, true), inkX1: d.getFloat32(o + 32, true), inkY1: d.getFloat32(o + 36, true) }); } return out; }
309
375
 
310
376
  // ── layout ──
311
- layout(spec) {
312
- const ex = this.e.ex, s = this.e.scratch; let d = this.e.dv();
377
+ /** Write a layout spec (QvpLayoutSpec, 76 bytes) at scratch offset `s`. */
378
+ _writeLayoutSpec(spec, s) {
379
+ const d = this.e.dv();
313
380
  d.setFloat32(s, spec.viewportW, true); d.setFloat32(s + 4, spec.viewportH, true); d.setFloat32(s + 8, spec.padTop || 0, true); d.setFloat32(s + 12, spec.padBottom || 0, true);
314
- d.setFloat32(s + 16, spec.padLeft || 0, true); d.setFloat32(s + 20, spec.padRight || 0, true); d.setFloat32(s + 24, spec.lineSpacing ?? 1, true); d.setFloat32(s + 28, spec.lineGap || 0, true);
315
- d.setUint32(s + 32, spec.fillHeight ? 1 : 0, true); d.setUint32(s + 36, spec.nominalLines || 15, true);
316
- ex.qvp_layout(this.h, s, s + 64);
317
- d = this.e.dv(); const o = s + 64;
381
+ d.setFloat32(s + 16, spec.padLeft || 0, true); d.setFloat32(s + 20, spec.padRight || 0, true); d.setFloat32(s + 24, spec.lineSpacing ?? 1, true);
382
+ d.setUint8(s + 28, spec.fillHeight ? 1 : 0); d.setUint32(s + 32, spec.gridLines || 0, true);
383
+ d.setFloat32(s + 36, spec.cropLeft || 0, true); d.setFloat32(s + 40, spec.cropRight || 0, true); d.setFloat32(s + 44, spec.maxAspectSlack || 0, true);
384
+ const r = spec.reflow;
385
+ d.setFloat32(s + 48, r ? (r.zoom ?? 1) : 0, true);
386
+ // 255 asks the engine for its own default
387
+ const fill = { ragged: 0, justified: 1, centred: 2, centered: 2 }[r && r.fill] ?? 255;
388
+ const breaks = { greedy: 0, even: 1, fitted: 2 }[r && r.breaks] ?? 255;
389
+ d.setUint8(s + 52, fill); d.setUint8(s + 53, breaks); d.setUint8(s + 54, r && r.gaps === 'printed' ? 0 : 1);
390
+ d.setFloat32(s + 56, r ? (r.wordGap ?? 1) : 1, true);
391
+ d.setFloat32(s + 60, r ? (r.maxStretch ?? 0) : 0, true);
392
+ d.setFloat32(s + 64, r && r.relax !== undefined ? r.relax : -1, true);
393
+ // bannerZoom: how big a surah name or basmalah may get against its printed size as the
394
+ // reader zooms; 0 lets it grow with the page until it fills the row.
395
+ d.setFloat32(s + 68, spec.bannerZoom ?? 0, true);
396
+ // Native frames preserve the printed page by default; a host may supply its own.
397
+ d.setUint8(s + 72, spec.surahFrames === false ? 0 : 1);
398
+ }
399
+ /** Leading (page units) that makes the page fill the padded viewport of `spec`; max 0 = unlimited. */
400
+ layoutLineSpacingToFill(spec, max = 0) { const s = this.e.scratch; this._writeLayoutSpec(spec, s); return this.e.ex.qvp_layout_line_spacing_to_fill(this.h, s, max); }
401
+ /** the share of the padded viewport left empty when the page is fitted to width */
402
+ layoutWastedFraction(spec) { const s = this.e.scratch; this._writeLayoutSpec(spec, s); return this.e.ex.qvp_layout_wasted_fraction(this.h, s); }
403
+ /** the page's height laid out for `spec` at the printed pitch, in viewport px: contentH before fill-height adds leading, padding included */
404
+ layoutPrintedHeight(spec) { const s = this.e.scratch; this._writeLayoutSpec(spec, s); return this.e.ex.qvp_layout_printed_height(this.h, s); }
405
+ /** the grid this page is laid out inside: the mushaf's line count and the printed line spacing */
406
+ get grid() { this.e.ex.qvp_page_grid(this.h, this.e.scratch); const d = this.e.dv(); return { lines: d.getUint32(this.e.scratch, true), lineSpacing: d.getFloat32(this.e.scratch + 4, true) }; }
407
+ layout(spec) {
408
+ const ex = this.e.ex, s = this.e.scratch;
409
+ this._writeLayoutSpec(spec, s);
410
+ ex.qvp_layout(this.h, s, s + 128);
411
+ return this._readLayout(s + 128);
412
+ }
413
+ /** read a QvpLayout the engine has written at `o`, and everything that goes with it */
414
+ _readLayout(o) {
415
+ const ex = this.e.ex; let d = this.e.dv();
318
416
  const n = d.getUint32(o + 24, true), lp = d.getUint32(o + 28, true);
319
417
  const f = new Float32Array(this.e.mem.buffer.slice(lp, lp + n * 12));
320
418
  const lineDy = new Float32Array(n), slots = new Array(n);
321
419
  for (let i = 0; i < n; i++) { lineDy[i] = f[i * 3]; slots[i] = [f[i * 3 + 1], f[i * 3 + 2]]; }
322
- return (this.currentLayout = { scale: d.getFloat32(o, true), ox: d.getFloat32(o + 4, true), oy: d.getFloat32(o + 8, true), contentW: d.getFloat32(o + 12, true), contentH: d.getFloat32(o + 16, true), pitch: d.getFloat32(o + 20, true), lineDy, slots });
420
+ const reflowed = d.getUint32(o + 44, true) === 1, rows = d.getUint32(o + 48, true);
421
+ const L = { scale: d.getFloat32(o, true), offsetX: d.getFloat32(o + 4, true), offsetY: d.getFloat32(o + 8, true), contentW: d.getFloat32(o + 12, true), contentH: d.getFloat32(o + 16, true), lineSpacing: d.getFloat32(o + 20, true), lineDy, slots,
422
+ fitScale: d.getFloat32(o + 32, true), fitX: d.getFloat32(o + 36, true), fitY: d.getFloat32(o + 40, true), reflowed, rows };
423
+ // where each group of paths sits: {dx, dy, k} triples, and the group of every path
424
+ const ng = ex.qvp_layout_groups(this.h, 0, 0);
425
+ const gp = this.e.buf(ng * 16);
426
+ ex.qvp_layout_groups(this.h, gp, ng);
427
+ L.groups = new Float32Array(this.e.mem.buffer.slice(gp, gp + ng * 16));
428
+ const np = ex.qvp_layout_path_groups(this.h, 0, 0);
429
+ if (np) { const pp = this.e.buf(np * 4); ex.qvp_layout_path_groups(this.h, pp, np); L.pathGroup = new Uint32Array(this.e.mem.buffer.slice(pp, pp + np * 4)); }
430
+ else L.pathGroup = null;
431
+ // paths this layout leaves undrawn: sheet furniture and a native surah frame hidden by the spec or reflow
432
+ const no = ex.qvp_layout_omitted_paths(this.h, 0, 0);
433
+ if (no) { const op = this.e.buf(no * 4); ex.qvp_layout_omitted_paths(this.h, op, no); L.omitted = new Set(new Uint32Array(this.e.mem.buffer, op, no)); }
434
+ else L.omitted = null;
435
+ // paths drawn a second time elsewhere: a sajdah line whose words ended up on two rows
436
+ const nr = ex.qvp_layout_repeats(this.h, 0, 0);
437
+ L.repeats = [];
438
+ if (nr) {
439
+ const rp = this.e.buf(nr * 24); ex.qvp_layout_repeats(this.h, rp, nr);
440
+ const f = new Float32Array(this.e.mem.buffer.slice(rp, rp + nr * 24));
441
+ for (let i = 0; i < nr; i++) L.repeats.push({ firstPath: f[i * 6], nPaths: f[i * 6 + 1], dx: f[i * 6 + 2], dy: f[i * 6 + 3], kx: f[i * 6 + 4], ky: f[i * 6 + 5] });
442
+ }
443
+ return (this.currentLayout = L);
444
+ }
445
+ // ── the reader's zoom control ──
446
+ /** where the zoom control stands: {mode, step, zoom}. All zero is what a page opens on:
447
+ * stepped, on the printed page. */
448
+ _putZoom(z, at) { const d = this.e.dv(); d.setUint32(at, ZOOM_MODE[z && z.mode] ?? 0, true); d.setUint32(at + 4, (z && z.step) || 0, true); d.setFloat32(at + 8, (z && z.zoom) || 1, true); return at; }
449
+ _getZoom(at) { const d = this.e.dv(); return { mode: ZOOM_MODE_NAME[d.getUint32(at, true)] || 'stepped', step: d.getUint32(at + 4, true), zoom: d.getFloat32(at + 8, true) }; }
450
+ _getZoomChange(at) { return { zoom: this._getZoom(at), view: this.e._getView(at + 12), relaid: !!this.e.dv().getUint32(at + 24, true) }; }
451
+ /** the same control under another policy, keeping the size the reader is at */
452
+ zoomMode(spec, zoom, mode) {
453
+ const s = this.e.scratch2 + 40960; this._writeLayoutSpec(spec, s);
454
+ const z = this._putZoom(zoom, this.e.scratch2 + 41984);
455
+ this.e.ex.qvp_zoom_mode(this.h, s, z, ZOOM_MODE[mode] ?? 0, this.e.scratch);
456
+ return this._getZoom(this.e.scratch);
457
+ }
458
+ /** one frame of a pinch: `factor` is the fingers' distance against their distance when
459
+ * they went down, and (x, y) the point between them. Returns {zoom, view, relaid}. */
460
+ zoomPinch(spec, zoom, view, factor, x, y) {
461
+ const s = this.e.scratch2 + 40960; this._writeLayoutSpec(spec, s);
462
+ const z = this._putZoom(zoom, this.e.scratch2 + 41984);
463
+ const v = this.e._putView(view, this.e.scratch2 + 42000);
464
+ this.e.ex.qvp_zoom_pinch(this.h, s, z, v, factor, x, y, this.e.scratch);
465
+ const c = this._getZoomChange(this.e.scratch);
466
+ // the engine laid the page out inside the call: keep the cached layout with it
467
+ if (c.relaid) this.layoutCurrent();
468
+ return c;
469
+ }
470
+ /** the control moved straight to a step: a size button, a double tap, a reset. Step 0 is
471
+ * the printed page. */
472
+ zoomToStep(spec, zoom, step, view) {
473
+ const s = this.e.scratch2 + 40960; this._writeLayoutSpec(spec, s);
474
+ const z = this._putZoom(zoom, this.e.scratch2 + 41984);
475
+ const v = this.e._putView(view, this.e.scratch2 + 42000);
476
+ this.e.ex.qvp_zoom_to_step(this.h, s, z, step, v, this.e.scratch);
477
+ const c = this._getZoomChange(this.e.scratch);
478
+ if (c.relaid) this.layoutCurrent();
479
+ return c;
323
480
  }
324
- wordBoxView(i) { this.e.ex.qvp_word_box_view(this.h, i, this.e.scratch); const f = new Float32Array(this.e.mem.buffer, this.e.scratch, 4); return { x0: f[0], y0: f[1], x1: f[2], y1: f[3] }; }
481
+ /** `spec` with this control's zoom in it: what the host lays out and draws with */
482
+ zoomSpec(spec, zoom) {
483
+ const s = this.e.scratch2 + 40960; this._writeLayoutSpec(spec, s);
484
+ const z = this._putZoom(zoom, this.e.scratch2 + 41984);
485
+ this.e.ex.qvp_zoom_spec(this.h, s, z, this.e.scratch);
486
+ const at = this.e.scratch + 48;
487
+ const v = this.e.dv().getFloat32(at, true);
488
+ return { ...spec, reflow: v > 0 ? { ...(spec.reflow || {}), zoom: v } : null };
489
+ }
490
+ /** The same control on this page: what the reader was reading at, carried onto the page they
491
+ * turned to. A step carries as a step, because every page's steps are its own; a free zoom
492
+ * carries as a size, held inside what this page can reach. */
493
+ zoomCarried(spec, zoom) {
494
+ const s = this.e.scratch2 + 40960; this._writeLayoutSpec(spec, s);
495
+ const z = this._putZoom(zoom, this.e.scratch2 + 41984);
496
+ this.e.ex.qvp_zoom_carried(this.h, s, z, this.e.scratch);
497
+ return this._getZoom(this.e.scratch);
498
+ }
499
+ /** the reflow zoom one step of this page's control means; step 0 is the printed page */
500
+ zoomAtStep(spec, step) {
501
+ const s = this.e.scratch2 + 40960; this._writeLayoutSpec(spec, s);
502
+ return this.e.ex.qvp_zoom_at_step(this.h, s, step);
503
+ }
504
+ /** true once the reader has zoomed in, by either road: a magnified view, or a page reflowed
505
+ * above the printed size. `fitScale` is the scale the page is fitted at (0 = it is at it). */
506
+ zoomIsZoomed(zoom, view, fitScale = 0) {
507
+ const z = this._putZoom(zoom, this.e.scratch2 + 41984);
508
+ const v = this.e._putView(view, this.e.scratch2 + 42000);
509
+ return !!this.e.ex.qvp_zoom_is_zoomed(z, v, fitScale);
510
+ }
511
+ /** what a sideways drag on this page means: 'pan' it, or 'turnPage' */
512
+ sidewaysDrag(zoom, view, fitScale = 0) {
513
+ const z = this._putZoom(zoom, this.e.scratch2 + 41984);
514
+ const v = this.e._putView(view, this.e.scratch2 + 42000);
515
+ return this.e.ex.qvp_sideways_drag(this.h, z, v, fitScale) === 1 ? 'turnPage' : 'pan';
516
+ }
517
+ /** The layout the page already has, without computing one: what to read after a call that
518
+ * laid the page out itself, as the zoom control does. */
519
+ layoutCurrent() {
520
+ const s = this.e.scratch;
521
+ if (!this.e.ex.qvp_layout_current(this.h, s + 128)) return this.currentLayout;
522
+ return this._readLayout(s + 128);
523
+ }
524
+ /** Everything the current layout draws, in drawing order: `{path, placement}` pairs into
525
+ * `layoutPlacements()`. Omitted paths are already gone and a repeated one is already there
526
+ * twice, so one loop draws any page — printed or reflowed. */
527
+ layoutDrawList(bandTop = 0, bandBottom = 0) {
528
+ const n = this.e.ex.qvp_layout_draw_list(this.h, bandTop, bandBottom, 0, 0);
529
+ if (!n) return new Uint32Array(0);
530
+ const p = this.e.buf(n * 8); this.e.ex.qvp_layout_draw_list(this.h, bandTop, bandBottom, p, n);
531
+ return new Uint32Array(this.e.mem.buffer.slice(p, p + n * 8));
532
+ }
533
+ /** what a draw list's `placement` indexes: every group, then every repeat */
534
+ layoutPlacements() {
535
+ const n = this.e.ex.qvp_layout_placements(this.h, 0, 0);
536
+ if (!n) return new Float32Array(0);
537
+ const p = this.e.buf(n * 16); this.e.ex.qvp_layout_placements(this.h, p, n);
538
+ return new Float32Array(this.e.mem.buffer.slice(p, p + n * 16));
539
+ }
540
+ /** the largest reflow zoom at which every word of this page still fits a row */
541
+ reflowMaxZoom(spec) { const s = this.e.scratch; this._writeLayoutSpec(spec, s); return this.e.ex.qvp_reflow_max_zoom(this.h, s); }
542
+ /** the zoom each step of a reader's zoom control lands on for this page, rising */
543
+ zoomLevels(spec, nominals, band) {
544
+ const s = this.e.scratch; this._writeLayoutSpec(spec, s);
545
+ const k = nominals ? nominals.length : 0;
546
+ const np = k ? this.e.buf(k * 4) : 0;
547
+ if (k) new Float32Array(this.e.mem.buffer, np, k).set(nominals);
548
+ const out = this.e.buf(8 * 4);
549
+ const n = this.e.ex.qvp_zoom_levels(this.h, s, np, k, band || 0, out, 8);
550
+ return Array.from(new Float32Array(this.e.mem.buffer, out, n));
551
+ }
552
+ /** the zoom steps this page ships with, lowest first; zoom 1 is the step before them */
553
+ zoomSteps(spec) {
554
+ const s = this.e.scratch; this._writeLayoutSpec(spec, s);
555
+ const out = this.e.buf(8 * 4);
556
+ const n = this.e.ex.qvp_zoom_steps(this.h, s, out, 8);
557
+ return Array.from(new Float32Array(this.e.mem.buffer, out, n));
558
+ }
559
+ /** every zoom the search weighs for one step of a zoom control, with its cost */
560
+ zoomLevelCandidates(spec, nominal, band, floor) {
561
+ const s = this.e.scratch; this._writeLayoutSpec(spec, s);
562
+ // one block, two halves: `buf` hands back the same scratch every time
563
+ const N = 64, z = this.e.buf(N * 8), c = z + N * 4;
564
+ const n = this.e.ex.qvp_zoom_level_candidates(this.h, s, nominal, band || 0, floor || 0, z, c, N);
565
+ const zs = new Float32Array(this.e.mem.buffer, z, n), cs = new Float32Array(this.e.mem.buffer, c, n);
566
+ return Array.from(zs, (v, i) => ({ zoom: v, cost: cs[i] }));
567
+ }
568
+ /** the words of a reflowed row, in reading order */
569
+ rowWords(row) {
570
+ const n = this.e.ex.qvp_layout_row_words(this.h, row, 0, 0);
571
+ if (!n) return [];
572
+ const p = this.e.buf(n * 4); this.e.ex.qvp_layout_row_words(this.h, row, p, n);
573
+ return Array.from(new Uint32Array(this.e.mem.buffer, p, n));
574
+ }
575
+ /** the row a word landed on in a reflowed layout, or null */
576
+ wordRow(word) { const r = this.e.ex.qvp_layout_word_row(this.h, word); return r === 0xffffffff ? null : r; }
577
+ /** the view that puts a point inside a word (nx, ny from 0 to 1) at a place on the screen,
578
+ * then clamps: how a pinch holds its place when the layout reflows under it */
579
+ viewAnchor(view, word, nx, ny, toX, toY, viewportW, viewportH) {
580
+ const i = this.e._putView(view, this.e.scratch2 + 32768);
581
+ this.e.ex.qvp_view_anchor(this.h, i, word, nx, ny, toX, toY, viewportW, viewportH, this.e.scratch);
582
+ return this.e._getView(this.e.scratch);
583
+ }
584
+ /** where a viewport point lands in the laid-out page, for turning a touch into a hit test */
585
+ viewToLayout(view, vx, vy) {
586
+ const i = this.e._putView(view, this.e.scratch2 + 32768);
587
+ this.e.ex.qvp_view_to_layout(this.h, i, vx, vy, this.e.scratch);
588
+ const f = new Float32Array(this.e.mem.buffer, this.e.scratch, 2);
589
+ return { x: f[0], y: f[1] };
590
+ }
591
+ wordBoundsView(i) { this.e.ex.qvp_word_bounds_view(this.h, i, this.e.scratch); const f = new Float32Array(this.e.mem.buffer, this.e.scratch, 4); return { x0: f[0], y0: f[1], x1: f[2], y1: f[3] }; }
325
592
 
326
593
  // ── styles (handles undo exactly) ──
327
594
  style(sel, color, { ms = 0, layer = LAYER.BASE } = {}) { return this.e.ex.qvp_style_add(this.h, layer, writeSel(this.e, this.e.scratch2 + 12288, sel), rgba(color), ms); }
328
595
  styleTarget(target, color, { ms = 0, layer = LAYER.BASE } = {}) { return this.e.ex.qvp_style_add_target(this.h, layer, this._target(target), rgba(color), ms); }
329
- unstyle(handle) { return this.e.ex.qvp_style_remove(this.h, handle); }
330
- restyle(handle, color, ms = 0) { return this.e.ex.qvp_style_repaint(this.h, handle, rgba(color), ms); }
331
- hide(sel) { return this.e.ex.qvp_hide(this.h, writeSel(this.e, this.e.scratch2 + 12288, sel)); }
596
+ removeStyle(handle) { return this.e.ex.qvp_style_remove(this.h, handle); }
597
+ recolorStyle(handle, color, ms = 0) { return this.e.ex.qvp_style_recolor(this.h, handle, rgba(color), ms); }
598
+ hide(sel) { return this.e.ex.qvp_style_hide(this.h, writeSel(this.e, this.e.scratch2 + 12288, sel)); }
332
599
  clearStyles() { this.e.ex.qvp_style_clear(this.h); }
333
600
  clearLayer(layer) { this.e.ex.qvp_style_clear_layer(this.h, layer); }
334
- setDefaultInk(color) { this._defaultInk = rgba(color); this.e.ex.qvp_style_default(this.h, this._defaultInk); }
601
+ setDefaultColor(color) { this._defaultInk = rgba(color); this.e.ex.qvp_style_default_color(this.h, this._defaultInk); }
335
602
  get defaultInk() { return this._defaultInk; }
336
603
  /** {ink, diacritics, dots, waqf, sifr, ayahMark, numeral, headers, marks: {name: colour}, ms} → handle */
337
604
  theme(t) {
@@ -348,59 +615,61 @@
348
615
  // ── clock & display list ──
349
616
  /** advance animations; returns true while something is still moving */
350
617
  tick(nowMs) { return !!this.e.ex.qvp_tick(this.h, nowMs); }
351
- paint() { const p = this.e.ex.qvp_paint(this.h); return new Uint32Array(this.e.mem.buffer.slice(p, p + this.nPaths * 4)); }
352
- styled() {
353
- const ex = this.e.ex; const n = ex.qvp_styled(this.h, 0, 0); if (!n) return [];
354
- const buf = ex.qvp_alloc(n * 8); ex.qvp_styled(this.h, buf, n);
618
+ colors() { const p = this.e.ex.qvp_colors(this.h); return new Uint32Array(this.e.mem.buffer.slice(p, p + this.nPaths * 4)); }
619
+ styledPaths() {
620
+ const ex = this.e.ex; const n = ex.qvp_styled_paths(this.h, 0, 0); if (!n) return [];
621
+ const buf = ex.qvp_alloc(n * 8); ex.qvp_styled_paths(this.h, buf, n);
355
622
  const v = new Uint32Array(this.e.mem.buffer.slice(buf, buf + n * 8)); ex.qvp_dealloc(buf, n * 8);
356
623
  const out = new Array(n); for (let i = 0; i < n; i++) out[i] = [v[i * 2], v[i * 2 + 1]]; return out;
357
624
  }
358
625
  colorOf(i) { return this.e.ex.qvp_color_of(this.h, i) >>> 0; }
359
626
 
360
627
  // ── highlights ──
361
- /** style: {mode:'ink'|'band'|'both', height:'pitch'|'ink', ink, band, padX, padY, radius, seam, ms, layer} */
362
- highlight(target, style = {}) { return this.e.ex.qvp_highlight(this.h, this._target(target), writeHl(this.e, this.e.scratch2 + 24576, style)); }
363
- rehighlight(handle, target) { return !!this.e.ex.qvp_rehighlight(this.h, handle, this._target(target)); }
364
- restyleHighlight(handle, style) { return !!this.e.ex.qvp_restyle_highlight(this.h, handle, writeHl(this.e, this.e.scratch2 + 24576, style)); }
365
- unhighlight(handle) { return !!this.e.ex.qvp_unhighlight(this.h, handle); }
366
- clearHighlights() { this.e.ex.qvp_clear_highlights(this.h); }
628
+ /** style: {mode:'ink'|'band'|'both', height:'lineSpacing'|'ink', ink, band, padX, padY, radius, seam, ms, layer} */
629
+ highlight(target, style = {}) { return this.e.ex.qvp_highlight_add(this.h, this._target(target), writeHl(this.e, this.e.scratch2 + 24576, style)); }
630
+ moveHighlight(handle, target) { return !!this.e.ex.qvp_highlight_move(this.h, handle, this._target(target)); }
631
+ restyleHighlight(handle, style) { return !!this.e.ex.qvp_highlight_restyle(this.h, handle, writeHl(this.e, this.e.scratch2 + 24576, style)); }
632
+ removeHighlight(handle) { return !!this.e.ex.qvp_highlight_remove(this.h, handle); }
633
+ clearHighlights() { this.e.ex.qvp_highlight_clear(this.h); }
367
634
  highlightHandles() { const n = this.e.ex.qvp_highlight_handles(this.h, this.e.scratch, 1024); return Array.from(new Uint32Array(this.e.mem.buffer, this.e.scratch, Math.min(n, 1024))); }
368
635
  highlightWords(h) { const n = this.e.ex.qvp_highlight_words(this.h, h, this.e.scratch, 4096); return Array.from(new Uint32Array(this.e.mem.buffer, this.e.scratch, Math.min(n, 4096))); }
369
636
  /** animated band boxes in viewport px; draw each id as one nonzero path behind the ink */
370
- highlightBoxes() { const n = this.e.ex.qvp_highlight_boxes(this.h, this.e.scratch, 1024); return readBoxes(this.e, this.e.scratch, Math.min(n, 1024)); }
371
- bandBoxes(words, { height = 'pitch', padX = 1.2, padY = 0 } = {}) { const p = this.e.putU32(Uint32Array.from(words)); const n = this.e.ex.qvp_band_boxes(this.h, p, words.length, height === 'ink' ? 1 : 0, padX, padY, this.e.scratch, 64); return readBoxes(this.e, this.e.scratch, Math.min(n, 64)); }
637
+ highlightBoxesView() { const n = this.e.ex.qvp_highlight_boxes_view(this.h, this.e.scratch, 1024); return readBoxes(this.e, this.e.scratch, Math.min(n, 1024)); }
638
+ wordBands(words, { height = 'lineSpacing', padX = DEFAULTS.HIGHLIGHT_PAD_X, padY = DEFAULTS.HIGHLIGHT_PAD_Y } = {}) { const p = this.e.putU32(Uint32Array.from(words)); const n = this.e.ex.qvp_word_bands(this.h, p, words.length, height === 'ink' ? 1 : 0, padX, padY, this.e.scratch, 64); return readBoxes(this.e, this.e.scratch, Math.min(n, 64)); }
372
639
 
373
640
  // ── selection ──
374
641
  select(anchor, focus = anchor) { this.e.ex.qvp_select(this.h, anchor < 0 ? NONE : anchor, focus < 0 ? NONE : focus); }
375
642
  clearSelection() { this.e.ex.qvp_select(this.h, NONE, NONE); }
376
643
  selection() { const n = this.e.ex.qvp_selection(this.h, this.e.scratch, 4096); return Array.from(new Uint32Array(this.e.mem.buffer, this.e.scratch, Math.min(n, 4096))); }
377
- selectionText(form = 'rasm_uthmani', citation = false) { this.e.ex.qvp_selection_text(this.h, FORM[form] ?? 0, citation ? 1 : 0, this.e.scratch); return this.e.qstr(this.e.scratch); }
644
+ selectionText(form = 'rasm_uthmani', includeCitation = false) { this.e.ex.qvp_selection_text(this.h, FORM[form] ?? 0, includeCitation ? 1 : 0, this.e.scratch); return this.e.qstr(this.e.scratch); }
378
645
 
379
646
  // ── memorisation ──
380
647
  mask(target, mode = 'hide') { this.e.ex.qvp_mask(this.h, this._target(target), { hide: 0, block: 1, blur: 2 }[mode] ?? 0); }
381
648
  maskFrom(i, mode = 'hide') { this.e.ex.qvp_mask_from(this.h, i, { hide: 0, block: 1, blur: 2 }[mode] ?? 0); }
382
- maskOptions({ blockColor = '#d9d4c8', padX = 0.6, padY = 0.6, radius = 0.8, reverse = false } = {}) { this.e.ex.qvp_mask_options(this.h, rgba(blockColor), padX, padY, radius, reverse ? 1 : 0); }
383
- revealNext(n = 1) { return this.e.ex.qvp_reveal_next(this.h, n); }
384
- hideBack(n = 1) { return this.e.ex.qvp_hide_back(this.h, n); }
385
- revealWord(i) { return !!this.e.ex.qvp_reveal_word(this.h, i); }
386
- hideWord(i) { return !!this.e.ex.qvp_hide_word(this.h, i); }
387
- revealAll() { this.e.ex.qvp_reveal_all(this.h); }
388
- hideAll() { this.e.ex.qvp_hide_all(this.h); }
649
+ maskOptions({ blockColor = DEFAULTS.MASK_BLOCK, padX = DEFAULTS.MASK_PAD, padY = DEFAULTS.MASK_PAD, radius = DEFAULTS.MASK_RADIUS, reverse = false } = {}) { this.e.ex.qvp_mask_options(this.h, rgba(blockColor), padX, padY, radius, reverse ? 1 : 0); }
650
+ unmaskNext(n = 1) { return this.e.ex.qvp_unmask_next(this.h, n); }
651
+ maskBack(n = 1) { return this.e.ex.qvp_mask_back(this.h, n); }
652
+ unmaskWord(i) { return !!this.e.ex.qvp_unmask_word(this.h, i); }
653
+ maskWord(i) { return !!this.e.ex.qvp_mask_word(this.h, i); }
654
+ unmaskAll() { this.e.ex.qvp_unmask_all(this.h); }
655
+ maskAll() { this.e.ex.qvp_mask_all(this.h); }
389
656
  unmask() { this.e.ex.qvp_unmask(this.h); }
390
657
  maskHidden() { const n = this.e.ex.qvp_mask_hidden(this.h, this.e.scratch, 4096); return Array.from(new Uint32Array(this.e.mem.buffer, this.e.scratch, Math.min(n, 4096))); }
391
658
  maskWords() { const n = this.e.ex.qvp_mask_words(this.h, this.e.scratch, 4096); return Array.from(new Uint32Array(this.e.mem.buffer, this.e.scratch, Math.min(n, 4096))); }
392
- maskBoxes() { const n = this.e.ex.qvp_mask_boxes(this.h, this.e.scratch, 1024); return readBoxes(this.e, this.e.scratch, Math.min(n, 1024)); }
659
+ /** band boxes for a word list in viewport px through the current layout */
660
+ wordBandsView(words, { height = 'lineSpacing', padX = DEFAULTS.HIGHLIGHT_PAD_X, padY = DEFAULTS.HIGHLIGHT_PAD_Y } = {}) { const p = this.e.putU32(Uint32Array.from(words)); const n = this.e.ex.qvp_word_bands_view(this.h, p, words.length, height === 'ink' ? 1 : 0, padX, padY, this.e.scratch, 64); return readBoxes(this.e, this.e.scratch, Math.min(n, 64)); }
661
+ maskBoxesView() { const n = this.e.ex.qvp_mask_boxes_view(this.h, this.e.scratch, 1024); return readBoxes(this.e, this.e.scratch, Math.min(n, 1024)); }
393
662
  /** greyed page with a lit window: {lit, byAyah, grey, ink, ayahMarks, ms} → steps */
394
- revealStart({ lit = 1, byAyah = false, grey = '#c9c4b8', ink = '#231f20', ayahMarks = true, ms = 0 } = {}) { return this.e.ex.qvp_reveal_start(this.h, lit, byAyah ? 1 : 0, rgba(grey), rgba(ink), ayahMarks ? 1 : 0, ms); }
663
+ revealStart({ lit = DEFAULTS.REVEAL_LIT, byAyah = false, grey = DEFAULTS.REVEAL_GREY, ink = DEFAULTS.INK, ayahMarks = true, ms = 0 } = {}) { return this.e.ex.qvp_reveal_start(this.h, lit, byAyah ? 1 : 0, rgba(grey), rgba(ink), ayahMarks ? 1 : 0, ms); }
395
664
  revealGoto(at) { return !!this.e.ex.qvp_reveal_goto(this.h, BigInt(at)); }
396
- revealAt() { const v = Number(this.e.ex.qvp_reveal_at(this.h)); return v === -2 ? null : v; }
397
- revealSteps() { return this.e.ex.qvp_reveal_steps(this.h); }
665
+ revealPosition() { const v = Number(this.e.ex.qvp_reveal_position(this.h)); return v === -2 ? null : v; }
666
+ revealStepCount() { return this.e.ex.qvp_reveal_step_count(this.h); }
398
667
  revealStepOf(i) { return Number(this.e.ex.qvp_reveal_step_of(this.h, i)); }
399
668
  revealStop() { this.e.ex.qvp_reveal_stop(this.h); }
400
669
 
401
670
  // ── crop ──
402
- cropBox(target, { pad = 2, keepAyahMarks = true } = {}) { if (!this.e.ex.qvp_crop_box(this.h, this._target(target), pad, keepAyahMarks ? 1 : 0, this.e.scratch)) return null; const d = this.e.dv(), s = this.e.scratch; return { x0: d.getFloat32(s, true), y0: d.getFloat32(s + 4, true), x1: d.getFloat32(s + 8, true), y1: d.getFloat32(s + 12, true), nWords: d.getUint32(s + 16, true), ayahMarkDeco: d.getUint32(s + 20, true) }; }
403
- cropSvg(target, { pad = 2, keepAyahMarks = true, background = null } = {}) { if (!this.e.ex.qvp_crop_svg(this.h, this._target(target), pad, keepAyahMarks ? 1 : 0, background ? rgba(background) : 0, this.e.scratch)) return null; return this.e.qstr(this.e.scratch); }
671
+ cropBounds(target, { pad = DEFAULTS.CROP_PAD, keepAyahMarks = true } = {}) { if (!this.e.ex.qvp_crop_bounds(this.h, this._target(target), pad, keepAyahMarks ? 1 : 0, this.e.scratch)) return null; const d = this.e.dv(), s = this.e.scratch; return { x0: d.getFloat32(s, true), y0: d.getFloat32(s + 4, true), x1: d.getFloat32(s + 8, true), y1: d.getFloat32(s + 12, true), nWords: d.getUint32(s + 16, true), ayahMarkDecoration: d.getUint32(s + 20, true) }; }
672
+ cropSvg(target, { pad = DEFAULTS.CROP_PAD, keepAyahMarks = true, background = null } = {}) { if (!this.e.ex.qvp_crop_svg(this.h, this._target(target), pad, keepAyahMarks ? 1 : 0, background ? rgba(background) : 0, this.e.scratch)) return null; return this.e.qstr(this.e.scratch); }
404
673
  }
405
674
 
406
675
  class QvpAtlas {
@@ -408,18 +677,22 @@
408
677
  free() { this.e.ex.qvp_atlas_free(this.h); this.h = 0; }
409
678
  pageOf(surah, ayah) { const p = this.e.ex.qvp_atlas_page_of(this.h, surah, ayah); return p < 0 ? null : p; }
410
679
  pageRange(page) { if (!this.e.ex.qvp_atlas_page_range(this.h, page, this.e.scratch)) return null; const v = new Uint16Array(this.e.mem.buffer, this.e.scratch, 4); return { first: [v[0], v[1]], last: [v[2], v[3]] }; }
411
- _surah(s) { const d = this.e.dv(); return { n: d.getUint16(s, true), page: d.getUint16(s + 2, true), ayahCount: d.getUint16(s + 4, true), place: ['makkah', 'madinah'][d.getUint8(s + 6)] || '', arabic: this.e.qstr(s + 8), latin: this.e.qstr(s + 16), english: this.e.qstr(s + 24) }; }
680
+ _surah(s) { const d = this.e.dv(); return { number: d.getUint16(s, true), page: d.getUint16(s + 2, true), ayahCount: d.getUint16(s + 4, true), place: names('place')[d.getUint8(s + 6)] || '', arabic: this.e.qstr(s + 8), latin: this.e.qstr(s + 16), english: this.e.qstr(s + 24) }; }
412
681
  surah(n) { return this.e.ex.qvp_atlas_surah(this.h, n, this.e.scratch) ? this._surah(this.e.scratch) : null; }
413
- surahs() { const n = this.e.ex.qvp_atlas_surahs(this.h), out = []; for (let i = 0; i < n; i++) { this.e.ex.qvp_atlas_surah_at(this.h, i, this.e.scratch); out.push(this._surah(this.e.scratch)); } return out; }
682
+ surahs() { const n = this.e.ex.qvp_atlas_surah_count(this.h), out = []; for (let i = 0; i < n; i++) { this.e.ex.qvp_atlas_surah_at(this.h, i, this.e.scratch); out.push(this._surah(this.e.scratch)); } return out; }
414
683
  pageOfSurah(n) { const s = this.surah(n); return s ? s.page : null; }
684
+ /** How many pages the atlas covers. */
685
+ pageCount() { return this.e.ex.qvp_atlas_page_count(this.h); }
686
+ /** The whole atlas as JSON text. */
687
+ json() { this.e.ex.qvp_atlas_json(this.h, this.e.scratch); return this.e.qstr(this.e.scratch); }
415
688
  division(kind, n) { if (!this.e.ex.qvp_atlas_division(this.h, { juz: 0, hizb: 1, nisf: 2, rubu_al_hizb: 3 }[kind], n, this.e.scratch)) return null; const v = new Uint16Array(this.e.mem.buffer, this.e.scratch, 4); return { rubuAlHizb: v[0], surah: v[1], ayah: v[2], page: v[3], ayahKey: `${v[1]}:${v[2]}` }; }
416
689
  juz(n) { return this.division('juz', n); }
417
690
  hizb(n) { return this.division('hizb', n); }
418
691
  rubuAlHizb(n) { return this.division('rubu_al_hizb', n); }
419
- divisionAt(kind, surah, ayah) { const v = this.e.ex.qvp_atlas_division_at(this.h, { juz: 0, hizb: 1, nisf: 2, rubu_al_hizb: 3 }[kind], surah, ayah); return v < 0 ? null : v; }
420
- juzAt(surah, ayah) { return this.divisionAt('juz', surah, ayah); }
692
+ divisionOf(kind, surah, ayah) { const v = this.e.ex.qvp_atlas_division_of(this.h, { juz: 0, hizb: 1, nisf: 2, rubu_al_hizb: 3 }[kind], surah, ayah); return v < 0 ? null : v; }
693
+ juzOf(surah, ayah) { return this.divisionOf('juz', surah, ayah); }
421
694
  pagesOfJuz(n) { if (!this.e.ex.qvp_atlas_pages_of_juz(this.h, n, this.e.scratch)) return null; const v = new Uint16Array(this.e.mem.buffer, this.e.scratch, 2); return [v[0], v[1]]; }
422
- findSurah(text) { const [p, n] = this.e.putStr(text); const c = this.e.ex.qvp_atlas_find_surah(this.h, p, n, this.e.scratch, 128); return Array.from(new Uint16Array(this.e.mem.buffer, this.e.scratch, Math.min(c, 128))).map(k => this.surah(k)); }
695
+ searchSurahs(text) { const [p, n] = this.e.putStr(text); const c = this.e.ex.qvp_atlas_search_surahs(this.h, p, n, this.e.scratch, 128); return Array.from(new Uint16Array(this.e.mem.buffer, this.e.scratch, Math.min(c, 128))).map(k => this.surah(k)); }
423
696
  }
424
697
 
425
698
  /**
@@ -430,16 +703,35 @@
430
703
  constructor(canvas) {
431
704
  this.canvas = canvas; this.ctx = canvas.getContext('2d');
432
705
  this.base = document.createElement('canvas'); this.baseKey = '';
706
+ // the engine's draw list for the band in hand, kept until the band moves
707
+ this.list = null; this.places = null; this.listKey = '';
708
+ /// How much ink to keep ready at once: a page that fits is drawn once and scrolling never
709
+ /// draws again. About 17 MB for a page of this mushaf at the first zoom step.
710
+ this.inkBudget = 64 << 20;
433
711
  this.stats = { baseMs: 0, overlayMs: 0, basePaths: 0, overlayPaths: 0, bands: 0 };
434
712
  }
435
- lineTransform(page, view, line, dpr) {
436
- const L = page.currentLayout || { scale: 1, ox: 0, oy: 0, lineDy: null };
437
- const s = dpr * view.scale * L.scale, dy = L.lineDy ? L.lineDy[line] : 0;
438
- return [s, dpr * (view.ox + view.scale * L.ox), dpr * (view.oy + view.scale * (L.oy + dy * L.scale))];
713
+ /** transform of one group of paths: a point p is drawn at (kx·p.x + dx, ky·p.y + dy), page units */
714
+ groupTransform(page, view, group, dpr) {
715
+ const L = page.currentLayout || { scale: 1, offsetX: 0, offsetY: 0, lineDy: null, groups: null };
716
+ let dx = 0, dy = 0, kx = 1, ky = 1;
717
+ if (L.groups && L.groups.length > group * 4) { dx = L.groups[group * 4]; dy = L.groups[group * 4 + 1]; kx = L.groups[group * 4 + 2]; ky = L.groups[group * 4 + 3]; }
718
+ else if (L.lineDy) dy = L.lineDy[group] || 0;
719
+ return this.placementTransform(page, view, { dx, dy, kx, ky }, dpr);
439
720
  }
721
+ /** transform of one placement: [a, d, tx, ty] for setTransform(a, 0, 0, d, tx, ty) */
722
+ placementTransform(page, view, p, dpr) {
723
+ const L = page.currentLayout || { scale: 1, offsetX: 0, offsetY: 0 };
724
+ const s = dpr * view.scale * L.scale;
725
+ return [s * p.kx, s * p.ky, dpr * (view.offsetX + view.scale * (L.offsetX + p.dx * L.scale)), dpr * (view.offsetY + view.scale * (L.offsetY + p.dy * L.scale))];
726
+ }
727
+ lineTransform(page, view, line, dpr) { const [a, , tx, ty] = this.groupTransform(page, view, line, dpr); return [a, tx, ty]; }
728
+ /** put a context into one group's space, so a host overlay lands where the group is drawn */
729
+ setGroupTransform(c, page, view, group, dpr) { const [a, d, tx, ty] = this.groupTransform(page, view, group, dpr); c.setTransform(a, 0, 0, d, tx, ty); }
730
+ /** the group a path is drawn with: its reflow group, or its printed line */
731
+ pathGroup(page, i) { const L = page.currentLayout; return L && L.pathGroup ? L.pathGroup[i] : page.pathLine(i); }
440
732
  /** boxes are in layout viewport px; view adds pan/zoom on top */
441
733
  drawBoxes(c, boxes, view, dpr) {
442
- c.setTransform(dpr * view.scale, 0, 0, dpr * view.scale, dpr * view.ox, dpr * view.oy);
734
+ c.setTransform(dpr * view.scale, 0, 0, dpr * view.scale, dpr * view.offsetX, dpr * view.offsetY);
443
735
  let cur = null, col = 0;
444
736
  const flush = () => { if (cur) { c.fillStyle = css(col); c.fill(cur, 'nonzero'); cur = null; } };
445
737
  for (const b of boxes) {
@@ -450,22 +742,54 @@
450
742
  }
451
743
  draw(page, view, dpr) {
452
744
  const paths = page.buildPaths();
453
- const styled = page.styled();
745
+ const styled = page.styledPaths();
454
746
  const styledSet = new Set(styled.map(s => s[0]));
455
- const L = page.currentLayout || { scale: 1, ox: 0, oy: 0, lineDy: null, pitch: 0 };
747
+ const L = page.currentLayout || { scale: 1, offsetX: 0, offsetY: 0, lineDy: null, lineSpacing: 0 };
456
748
  const ink = page.defaultInk;
457
- const key = `${view.scale.toFixed(4)}|${view.ox.toFixed(1)}|${view.oy.toFixed(1)}|${dpr}|${ink}|${L.scale}|${L.pitch}|${L.lineDy ? L.lineDy[0] : ''}|${[...styledSet].sort((a, b) => a - b).join(',')}`;
458
749
  const W = this.canvas.width, H = this.canvas.height;
459
- const setTf = (c, line) => { const [s, tx, ty] = this.lineTransform(page, view, line, dpr); c.setTransform(s, 0, 0, s, tx, ty); };
460
- if (key !== this.baseKey || this.base.width !== W || this.base.height !== H) {
750
+ // The cached ink is a band of the page, not the screen, so scrolling inside it is one
751
+ // blit and nothing is drawn again. A page that is several screens of ink used to redraw
752
+ // every path on every frame of a drag, because the cache was keyed on where the reader
753
+ // had scrolled to. The band is rebuilt only when the drag has travelled half a screen.
754
+ const viewH = H / dpr, slack = viewH / 2, step = viewH / 2;
755
+ const scale = view.scale || 1;
756
+ // A page that fits the ink budget is drawn once, the parts past both ends of the screen
757
+ // included, and then scrolling draws nothing at all. A taller one keeps a band of itself,
758
+ // two screens tall, redrawn when the reader scrolls out of it.
759
+ const contentH = (L.contentH || 0) * scale;
760
+ const whole = contentH > 0 && W * contentH * dpr * 4 <= this.inkBudget && contentH * dpr <= 16384;
761
+ const bandTop = whole ? 0 : Math.floor((-view.offsetY - slack) / step) * step; // view px
762
+ const bandH = whole ? contentH : viewH + 2 * slack;
763
+ // the ink drawn into the band, in layout px: what the engine is asked for
764
+ const cullTop = bandTop / scale, cullBottom = (bandTop + bandH) / scale;
765
+ // the view the band is rendered under: the reader's, less where the band starts
766
+ const bandView = { ...view, offsetY: -bandTop };
767
+ const key = `${view.scale.toFixed(4)}|${view.offsetX.toFixed(1)}|${bandTop.toFixed(1)}|${dpr}|${ink}|${L.scale}|${L.lineSpacing}|${L.lineDy ? L.lineDy[0] : ''}|${L.groups ? L.groups.length + ':' + L.groups[1] : ''}|${(L.repeats || []).length}|${L.omitted ? L.omitted.size : 0}|${[...styledSet].sort((a, b) => a - b).join(',')}`;
768
+ // The engine says what is drawn and where: each path once under its placement, again for
769
+ // every row a decoration repeats over, and nothing for a path this layout leaves out. It
770
+ // is asked once per band, not once per frame: a scroll inside the band changes neither.
771
+ const listKey = `${page.page}|${cullTop.toFixed(1)}|${cullBottom.toFixed(1)}|${L.scale}|${L.rows}|${L.contentH}`;
772
+ if (listKey !== this.listKey) {
773
+ this.list = page.layoutDrawList(cullTop, cullBottom);
774
+ this.places = page.layoutPlacements();
775
+ this.listKey = listKey;
776
+ }
777
+ const list = this.list, places = this.places;
778
+ const setPlace = (c, g, v = view) => {
779
+ const [a, d, tx, ty] = this.placementTransform(page, v, { dx: places[g * 4], dy: places[g * 4 + 1], kx: places[g * 4 + 2], ky: places[g * 4 + 3] }, dpr);
780
+ c.setTransform(a, 0, 0, d, tx, ty);
781
+ };
782
+ const baseH = Math.ceil(bandH * dpr);
783
+ if (key !== this.baseKey || this.base.width !== W || this.base.height !== baseH) {
461
784
  const t0 = performance.now();
462
- this.base.width = W; this.base.height = H;
785
+ this.base.width = W; this.base.height = baseH;
463
786
  const b = this.base.getContext('2d');
464
787
  b.fillStyle = css(ink);
465
788
  let n = 0, cur = -1;
466
- for (let i = 0; i < page.nPaths; i++) {
789
+ for (let k = 0; k < list.length; k += 2) {
790
+ const i = list[k], g = list[k + 1];
467
791
  if (styledSet.has(i)) continue;
468
- const ln = page.pathLine(i); if (ln !== cur) { setTf(b, ln); cur = ln; }
792
+ if (g !== cur) { setPlace(b, g, bandView); cur = g; }
469
793
  b.fill(paths[i], page.pathEvenOdd(i) ? 'evenodd' : 'nonzero'); n++;
470
794
  }
471
795
  this.baseKey = key; this.stats.baseMs = performance.now() - t0; this.stats.basePaths = n;
@@ -473,20 +797,23 @@
473
797
  const t1 = performance.now();
474
798
  const c = this.ctx;
475
799
  c.setTransform(1, 0, 0, 1, 0, 0); c.clearRect(0, 0, W, H);
476
- const bands = page.highlightBoxes();
800
+ const bands = page.highlightBoxesView();
477
801
  this.drawBoxes(c, bands, view, dpr);
478
802
  c.setTransform(1, 0, 0, 1, 0, 0);
479
- c.drawImage(this.base, 0, 0);
803
+ c.drawImage(this.base, 0, Math.round(dpr * (view.offsetY + bandTop)));
804
+ const colors = new Map(styled);
480
805
  let cur = -1;
481
- for (const [i, col] of styled) {
482
- if ((col & 255) === 0) continue;
483
- const ln = page.pathLine(i); if (ln !== cur) { setTf(c, ln); cur = ln; }
806
+ for (let k = 0; k < list.length; k += 2) {
807
+ const i = list[k], g = list[k + 1];
808
+ const col = colors.get(i);
809
+ if (col === undefined || (col & 255) === 0) continue;
810
+ if (g !== cur) { setPlace(c, g); cur = g; }
484
811
  c.fillStyle = css(col); c.fill(paths[i], page.pathEvenOdd(i) ? 'evenodd' : 'nonzero');
485
812
  }
486
- this.drawBoxes(c, page.maskBoxes(), view, dpr);
813
+ this.drawBoxes(c, page.maskBoxesView(), view, dpr);
487
814
  this.stats.overlayMs = performance.now() - t1; this.stats.overlayPaths = styled.length; this.stats.bands = bands.length;
488
815
  }
489
816
  }
490
817
 
491
- global.QVP = { QvpEngine, QvpPage, QvpAtlas, CanvasRenderer, Sel, T, KIND, FAMILY, CATEGORY, DECO, FORM, LAYER, MARK, MARKS, NONE, css, rgba, parseTarget };
818
+ global.QVP = { QvpEngine, QvpPage, QvpAtlas, CanvasRenderer, Sel, T, KIND, FAMILY, CATEGORY, DECORATION, FORM, LAYER, NAMES_TABLE, DEFAULTS, NONE, css, rgba, parseTarget };
492
819
  })(typeof window !== 'undefined' ? window : globalThis);