@quran.ws/engine 0.1.0 → 0.3.1

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/docs/API.md CHANGED
@@ -3,8 +3,8 @@
3
3
  One engine, one contract. The C ABI in `crates/qvp-ffi/include/qvp.h` is the source of
4
4
  truth; every wrapper (`web/qvp.js`, Kotlin, Dart, React Native, Swift) exposes the same
5
5
  names in the platform's own casing, so this page documents once and applies everywhere.
6
- Examples are JavaScript; read `page.hitTestEx(...)` as `page.hitTestEx(...)` in Dart,
7
- `page.hitTestEx(...)` in Kotlin, `qvp_hit_test_ex(...)` in C.
6
+ Examples are JavaScript; the same call is `page.hitTest(...)` in Dart, Kotlin and Swift
7
+ and `qvp_hit_test(...)` in C.
8
8
 
9
9
  **Conventions**
10
10
 
@@ -12,6 +12,10 @@ Examples are JavaScript; read `page.hitTestEx(...)` as `page.hitTestEx(...)` in
12
12
  Wrappers also accept `'#rgb'`, `'#rrggbb'`, `'#rrggbbaa'`.
13
13
  - **Page units** are the printed page's viewBox space (345 × 550 for this mushaf, y down).
14
14
  Anything named `…View` is in *viewport pixels through the current layout*.
15
+ - **Defaults.** Every default a wrapper applies (ink, highlight colours and padding, gap bias,
16
+ tap distance, mask and reveal colours, crop padding) is a `QVP_DEFAULT_*` value in `qvp.h`,
17
+ defined once in the engine (`crates/qvp-core/src/defaults.rs`) and mirrored as `QvpDefaults`
18
+ in each wrapper; the parity check keeps every copy equal.
15
19
  - **Handles.** Every mutating style/highlight call returns a handle; removing the handle
16
20
  undoes exactly that call and nothing else. There is a `clear`, but you never need it.
17
21
  - **Targets** resolve to a word list: `'page'`, `'2:255'` (ayah), `'2:255:3'` (word),
@@ -21,11 +25,13 @@ Examples are JavaScript; read `page.hitTestEx(...)` as `page.hitTestEx(...)` in
21
25
  diacritic of one word*: `Sel.page()`, `Sel.word(i)`, `Sel.ayah(s,a)`, `Sel.line(n)`,
22
26
  `Sel.wordBody(i)`, `Sel.wordMarks(i)`, `Sel.wordMark(i, nth)`, `Sel.wordMarkNamed(i, 'fathah', nth)`,
23
27
  `Sel.wordPath(i, nth)`, `Sel.path(p)`, `Sel.mark('shaddah')`, `Sel.category('harakah')`,
24
- `Sel.family('dots')`, `Sel.kind('mark')`, `Sel.deco('ayah-mark')`, `Sel.decoIdx(d)`.
25
- - **The engine decides, the host draws.** Hit-testing, layout, styling, highlight bands,
26
- masks and search are engine calls. A wrapper only marshals and paints what it is told.
27
- - **Data is separate from code.** Pages (`NNN.qvp`), the atlas (`atlas.qva`) and the
28
- optional text sidecars (`NNN.words.json`) are assets your app loads; no package bundles them.
28
+ `Sel.family('dots')`, `Sel.kind('mark')`, `Sel.decoration('ayah-mark')`, `Sel.decorationIndex(d)`.
29
+ - **The engine computes, the host renders.** Hit-testing, layout, styling, highlight bands,
30
+ masks and search are engine calls. A wrapper marshals the calls and renders the results.
31
+ - **Data is separate from code.** Pages (`NNN.qvp`), the atlas (`atlas.qva`), optional text
32
+ sidecars (`NNN.words.json`) and reusable title-only assets under `surah-names/` are loaded
33
+ by the app; no package bundles them. Titles are published as individual and combined QVP
34
+ and SVG files, plus a WOFF2 font and its map.
29
35
 
30
36
  ## Loading
31
37
 
@@ -37,32 +43,40 @@ page.attachWords(await fetchJson('pages/042.words.json')); // opti
37
43
  page.free(); atlas.free();
38
44
  ```
39
45
 
40
- `page.width/height/page/nLines/nAyahs/nWords/nPaths/nDecos`, `page.naturalPitch`.
46
+ `page.width/height/page/nLines/nAyahs/nWords/nPaths/nDecorations`, `page.lineSpacing`,
47
+ `page.grid`.
41
48
 
42
49
  ## Words, ayahs, lines, decorations
43
50
 
44
51
  | | |
45
52
  |---|---|
46
- | `page.words[i]` | `{idx, surah, ayah, word, line, lineIdx, ayahIdx, x0,y0,x1,y1, text, firstPath, nPaths}` |
47
- | `page.ayahs[i]` | one **fragment** per printed line: `{surah, ayah, fragment, fragments, flags, rubuAlHizb, firstWord, nWords, ayahMarkDeco, bbox}` |
48
- | `page.lines[i]` | `{lineNo, isHeader, firstWord, nWords, bbox, bandY0, bandY1, centre}` |
49
- | `page.decos[i]` | `{kind, surah, ayah, line, bbox, text, firstPath, nPaths}` — ayah marks, surah banners, basmalah, division rosettes, sajdah signs, page furniture |
53
+ | `page.words[i]` | `{index, surah, ayah, word, line, lineIndex, ayahIndex, x0,y0,x1,y1, text, firstPath, nPaths}` |
54
+ | `page.ayahs[i]` | one **fragment** per printed line: `{surah, ayah, fragment, fragments, flags, rubuAlHizb, firstWord, nWords, ayahMarkDecoration, bbox}` |
55
+ | `page.lines[i]` | `{lineNumber, isHeader, firstWord, nWords, bbox, bandY0, bandY1, centre}` |
56
+ | `page.decorations[i]` | `{decoration, surah, ayah, line, bbox, text, firstPath, nPaths}` — ayah marks, surah banners, basmalah, division rosettes, sajdah signs, page furniture |
50
57
  | `page.findWord(s,a,w)` | index or −1 |
51
- | `page.resolve(target)` | word indices in reading order |
58
+ | `page.targetWords(target)` | word indices in reading order |
52
59
  | `page.wordForm(i, form)` | `'rasm_uthmani' \| 'rasm_imlai' \| 'qpc' \| 'rasm' \| 'search'` (derived forms need the sidecar; `hasForm(form)`) |
53
60
  | `page.attachWords(json)` | attach `NNN.words.json` (`{"s:a:w": {rasm_uthmani, rasm_imlai, qpc, rasm, search}}`); returns words updated |
54
61
  | `page.pathKind/Mark/Family/Category(p)`, `pathWord(p)`, `pathLine(p)`, `pathNthMark(p)` | per-path facts from the geometry table |
55
62
 
56
- An ayah is several fragments. `resolve('2:255')` gives all its words on the page;
57
- `ayahWordCount(s,a)` returns `{count, complete}` — `complete` is false when the ayah
63
+ An ayah is several fragments. `targetWords('2:255')` gives all its words on the page;
64
+ `ayahWordCount(s,a)` returns `{count, isComplete}`; `isComplete` is false when the ayah
58
65
  continues on another page.
59
66
 
67
+ **Word tokenization.** The mushaf holds **77,432** words, keyed `surah:ayah:word` — the
68
+ quran-ws shared word identity (the same keys quran-svg, quran-svg-elements and the tajweed
69
+ spans use). A host app with its own word table may tokenize an edge case differently (a
70
+ compound written as one word split into two, or the reverse); map at the boundary with
71
+ `findWord(s,a,w)` / `words[i].wordKey`, and treat a per-ayah word-count mismatch as
72
+ "skip, don't guess" — the engine's numbering follows the standard, never a host table.
73
+
60
74
  ## Metadata (no database needed)
61
75
 
62
76
  `surahs()` → `{number, arabic, latin, english, place, ayahCount, hasBanner, hasBasmalah}`;
63
77
  `divisions()` → juz/hizb/nisf/`rubu_al_hizb` that **start** on the page; `rosettes()` (drawn division
64
78
  marks); `sajdahs()`; `ayahMarks()` → real ayah medallions with centre/radius and the
65
- ornament/numeral path indices (swap or restyle them); `ayahKeys()`; `wordLabel(i)`,
79
+ ornament/numeral path indices (swap or recolorStyle them); `ayahKeys()`; `wordLabel(i)`,
66
80
  `ayahLabel(i)` for screen readers.
67
81
 
68
82
  ## Text and search
@@ -70,7 +84,7 @@ ornament/numeral path indices (swap or restyle them); `ayahKeys()`; `wordLabel(i
70
84
  ```js
71
85
  page.text('2:255') // with the mushaf's own line breaks
72
86
  page.text('page', {form: 'search', wordSep: ' '})
73
- page.search('الرحمان', {mode: 'includes'}) // [{word, wordKey, text, index, loose}]
87
+ page.search('الرحمان', {mode: 'includes'}) // [{word, wordKey, text, index, isLooseMatch}]
74
88
  page.citation([12, 13, 14]) // "2:255" / "2:255-257" / "2:286, 3:1"
75
89
  engine.strip(s); engine.fold(s); engine.normalize(s); engine.looseKey(s)
76
90
  ```
@@ -82,41 +96,415 @@ Modes: `includes`, `exact`, `prefix`. Without a sidecar it searches the stripped
82
96
  ## Hit testing
83
97
 
84
98
  ```js
85
- page.hitTestViewEx(vx, vy, {maxDistance: 6, gapBias: 0.6})
86
- // → {word, path, deco, line, distance, exact, wordKey, ayahKey} | null
99
+ page.hitTestView(viewX, viewY, {maxDistance: 6, gapBias: 0.6})
100
+ // → {word, path, decoration, line, distance, isExact, wordKey, ayahKey} | null
87
101
  ```
88
102
 
89
103
  Exact outline first, then **nearest with direction**: the point is resolved to a line
90
- by its pitch band, then to a word, with a gap between two words split 60/40 towards the
104
+ by its line spacing band, then to a word, with a gap between two words split 60/40 towards the
91
105
  preceding (right-hand) word — trailing ink is drawn *into* the following gap in this
92
- print. `hitBoxes()` returns the same partition as boxes (no dead zones on a line);
93
- `lineBands()` the pitch bands. `hitTest`/`hitTestView` are the exact-only variants.
106
+ print. `hitAreas()` returns the same partition as boxes (no dead zones on a line);
107
+ `lineBands()` the line spacing bands. `hitTestExact`/`hitTestExactView` are the exact-only variants.
94
108
 
95
109
  ## Layout
96
110
 
97
111
  ```js
98
112
  const L = page.layout({viewportW, viewportH, padTop, padBottom, padLeft, padRight,
99
- lineSpacing: 1.0, lineGap: 0, fillHeight: false, nominalLines: 15});
100
- // L = {scale, ox, oy, contentW, contentH, pitch, lineDy[], slots[]}
113
+ lineSpacing: 1.0, fillHeight: false, gridLines: 0,
114
+ cropLeft: 0, cropRight: 0, maxAspectSlack: 0, bannerZoom: 0,
115
+ surahFrames: true});
116
+ // L = {scale, offsetX, offsetY, contentW, contentH, lineSpacing, lineDy[], slots[], fitScale, fitX, fitY}
117
+ page.layoutLineSpacingToFill(spec) // the lineSpacing multiplier that fills the padded viewport of spec
118
+ page.layoutWastedFraction(spec) // the share of the padded viewport left empty at fit-to-width
119
+ page.layoutPrintedHeight(spec) // the page's height at the printed pitch: contentH before fill-height adds leading
120
+ page.grid // {lines, lineSpacing}: the mushaf's line count and the printed spacing
121
+ page.lineSpacing // the printed line spacing of this page, in page units
101
122
  ```
102
123
 
124
+ **The fit.** `fitScale`, `fitX`, `fitY` is the view transform that shows the whole laid-out
125
+ content in the viewport: shrink by `fitScale` when the content is taller than the viewport
126
+ (never enlarge), then centre. A host draws at `fitX + fitScale·viewX`, `fitY + fitScale·viewY`
127
+ and applies its own pan and zoom on top. `maxAspectSlack` bounds the content width to
128
+ `viewportH·pageW/pageH·slack` so a landscape screen does not stretch the lines (0 = no
129
+ bound; the examples use 1.15). `cropLeft`/`cropRight` cut the printed side margins (page
130
+ units) so the ink spans the padded width. Wrappers compute none of this; the engine's
131
+ answers for forty viewport cases are in `conformance/scenarios/layout.json`.
132
+
133
+ **`bannerZoom` caps how big a surah name or a basmalah gets as the reader zooms in**, as a
134
+ multiple of its PRINTED size. 0 (the default) leaves it uncapped: the drawing grows with the
135
+ words around it until it fills the row, which for a surah name is about five times the print.
136
+ 1 holds it at the printed size however far the reader zooms — what a host that draws its own
137
+ frame around the printed name wants, since the frame has a shape to keep. It is a LAYOUT knob
138
+ and not one of the reflow knobs, because the reader's zoom control fills those in itself
139
+ (`page.zoomSpec`), so a host that pinches never gets to set one; only a reflowed page grows a
140
+ banner at all.
141
+
142
+ **`surahFrames` controls the source-native frame around a surah name.** It defaults to
143
+ `true`, preserving the printed page. Set it to `false` when the host supplies its own frame.
144
+ The native frame is then omitted from drawing and from exact hit testing. Reflowed titles and
145
+ the reusable title assets are frameless either way.
146
+
147
+ **Drawing your own frame.** `page.surahHeadersView()` gives each heading two boxes in
148
+ viewport px: `x0, y0, x1, y1` is the box a frame fills, the page's text block wide and the
149
+ heading's row tall, and `titleX0 … titleY1` is the title ink inside it. Both leave out a
150
+ native frame, whether the layout draws one or not, so the numbers do not change when the
151
+ reader turns the frame off. `page.surahHeaders()` is the same in page units.
152
+
153
+ ```js
154
+ page.layout({ ...spec, surahFrames: false, bannerZoom: 1 });
155
+ for (const h of page.surahHeadersView()) {
156
+ ctx.strokeRect(h.x0, h.y0, h.x1 - h.x0, h.y1 - h.y0); // your frame
157
+ }
158
+ ```
159
+
160
+ `bannerZoom: 1` belongs with this: it holds the title at its printed size as the reader zooms,
161
+ so a frame with a fixed shape still fits it.
162
+
163
+ Read the boxes from `surahHeadersView()` rather than mapping `decorationInfo` yourself. That
164
+ box is the one stored in the file, so it covers the native frame you just hid, and it is in
165
+ page units: the layout moves a heading's line by `L.lineDy[line]`, and a mapping that leaves
166
+ that term out draws the frame about eight px above the title on a printed page.
167
+
168
+ **What this is for.** A printed mushaf page is squatter than a phone screen: fitted to the
169
+ width of a tall viewport it leaves a band of empty paper top and bottom. The layout knobs
170
+ exist to fill that empty band with leading, so the lines drift apart until the page fills
171
+ the screen, and for nothing else. They are an expansion control, never a compression one.
172
+
103
173
  Horizontal placement is as printed; each line moves by `lineDy[line]`. Lines are never
104
- re-spread onto a grid (printed lines are not equally tall or equally pitched, and ink
174
+ re-spread onto a grid (printed lines are not equally tall or equally spaced, and ink
105
175
  crosses into neighbouring lines): every line keeps its printed position and the same
106
- delta is added between each pair of consecutive lines. `lineSpacing` sets that delta as
107
- a multiple of the printed pitch (`pitch·(lineSpacing−1)`), `lineGap` adds leading in page
108
- units, `fillHeight` picks the delta that makes the page fill the padded viewport
109
- (pages 1–2 stay centred). `slots[]` boundaries sit halfway between neighbouring lines.
110
- Pure helpers:
111
- `engine.gapToFill(pageW, pageH, lines, viewW, viewH, max)` and `wastedFraction(...)`.
112
- `wordBoxView(i)` gives a word's box in viewport px for scroll-into-view.
113
-
114
- `nominalLines` is the grid the page is laid out *inside*, not the page's own line
115
- count: it defaults to 15 and is clamped up to `page.nLines`, never down. A short page
116
- laid out at 15 — al-Fatiha's 7 lines, say — is therefore centred in a full-page box and
117
- draws at under half the height, with the rest of the viewport left empty. That is the
118
- spec working, not a rendering bug. Pass `nominalLines: page.nLines` when you want the
119
- page to fill what you gave it, and keep 15 only when several pages must share one grid.
176
+ delta is added between each pair of consecutive lines. One name covers the concept at
177
+ every level: `page.lineSpacing` is the printed spacing, the spec's `lineSpacing` is the
178
+ multiplier you ask for, `L.lineSpacing` is the spacing the layout produced. The delta is
179
+ `page.lineSpacing·(lineSpacing − 1)`; `fillHeight` picks the delta that makes the page fill
180
+ the padded viewport instead. A short page (fewer lines than the grid, pages 1 and 2) has no
181
+ height of its own to fill, so under `fillHeight` it takes the rows a full page gets,
182
+ `(viewportH − pads)/gridLines` each, centred. Leading only opens up: the printed spacing
183
+ is the floor, and a page that cannot fit at it reports a `contentH` taller than the
184
+ viewport. `slots[]` boundaries sit halfway between neighbouring lines, except beside a
185
+ header line (surah name, basmalah), where they stop half a line spacing from the line's
186
+ centre: the banner on pages 1 and 2 sits several line spacings above the text, and that
187
+ gap is not the first line's.
188
+
189
+ **Spacing only opens up.** The delta is clamped at 0, so `lineSpacing < 1` or a
190
+ `fillHeight` that would need to tighten lays the page out exactly as printed. Lines can
191
+ never be pulled closer together than the mushaf prints them. The text width is not
192
+ adjustable either: the page is always fitted to the padded viewport width
193
+ (`scale = (viewportW − padLeft − padRight) / pageW`), so the only layout knob a reader gets
194
+ is more leading, never a narrower or wider line.
195
+
196
+ `gridLines` is the grid the page is laid out inside, not the page's own line count. 0
197
+ means the page's grid (`page.grid.lines`: 15 for this mushaf, or more when a page has
198
+ more), clamped up to `page.nLines`, never down. A short page laid out on 15 lines,
199
+ Fatihah's 7 for instance, is therefore centred in a full-page box: without `fillHeight` it
200
+ draws at under half the height with the rest of the viewport left empty; with it, its lines
201
+ take full-page rows. That is the spec working, not a rendering bug. Pass
202
+ `gridLines: page.nLines` when you want the page to fill what you gave it, and keep the
203
+ default only when several pages must share one grid.
204
+
205
+ `wordBoundsView(i)` gives a word's box in viewport px for scroll-into-view.
206
+
207
+ ## Reflow
208
+
209
+ ```js
210
+ const L = page.layout({viewportW, viewportH, padLeft, padRight,
211
+ reflow: {zoom: 1.6, fill: 'ragged' | 'justified' | 'centred', gaps: 'printed',
212
+ wordGap: 1, maxStretch: 1.6}});
213
+ // L.reflowed, L.rows, L.groups (dx, dy, kx, ky per group), L.pathGroup (the group of a path)
214
+ page.reflowMaxZoom(spec) // the largest zoom whose rows still hold every word of this page
215
+ L.omitted // paths this layout does not draw (the sheet's own furniture)
216
+ page.rowWords(row) // the words of a reflowed row, in reading order
217
+ page.wordRow(word) // the row a word landed on, or null
218
+ ```
219
+
220
+ Reflow keeps the page width and breaks the words onto rows of that width. A row is the page's
221
+ printed text block, not the whole sheet, so the print's side margins stay. `zoom` is the ink
222
+ size as a multiple of fit-to-width, so a row holds `blockW/zoom` page units and the page grows
223
+ taller than the viewport: `fitScale` stays 1 and the host scrolls `contentH`. Without `reflow`
224
+ the layout above is unchanged.
225
+
226
+ **Zoom 1 is the printed page.** A `reflow` of `zoom: 1` or less lays the page out as printed:
227
+ `reflowed` is 0, there are no rows, and every knob above — leading, `fillHeight`, the grid a
228
+ short page sits on, the crops, the aspect bound — behaves exactly as it does without `reflow`.
229
+ A reader returning to 1 gets the printed page back, whatever else they have set. Rows appear
230
+ once the ink is bigger than the print, and text flows from one line into the next only once
231
+ the rows are narrower than the printed lines.
232
+
233
+ The running head (surah name and juz) and the page number are printed outside the page box.
234
+ They belong to the sheet, and a reflowed page has none, so they are left out and their paths
235
+ are listed in `L.omitted` for renderers to skip. A host that wants them draws its own, from
236
+ `page.pageNumber` and the atlas.
237
+
238
+ What the engine holds to: a word is placed whole and is never reshaped or resized, an ayah
239
+ keeps the medallion that closes it on the same row, a sajdah line travels with its word, a
240
+ surah name or basmalah keeps a row of its own and text never flows across it, and no word
241
+ moves to another page.
242
+
243
+ **How the rows come out.** `breaks` picks where the words are cut into rows. `greedy` fills
244
+ each row until the next word does not fit, which is what leaves one row full and the next half
245
+ empty when a long word falls at a boundary. `even` weighs every row of a block together and
246
+ takes the cuts that leave them closest in width, costing a row the square of what it is left
247
+ short. `fitted`, the default, weighs a row by the width it can reach once its gaps have opened
248
+ as far as `maxStretch` allows, so a shortfall the spacing can absorb costs little and one it
249
+ cannot moves the cut; it also charges for the step between a row and the row above it, and for
250
+ the spacing spent, which makes a better cut worth more than a wider gap.
251
+
252
+ Over all 604 pages at zoom 1.3, 1.8 and 2.4, counting every row that does not end a block,
253
+ because a row that ends a block is short only because the text ran out:
254
+
255
+ | breaks | median fill | standard deviation | rows under 60% | 95th step | worst step |
256
+ |---|---:|---:|---:|---:|---:|
257
+ | greedy | 92.6% | 10.48 pp | 2.40% | 29.1 pp | 87.1 pp |
258
+ | even | 91.3% | 8.71 pp | 1.03% | 21.2 pp | 87.1 pp |
259
+ | fitted | 90.6% | 8.45 pp | 0.92% | 19.5 pp | 64.8 pp |
260
+
261
+ The step is the difference in fill between a row and the row above it. `fitted` adds 7 rows
262
+ over the whole mushaf and costs 0.05 ms a page on a warm layout: 0.25 ms against 0.20 ms at
263
+ the median, 0.32 ms against 0.27 ms at the 95th percentile.
264
+
265
+ **The zoom steps a reader gets.** `page.zoomLevels(spec)` returns the zoom each step of a
266
+ reader's zoom control lands on for this page. The rows only rearrange at certain zooms, so a
267
+ zoom that breaks a page well can sit beside one that breaks it badly. The engine searches a
268
+ band around each nominal step and returns the zoom whose rows come out best, counting how
269
+ short the rows are, how much each differs from the row above, how tall the page grows, and how
270
+ far the zoom is from the step asked for. Each returned zoom is at least 8% above the one
271
+ before it. Zoom 1, the printed page, is the step every control starts from and is not
272
+ returned.
273
+
274
+ Over all 604 pages, against the same three steps taken at their nominal zoom:
275
+
276
+ | steps | median fill | standard deviation | rows under 60% | 95th step |
277
+ |---|---:|---:|---:|---:|
278
+ | nominal ×1.4, ×1.8, ×2.2 | 91.1% | 7.54 pp | 0.47% | 17.5 pp |
279
+ | searched | 92.4% | 6.79 pp | 0.17% | 15.7 pp |
280
+
281
+ The zooms chosen for a page differ from its neighbours' by 2.2% to 3.3% at the median and at
282
+ most 12.4%, so the ink changes size a little from page to page. Widening the band finds better
283
+ rows and makes that change larger. The search takes about 40 ms a page. Its answer depends only on the page: the same zooms come
284
+ back at every viewport measured, from 360x1200 to 820x1180, so a generator can work the table
285
+ out once and ship it.
286
+
287
+ `page.zoomSteps(spec)` reads this page's steps out of the table the engine carries, which is
288
+ what a reader's zoom control uses: the search runs once, at release time, and the engine ships
289
+ the answer. A lookup takes about 0.02 ms against about 40 ms for the search.
290
+
291
+ The steps were chosen for the whole mushaf at once, weighing a page's own rows against how much
292
+ the ink changes size at the page turn, so no page turn changes the text size by more than 7%
293
+ and half change it by about 1%. They were chosen for the engine's own breaking and spacing: a
294
+ reader who changes a spacing knob keeps these steps and gets a page laid out with the settings
295
+ they asked for, so a spacing knob never resizes the text.
296
+
297
+ | steps | median fill | standard deviation | rows under 60% | 95th step |
298
+ |---|---:|---:|---:|---:|
299
+ | nominal ×1.4, ×1.8, ×2.2 | 90.5% | 7.49 pp | 0.47% | 17.1 pp |
300
+ | the shipped table | 91.3% | 6.90 pp | 0.21% | 15.8 pp |
301
+
302
+ `page.zoomLevels(spec, nominals, band)` runs the search itself, and
303
+ `page.zoomLevelCandidates(spec, nominal, band, floor)` returns every zoom it weighs with its
304
+ cost. These are what the generator uses; a reader does not need them.
305
+
306
+ `cargo run -p qvp-convert --release -- zoom-levels dist/pages crates/qvp-core/src/zoom_table.rs`
307
+ writes the table, and `--check` reports whether the committed one is still what the page data
308
+ and the layout produce. `scripts/check.sh gates` runs the check, and the table carries what it
309
+ was built from, so a change to the artwork or to the spacing defaults is caught rather than
310
+ left to drift.
311
+
312
+ `fill` is `centred` (the default), `ragged` (the row starts at the right margin) or `justified`
313
+ (gaps stretch to both margins, the row that ends a block excepted). `gaps` is `uniform` (the
314
+ default) or `printed`, and `wordGap` scales whichever it picked.
315
+
316
+ `relax` opens a row that still comes out short. Each row is opened a share of the way towards
317
+ the widest row within three rows of it, because those are the rows a reader sees beside it and
318
+ compares it with. The window slides, so a row is measured against its own neighbours rather
319
+ than against whichever group of rows it fell in, and it holds a fixed number of rows, so the
320
+ same page is set the same way on any screen. The row is never justified by this, and
321
+ `maxStretch` caps how far a gap may open, as a multiple of the air the page keeps between two
322
+ words. That cap is why a row of few words opens less than a row of many: fewer gaps, less room,
323
+ before the words stand apart. Half way (`relax: 0.5`) is where a page comes out most even —
324
+ past it only the rows with many gaps keep moving, and the page reads less even, not more.
325
+
326
+ **Words are spaced by the air between their strokes.** Not by the distance between their
327
+ boxes: the calligraphy interlocks one word's opening stroke with the one before it, so the
328
+ boxes of `سَاحِرٌ` and `كَذَّابٌ` overlap by 12 page units while the strokes stay 5 apart. Space
329
+ that pair by its boxes and it comes apart, leaving a hole where the strokes used to interleave.
330
+
331
+ The engine traces every word's outline into bands counted from its line's baseline
332
+ (`defaults::SLICES_PER_LINE`, 96 of them) and measures the narrowest distance between two words
333
+ over the bands they share: `page.wordsClearance(a, b, dx)`, with
334
+ `page.shiftForClearance(a, b, air)` for the shift that leaves a given air. The outline is
335
+ walked, not its points: a curve's control points stand far apart, so bands between them would
336
+ read as empty and a pair would be measured against ink that is not facing it. The tracing waits
337
+ until a reflow asks for it, because loading a page is otherwise three times faster.
338
+
339
+ The trace holds a word's marks as well as its letters, because a mark reaching past the letters
340
+ still has to clear the next word, and a band knows the height it reaches at. A medallion is
341
+ traced with the word it closes, so the next word is spaced from the medallion.
342
+
343
+ The shape decides what a pair may do: a final `م` written round lets the next word tuck under it
344
+ in 83% of pairs, the same letter written with a tail in 47%, and the engine reads that off the
345
+ ink rather than off a list of letters. Across the mushaf the print leaves ink clear of ink in
346
+ 6,699 of 6,700 neighbouring pairs, and a reflowed row holds to that: of 37,788 placed pairs the
347
+ only ink that meets is the `ٱلرَّحْمَٰنِ ٱلرَّحِيمِ` the print draws as one piece. A reflowed row leaves every pair the air the print keeps on
348
+ that page, which across the 604 pages holds the placed air to a median of 4.2 page units and a
349
+ spread of 0.5, against a box gap that varies by 2.7.
350
+
351
+ Marks are not in this measure. A word's box holds them, two words in three carry ink that
352
+ reaches past their letters, and spacing to those would set such pairs tighter than the rest.
353
+
354
+ **One word drawn inside another.** Strokes almost never meet: 10 of this mushaf's 68,612
355
+ neighbouring pairs. Three of those are `ٱلرَّحْمَٰنِ ٱلرَّحِيمِ`, where `ٱلرَّحِيمِ` is set in the bowl
356
+ of `ٱلرَّحْمَٰنِ`, overlapping by 19 to 26 page units where the next deepest reaches 3.8. Such a
357
+ pair keeps the place the print gave it and justification does not stretch it; only a row break
358
+ between the two sets them as ordinary words. `page.wordsInterlock(a, b)` reports it, and
359
+ `defaults::INTERLOCK_DEPTH` is where the line is drawn.
360
+
361
+ **Where each mark goes.** What a mark is for decides which word it travels with, and the rows
362
+ then hold to what a reader expects of the page:
363
+
364
+ - The medallion that closes an ayah runs with that ayah's last word, so no row opens with it.
365
+ Where the next word shares its row, the medallion is set midway between the two, measured as
366
+ the air on either side, rather than at the distance the print gave it on a line it is no
367
+ longer on.
368
+ - A sajdah mark closes the word before it and runs with that word, so no row opens with it
369
+ either. It is followed by the medallion of its own ayah, and the two are read as one sign, so
370
+ the engine gives them the same word whatever their geometry says.
371
+ - A rubu_al_hizb opens a division, so it runs with the word it opens and no row closes with it.
372
+
373
+ A mark printed inside the text block claims its own room on the row and keeps the distance the
374
+ print put between it and its word. In this mushaf every rubu_al_hizb and sajdah mark is printed
375
+ in the text. A mark printed out in the sheet's margin, where a reflowed row has no room, keeps
376
+ the margin and the side the print gives it and follows its word down to the new row.
377
+ The sajdah line is drawn over the words it marks, wherever they now are: when the span breaks
378
+ across rows, the stroke is drawn once per row, stretched along x to cover that row's part of
379
+ the span, and carrying the same shift as those words so it keeps the height above them the
380
+ print gave it. It is the one piece of ink the layout stretches, and only along x. A word is
381
+ always placed with `kx == ky == 1`.
382
+
383
+ One decoration can hold more than one mark: the sajdah of 16:50 on page 272 is a single
384
+ record holding the stroke over its words *and* the sign printed in the margin. They are placed
385
+ apart — the sign keeps its size and is drawn once, beside the word it stands next to — so a
386
+ path's group follows the job it does, not only the record it belongs to.
387
+
388
+ **What a renderer draws.** `page.layoutDrawList(bandTop, bandBottom)` is every drawing this
389
+ layout makes, in drawing order: `{path, placement}` pairs into `page.layoutPlacements()`. A
390
+ path the layout leaves out — sheet furniture, or a native surah frame once the page reflows — never appears, a sajdah
391
+ line stroked over the two rows its words landed on appears twice, and a printed page hands back
392
+ each path under its own line. So one loop draws any page, and no host has to know which case it
393
+ is in:
394
+
395
+ ```js
396
+ for (const {path, placement} of drawList) { setTransform(places[placement]); fill(paths[path]); }
397
+ ```
398
+
399
+ The band holds it to a part of the laid-out page, in viewport px, as `L.slots` and every
400
+ `…View` answer are; a band no taller than nothing means the whole page.
401
+
402
+ A reflowed page is several screens of ink, so a renderer draws it into an image once and lets
403
+ the reader scroll that image, rather than drawing again on every frame. Both reference
404
+ renderers keep the whole page when it fits their ink budget — about 18 MB for a page of this
405
+ muṣḥaf at its largest step on a phone — and fall back to a band two screens tall when it does
406
+ not, which is redrawn only when the reader scrolls out of it. That is what the band argument is
407
+ for. On iOS the image is the content of a `UIScrollView`, so the scroll itself — the
408
+ deceleration, the rubber band at the ends, the indicator — is the platform's own.
409
+
410
+ `reflowMaxZoom(spec)` is the largest zoom at which every word still fits a row (2.44 on page
411
+ 121, whose longest word is 121 of 345 page units). Clamp a pinch to it; past it the engine
412
+ still lays the page out, but ink wider than a row runs past the margin.
413
+
414
+ **Groups.** A reflowed page shifts words, not lines, so `L.lineDy[line]` no longer places a
415
+ path. `L.groups` holds one `{dx, dy, kx, ky}` per group and `L.pathGroup[i]` the group of path
416
+ `i`: draw the path's points at `(kx·p.x + dx, ky·p.y + dy)` in page units, then apply `scale`
417
+ and the offsets as before. Without reflow there is one group per printed line, `dx` is 0, both
418
+ scales are 1 and `dy` is that line's `lineDy`, so one code path serves both. `slots[]` holds
419
+ the rows. `L.repeats` lists paths to draw a second time (`{firstPath, nPaths, dx, dy, kx,
420
+ ky}`), which is how one sajdah line covers two rows; a renderer draws them after the main
421
+ pass, in the same colour the path already has.
422
+
423
+ ## The reader's pan and zoom
424
+
425
+ ```js
426
+ let view = {scale: 1, offsetX: 0, offsetY: 0};
427
+ view = engine.viewZoomAbout(view, focalX, focalY, factor); // a pinch, clamped
428
+ view = engine.viewPan(view, dx, dy); // a drag
429
+ view = engine.viewClamp(view, L.contentW, L.contentH, viewportW, viewportH);
430
+ view = page.viewAnchor(view, word, nx, ny, toX, toY, viewportW, viewportH);
431
+ const {x, y} = page.viewToLayout(view, touchX, touchY); // a touch → the laid-out page
432
+ engine.viewSwipe(dx, dy, vx, vy) // +1, -1 or 0
433
+ ```
434
+
435
+ A layout places the page's ink; the reader pans and zooms on top of it. That arithmetic is the
436
+ engine's, so the platforms cannot drift apart: a host reads the gesture from its own recognizer
437
+ and asks for the view it produces. `viewZoomAbout` keeps the point under two fingers under
438
+ them, `viewClamp` centres an axis the content does not fill and covers the viewport on one it
439
+ overflows.
440
+
441
+ `viewAnchor` is the one reflow needs. A relayout moves a word to another row, so a host cannot
442
+ hold the page still by remembering pixels: it remembers the word under the fingers and the
443
+ point inside it (`nx`, `ny` from 0 to 1) and asks for the view that brings that point back to a
444
+ place on the screen. A word that moved rows cannot hold both axes on a page of fixed width —
445
+ its new row decides its x — so the vertical place is the one this keeps.
446
+
447
+ ## The reader's zoom control
448
+
449
+ ```js
450
+ let zoom = {}; // stepped, on the printed page
451
+ const c = page.zoomPinch(spec, zoom, view, factor, x, y); // one frame of a pinch
452
+ zoom = c.zoom; view = c.view; // c.relaid: the page moved under it
453
+ spec = page.zoomSpec(spec, zoom); // the spec to lay out and hit-test with
454
+ page.zoomToStep(spec, zoom, 2, view); // a size button, a double tap, a reset
455
+ page.zoomMode(spec, zoom, 'continuous'); // another policy, same size
456
+ ```
457
+
458
+ A pinch means two different things on a muṣḥaf page. It can scale the printed page, the way it
459
+ scales a photograph, and leave the reader panning across lines that no longer fit. Or it can
460
+ ask for bigger ink at the same page width, which is reflow: fewer words to a row, the rest
461
+ moved down. Which of those it means, where it lands, when it commits and what holds the
462
+ reader's place across the relayout is one policy, and it is the engine's — a host reads the
463
+ gesture from its own recognizer and asks what it produced.
464
+
465
+ `factor` is the distance between the fingers against their distance when they went down: the
466
+ whole gesture every time, not the change since the last frame. There is no call when the
467
+ fingers lift.
468
+
469
+ **Three modes.** `stepped` is what a reader gets unless a host says otherwise: the pinch lands
470
+ on one of the page's own steps (`zoomSteps`) and the page breaks its rows again at that size,
471
+ so a reader never ends up at a zoom that breaks the page badly. `continuous` reflows to
472
+ whatever the fingers ask for, between the printed page and `reflowMaxZoom`, quantized to 0.01
473
+ so a pinch never asks for a layout that moves no word. `magnify` scales the laid-out page and
474
+ never changes a row. A zeroed control is `stepped` on the printed page, so a host that sets
475
+ nothing gets the reader's behaviour.
476
+
477
+ **Where a step commits.** Two neighbouring steps meet at their geometric mean, because a zoom
478
+ is a ratio and not a distance, and the commit is held off by 3% either way so a finger resting
479
+ on the boundary does not flicker between two layouts. The steps are at least 1.08 apart
480
+ (`zoomLevels`), and `1.03² < 1.08`, which is what keeps the two thresholds of one step clear of
481
+ each other. The commit happens mid-gesture, one step per crossing: the reflow is the loud
482
+ event, and a pinch that does nothing until release reads as broken.
483
+
484
+ **Holding the reader's place.** On every commit the engine hit-tests the point between the
485
+ fingers against the layout in hand, lays the page out at the new size, and anchors that point
486
+ back under them with `viewAnchor`. The word is found again on each commit rather than
487
+ remembered from the start of the gesture: `viewAnchor` can only promise the vertical place, so
488
+ a remembered word slides sideways out from under the fingers while the word found now is the
489
+ one they are on. The host never sees the word.
490
+
491
+ The control owns one field of the spec and leaves the reader's spacing knobs alone, so
492
+ `zoomSpec` is what a host lays out, draws and hit-tests with. `zoomPinch` and `zoomToStep` lay
493
+ the page out themselves when they commit, and the wrapper reads that layout back — a host does
494
+ not lay out again after `relaid`.
495
+
496
+ ## Where geometry is answered
497
+
498
+ Every call named `…View` answers in viewport pixels through the current layout:
499
+ `wordBoundsView`, `wordBandsView`, `hitAreasView`, `ayahMarksView`, `highlightBoxesView`,
500
+ `maskBoxesView`, `hitTestView`, `hitTestExactView`, and `L.slots`. A host drawing its own
501
+ overlay uses these and never places ink itself.
502
+
503
+ The calls without `View` answer in page units, as the print has them: `wordBands`, `hitAreas`,
504
+ `lineBands`, `ayahMarks`, `hitTest`, `hitTestExact`, `cropBounds` and `cropSvg`. They describe
505
+ the printed page, which is what a crop exports and what a reflowed page no longer looks like.
506
+ `hitAreasView` is not `hitAreas` transformed: a row's words have other neighbours than a
507
+ printed line's, so the partition is computed again.
120
508
 
121
509
  ## Styles
122
510
 
@@ -125,7 +513,7 @@ const h = page.style(Sel.wordMark(w, 1), '#1a73e8', {ms: 200}); // the 2nd d
125
513
  page.styleTarget('2:255', '#0a7d32', {layer: LAYER.HIGHLIGHT});
126
514
  page.hide(Sel.kind('mark')); // reading view without tashkil
127
515
  page.theme({ink: '#e8e4dc', diacritics: '#7fb0e8', dots: '#ff8a80', ayahMark: '#b8860b', ms: 300});
128
- page.restyle(h, '#ff0000', 100); page.unstyle(h); page.setDefaultInk('#231f20');
516
+ page.recolorStyle(h, '#ff0000', 100); page.removeStyle(h); page.setDefaultColor('#231f20');
129
517
  ```
130
518
 
131
519
  Rules live in **layers** (`LAYER.BASE 0`, `THEME 10`, `HIGHLIGHT 50`, `SELECTION 60`,
@@ -146,7 +534,7 @@ function frame(now) {
146
534
 
147
535
  `Renderer.draw()` calls `page.buildPaths()` for you, and `buildPaths()` memoises, so a
148
536
  consumer using the supplied renderer never calls it directly. Call it yourself only when
149
- you write your own renderer on top of `paint()`/`styled()`: it turns the page's op/point
537
+ you write your own renderer on top of `colors()`/`styledPaths()`: it turns the page's op/point
150
538
  arrays into the path objects those lists index, and drawing without it has nothing to
151
539
  fill.
152
540
 
@@ -160,65 +548,206 @@ ReferenceError: Path2D is not defined
160
548
  ```
161
549
 
162
550
  Everything above drawing is pure wasm and runs anywhere: loading, `words`/`ayahs`/`lines`,
163
- hit-testing, `layout()`, styles, `paint()` and `styled()` themselves. So a server-side or
551
+ hit-testing, `layout()`, styles, `colors()` and `styledPaths()` themselves. So a server-side or
164
552
  worker consumer can use the engine for everything except the final fill, and should stop
165
553
  at the display list. The native bindings build paths against their own platform types
166
554
  (`CGPath`, `android.graphics.Path`, `ui.Path`) and have no such restriction.
167
555
 
168
- `paint()` is the full display list (a colour per path); `styled()` lists only the
556
+ `colors()` is the full display list (a colour per path); `styledPaths()` lists only the
169
557
  paths that differ from the default ink, which is what the cached-base-layer renderer
170
- repaints. `highlightBoxes()` and `maskBoxes()` are viewport-px rectangles.
558
+ repaints. `highlightBoxesView()` and `maskBoxesView()` are viewport-px rectangles.
171
559
 
172
560
  ## Highlights
173
561
 
174
562
  ```js
175
563
  const h = page.highlight('2:255', {mode: 'both', ink: '#0a7d32', band: '#0a7d3224',
176
- height: 'pitch', padX: 1.2, radius: 1.5, seam: 0.25, ms: 250});
177
- page.rehighlight(h, '2:256'); // the band slides to the new words, ink cross-fades
564
+ height: 'lineSpacing', padX: 1.2, radius: 1.5, seam: 0.25, ms: 250});
565
+ page.moveHighlight(h, '2:256'); // the band slides to the new words, ink cross-fades
178
566
  page.restyleHighlight(h, {...}); // recolour in place
179
- page.unhighlight(h); // fades out, then disappears
567
+ page.removeHighlight(h); // fades out, then disappears
180
568
  ```
181
569
 
182
570
  `mode` is `ink`, `band` or `both`. A band is **one path per highlight** covering every
183
571
  printed line the words occupy, with a `seam` overlap so a six-line ayah reads as one
184
- shape and not six stripes; height is the line pitch or the words' ink. Use one handle
185
- and `rehighlight` for word-by-word following.
572
+ shape and not six stripes; height is the line line spacing or the words' ink. Use one handle
573
+ and `moveHighlight` for word-by-word following.
186
574
 
187
575
  ## Selection
188
576
 
189
577
  `select(anchor, focus)` snaps to whole words; `selection()`, `selectionText(form, withCitation)`.
190
- Draw the band with a highlight in `LAYER.SELECTION`; see `web/app.js` for drag-to-select.
578
+ Draw the band with a highlight in `LAYER.SELECTION`; see `web/example/app.js` for drag-to-select.
191
579
 
192
580
  ## Memorisation
193
581
 
194
582
  ```js
195
- page.mask('2:255', 'hide' | 'block' | 'blur'); page.revealNext(1); page.hideBack(1);
196
- page.revealWord(i); page.revealAll(); page.hideAll(); page.unmask(); page.maskHidden()
583
+ page.mask('2:255', 'hide' | 'block' | 'blur'); page.unmaskNext(1); page.maskBack(1);
584
+ page.unmaskWord(i); page.unmaskAll(); page.maskAll(); page.unmask(); page.maskHidden()
197
585
  const steps = page.revealStart({lit: 2, byAyah: false, grey: '#c9c4b8', ink: '#231f20', ayahMarks: true, ms: 150});
198
586
  page.revealGoto(at); page.revealStop();
199
587
  ```
200
588
 
201
589
  `hide` keeps the page's shape (ink alpha 0). `block`/`blur` keep the ink and hand the
202
- host `maskBoxes()` to draw over. The greyed-page reveal lights a window of `lit` steps
590
+ host `maskBoxesView()` to draw over. `qvp_mask_transition(ms)` (iOS `maskTransition`) fades
591
+ `hide` words in and out on the engine clock instead of switching at once — the ink keeps
592
+ its colour and only its alpha moves; `unmask()` resets it like the other mask options.
593
+ The greyed-page reveal lights a window of `lit` steps
203
594
  ending at `at`; a medallion lights with the ayah it closes.
204
595
 
205
596
  ## Recitation
206
597
 
207
598
  `reciteMap(s, a, nSegments)` returns the words to pair with `nSegments` timings, or
208
599
  `null` when the counts disagree — then follow the ayah whole rather than drift.
209
- Drive the highlight with `rehighlight(h, T.word(i))`.
600
+ Drive the highlight with `moveHighlight(h, T.word(i))`.
210
601
 
211
602
  ## Crop and export
212
603
 
213
- `cropBox(target, {pad, keepAyahMarks})`; `cropSvg(target, {pad, keepAyahMarks, background})`
604
+ `cropBounds(target, {pad, keepAyahMarks})`; `cropSvg(target, {pad, keepAyahMarks, background})`
214
605
  returns a standalone SVG string with the current colours (masks, themes and highlights'
215
606
  ink applied). The medallion is kept only when the whole ayah is inside the crop.
216
607
 
217
608
  ## Atlas (cross-page)
218
609
 
219
610
  `atlas.pageOf(s,a)`, `pageRange(page)`, `surah(n)`, `surahs()`, `pageOfSurah(n)`,
220
- `juz(n)/hizb(n)/rubuAlHizb(n)` → `{surah, ayah, page}`, `juzAt(s,a)`, `divisionAt(kind, s, a)`,
221
- `pagesOfJuz(n)`, `findSurah('cow' | 'البقرة' | '2')`.
611
+ `juz(n)/hizb(n)/rubuAlHizb(n)` → `{surah, ayah, page}`, `juzOf(s,a)`, `divisionOf(division, s, a)`,
612
+ `pagesOfJuz(n)`, `searchSurahs('cow' | 'البقرة' | '2')`.
613
+
614
+ ## Names
615
+
616
+ ```js
617
+ engine.names('mark') // every name of a table, index = id: 'mark' | 'kind' | 'family' |
618
+ // 'category' | 'decoration' | 'division' | 'place'
619
+ engine.name('decoration', 0) // 'ayah-mark'
620
+ engine.nameId('mark', 'shaddah') // 7; 255 when the table has no such name
621
+ engine.nameCount('mark') // 36
622
+ ```
623
+
624
+ The engine holds every name table. A wrapper reads them from the engine when it starts
625
+ (`Sel.mark('shaddah')`, `Sel.decoration('ayah-mark')` and the rest resolve through them) and
626
+ carries no table of its own; a name the engine does not have resolves to 255, never to 0.
627
+
628
+ ## Every symbol
629
+
630
+ One row per C function, with its spelling in the reference wrapper (`web/qvp.js`; the other
631
+ wrappers use the same names in their own casing) and what it does. `docs/API-PARITY.md`
632
+ says which wrapper binds which.
633
+
634
+ | section | C | reference wrapper | what it does |
635
+ |---|---|---|---|
636
+ | memory | `qvp_alloc` | engine internal | Allocate `len` bytes inside the engine's memory, for hosts without their own allocator (wasm). |
637
+ | memory | `qvp_dealloc` | engine internal | Free bytes from `qvp_alloc`. |
638
+ | page | `qvp_page_load` | `engine.loadPage(bytes)` | Decode a page file into a page handle; null on a malformed file; the bytes are copied. |
639
+ | page | `qvp_page_free` | `page.free()` | Free the page handle. |
640
+ | page | `qvp_page_info` | `page.width`, `.height`, `.page`, `.nLines`, `.nAyahs`, `.nWords`, `.nPaths`, `.nDecorations` | Return the page's dimensions and element counts. |
641
+ | page | `qvp_geometry` | `page.paths`, `page.buildPaths()` | Return the outline streams and the per-path table a renderer draws from; they live as long as the page. Ink outside the page's box gets no outline, so a host draws the page and not what the artwork left beyond it. |
642
+ | page | `qvp_word_info` | `page.words[i]` | Return one word: key, line, ayah fragment, bounds, text and its path range. |
643
+ | page | `qvp_word_form` | `page.wordForm(i, form)` | Return one of a word's text forms; the derived forms need the words sidecar. |
644
+ | page | `qvp_ayah_info` | `page.ayahs[i]` | Return one ayah fragment: key, fragment index and count, flags, word range, ayah-mark decoration, bounds. |
645
+ | page | `qvp_line_info` | `page.lines[i]` | Return one printed line: number, header flag, word range, bounds, band and centre. |
646
+ | page | `qvp_decoration_info` | `page.decorations[i]` | Return one decoration: its `QVP_DECORATION_*` value, key, line, bounds, text and its path range. |
647
+ | page | `qvp_find_word` | `page.findWord(surah, ayah, word)` | Return the page index of a word by its key, or −1 when the word is not on this page. |
648
+ | page | `qvp_target_words` | `page.targetWords(target)` | Expand a target (page, word, ayah, range, line, surah) into word indices in reading order. |
649
+ | page | `qvp_page_line_spacing` | `page.lineSpacing` | Return the printed line spacing of this page, in page units. |
650
+ | page | `qvp_page_grid` | `page.grid` | Return the grid the page is laid out inside: the mushaf's line count and the printed line spacing. |
651
+ | metadata | `qvp_surah_count` | `page.surahs().length` | Return how many surahs have text on this page. |
652
+ | metadata | `qvp_surah_at` | `page.surahs()[i]` | Return the i-th surah on the page: number, ayah count, banner and basmalah flags, place, names. |
653
+ | metadata | `qvp_divisions` | `page.divisions()` | Return the juz, hizb, nisf and rubu_al_hizb divisions that start on this page. |
654
+ | metadata | `qvp_ayah_marks` | `page.ayahMarks()` | Return the ayah-mark medallions on the page with their centres and radii. |
655
+ | metadata | `qvp_rosettes` | `page.rosettes()` | Return the drawn hizb rosettes with the division numbers they mark. |
656
+ | metadata | `qvp_sajdahs` | `page.sajdahs()` | Return the sajdah signs on the page. |
657
+ | metadata | `qvp_ayah_keys` | `page.ayahKeys()` | Return the ayah keys on the page in reading order, packed as surah in the high 16 bits and ayah in the low 16. |
658
+ | metadata | `qvp_ayah_word_count` | `page.ayahWordCount(surah, ayah)` | Return how many of an ayah's words are on this page and whether the ayah is complete here. |
659
+ | metadata | `qvp_recite_map` | `page.reciteMap(surah, ayah, nSegments)` | Map an ayah's words onto `nSegments` recitation timings, or −1 when the counts disagree. |
660
+ | metadata | `qvp_word_label` | `page.wordLabel(i)` | Return an accessibility label for a word. |
661
+ | metadata | `qvp_ayah_label` | `page.ayahLabel(i)` | Return an accessibility label for an ayah fragment. |
662
+ | text and search | `qvp_text` | `page.text(target, form, wordSep, lineSep)` | Return the text of a target (the page by default) in one form, with the given separators. |
663
+ | text and search | `qvp_search` | `page.search(query, …)` | Search the page's words in one text form with Arabic normalisation; returns matches with their word index. |
664
+ | text and search | `qvp_arabic` | `engine.strip()`, `.fold()`, `.normalize()`, `.looseKey()`, `.searchKey()` | Apply one of the Arabic text transforms of the search fold to a string. |
665
+ | text and search | `qvp_citation` | `page.citation(words)` | Return a citation such as `2:255-257, 3:1` for a word list. |
666
+ | text and search | `qvp_attach_words` | `page.attachWords(json)` | Attach the words sidecar's text forms; returns the number of words updated, −1 on bad JSON. |
667
+ | text and search | `qvp_has_form` | `page.hasForm(form)` | Return whether a text form is available (the derived forms need the sidecar). |
668
+ | hit testing | `qvp_hit_test_exact` | `page.hitTestExact(x, y)` | Return the word, path or decoration whose exact outline contains a point in page units. |
669
+ | hit testing | `qvp_hit_test_exact_view` | `page.hitTestExactView(viewX, viewY)` | The same for a point in viewport pixels through the current layout. |
670
+ | hit testing | `qvp_hit_test` | `page.hitTest(x, y, options)` | Gap-aware: resolve a point to the nearest word by line band and gap bias, with the distance and whether the hit was exact. |
671
+ | hit testing | `qvp_hit_test_view` | `page.hitTestView(viewX, viewY, options)` | The same for viewport pixels. |
672
+ | hit testing | `qvp_line_bands` | `page.lineBands()` | Return every line's vertical band in page units. |
673
+ | hit testing | `qvp_hit_areas` | `page.hitAreas(gapBias)` | Return the gap-aware rectangle of every word, a partition of each line with no dead zone. |
674
+ | layout | `qvp_layout` | `page.layout(spec)` | Lay the page out for a viewport: scale, per-line shifts, slots, content size and the fit transform. |
675
+ | layout | `qvp_layout_line_spacing_to_fill` | `page.layoutLineSpacingToFill(spec, max)` | Return the `lineSpacing` multiplier that fills the padded viewport of a spec when fitted to width. |
676
+ | layout | `qvp_layout_wasted_fraction` | `page.layoutWastedFraction(spec)` | Return the share of the padded viewport of a spec left empty when the page is fitted to width. |
677
+ | layout | `qvp_layout_printed_height` | `page.layoutPrintedHeight(spec)` | Return the page's height laid out for a spec at the printed pitch, in viewport px: `contentH` before fill-height adds any leading. |
678
+ | layout | `qvp_word_bounds_view` | `page.wordBoundsView(i)` | Return a word's bounds in viewport pixels through the current layout. |
679
+ | styles | `qvp_style_add` | `page.style(selector, colour, ms, layer)` | Add a colour rule for a selector on a layer; returns a handle, 0 for a bad selector. |
680
+ | styles | `qvp_style_add_target` | `page.styleTarget(target, colour, ms, layer)` | Add a colour rule for a target's words. |
681
+ | styles | `qvp_style_remove` | `page.removeStyle(handle)` | Remove a rule by handle; returns how many rules were removed. |
682
+ | styles | `qvp_style_recolor` | `page.recolorStyle(handle, colour, ms)` | Change a rule's colour in place, with a transition. |
683
+ | styles | `qvp_style_clear` | `page.clearStyles()` | Remove every rule. |
684
+ | styles | `qvp_style_clear_layer` | `page.clearLayer(layer)` | Remove every rule on one layer. |
685
+ | styles | `qvp_style_default_color` | `page.setDefaultColor(colour)` | Set the ink colour a path has when no rule applies. |
686
+ | styles | `qvp_style_hide` | `page.hide(selector)` | Add an alpha-0 rule on the top layer; returns its handle. |
687
+ | styles | `qvp_theme` | `page.theme(theme)` | Apply a theme (ink, mark families, headers) as one rule set with one handle. |
688
+ | styles | `qvp_style_handles` | `page.styleHandles()` | Return the handles of every live rule. |
689
+ | clock and colours | `qvp_tick` | `page.tick(nowMs)` | Advance transitions to a time; returns 1 while anything is still animating. |
690
+ | clock and colours | `qvp_colors` | `page.colors()` | Return the current colour of every path, mid-transition included. |
691
+ | clock and colours | `qvp_styled_paths` | `page.styledPaths()` | Return the (path, colour) pairs whose colour differs from the default ink. |
692
+ | clock and colours | `qvp_color_of` | `page.colorOf(path)` | Return one path's current colour. |
693
+ | highlights | `qvp_highlight_add` | `page.highlight(target, style)` | Add a highlight (ink, band or both) for a target; returns a handle. |
694
+ | highlights | `qvp_highlight_move` | `page.moveHighlight(handle, target)` | Move a highlight to another target: the band slides, the ink cross-fades. |
695
+ | highlights | `qvp_highlight_restyle` | `page.restyleHighlight(handle, style)` | Change a highlight's style in place. |
696
+ | highlights | `qvp_highlight_remove` | `page.removeHighlight(handle)` | Fade a highlight out and remove it. |
697
+ | highlights | `qvp_highlight_clear` | `page.clearHighlights()` | Remove every highlight. |
698
+ | highlights | `qvp_highlight_handles` | `page.highlightHandles()` | Return the handles of every live highlight. |
699
+ | highlights | `qvp_highlight_words` | `page.highlightWords(handle)` | Return the words a highlight covers. |
700
+ | highlights | `qvp_highlight_boxes_view` | `page.highlightBoxesView()` | Return every highlight's band rectangles in viewport pixels; draw each id as one nonzero path behind the ink. |
701
+ | highlights | `qvp_word_bands` | `page.wordBands(words, options)` | Return band rectangles for an arbitrary word list, in page units. |
702
+ | selection | `qvp_select` | `page.select(anchor, focus)` | Select the whole words between two word indices; `QVP_NONE` clears. |
703
+ | selection | `qvp_selection` | `page.selection()` | Return the selected word indices. |
704
+ | selection | `qvp_selection_text` | `page.selectionText(form, citation)` | Return the selected text, with its citation when asked. |
705
+ | memorisation | `qvp_mask` | `page.mask(target, mode)` | Mask a target's words: hide, block or blur. |
706
+ | memorisation | `qvp_mask_from` | `page.maskFrom(word, mode)` | Mask every word from a word index to the end of the page. |
707
+ | memorisation | `qvp_mask_options` | `page.maskOptions(options)` | Set the block colour, padding, radius and direction of the mask. |
708
+ | memorisation | `qvp_unmask_next` | `page.unmaskNext(n)` | Unhide the next `n` masked words; returns how many are still hidden. |
709
+ | memorisation | `qvp_mask_back` | `page.maskBack(n)` | Re-hide the last `n` revealed words. |
710
+ | memorisation | `qvp_unmask_word` | `page.unmaskWord(i)` | Unhide one word. |
711
+ | memorisation | `qvp_mask_word` | `page.maskWord(i)` | Hide one word again. |
712
+ | memorisation | `qvp_unmask_all` | `page.unmaskAll()` | Unhide every masked word. |
713
+ | memorisation | `qvp_mask_all` | `page.maskAll()` | Hide every word in the mask again. |
714
+ | memorisation | `qvp_unmask` | `page.unmask()` | Remove the mask. |
715
+ | memorisation | `qvp_mask_hidden` | `page.maskHidden()` | Return the words currently hidden. |
716
+ | memorisation | `qvp_mask_words` | `page.maskWords()` | Return the words in the mask's scope. |
717
+ | memorisation | `qvp_mask_boxes_view` | `page.maskBoxesView()` | Return the block or blur rectangles in viewport pixels for the host to draw. |
718
+ | memorisation | `qvp_reveal_start` | `page.revealStart(options)` | Grey the page and light a moving window of steps; returns the step count. |
719
+ | memorisation | `qvp_reveal_goto` | `page.revealGoto(at)` | Light the window ending at a step; −1 when nothing is lit yet. |
720
+ | memorisation | `qvp_reveal_position` | `page.revealPosition()` | Return the current step, −2 when no reveal is running. |
721
+ | memorisation | `qvp_reveal_step_count` | `page.revealStepCount()` | Return the number of steps in the running reveal. |
722
+ | memorisation | `qvp_reveal_step_of` | `page.revealStepOf(i)` | Return the step that lights a word. |
723
+ | memorisation | `qvp_reveal_stop` | `page.revealStop()` | End the reveal and restore the page. |
724
+ | crop | `qvp_crop_bounds` | `page.cropBounds(target, options)` | Return the crop rectangle of a target with padding, and whether the ayah mark is inside it. |
725
+ | crop | `qvp_crop_svg` | `page.cropSvg(target, options)` | Return a standalone SVG of a target with the current colours applied. |
726
+ | atlas | `qvp_atlas_load` | `engine.loadAtlas(bytes)` | Decode the atlas file; null on a malformed file. |
727
+ | atlas | `qvp_atlas_free` | `atlas.free()` | Free the atlas. |
728
+ | atlas | `qvp_atlas_page_of` | `atlas.pageOf(surah, ayah)` | Return the page an ayah is on. |
729
+ | atlas | `qvp_atlas_page_range` | `atlas.pageRange(page)` | Return the first and last ayah keys of a page. |
730
+ | atlas | `qvp_atlas_page_count` | `atlas.pageCount()` | Return how many pages the atlas covers. |
731
+ | atlas | `qvp_atlas_surah_count` | `atlas.surahs()` | Return how many surahs the atlas covers. |
732
+ | atlas | `qvp_atlas_surah` | `atlas.surah(n)` | Return a surah by number: first page, ayah count, place, names. |
733
+ | atlas | `qvp_atlas_surah_at` | `atlas.surahs()[i]` | Return the i-th surah record. |
734
+ | atlas | `qvp_atlas_division` | `atlas.juz(n)`, `.hizb(n)`, `.nisf(n)`, `.rubuAlHizb(n)` | Return where a division starts: surah, ayah, page. |
735
+ | atlas | `qvp_atlas_division_of` | `atlas.juzOf(surah, ayah)`, `.divisionOf(division, surah, ayah)` | Return the number of the division that contains an ayah. |
736
+ | atlas | `qvp_atlas_pages_of_juz` | `atlas.pagesOfJuz(n)` | Return the first and last page of a juz. |
737
+ | atlas | `qvp_atlas_search_surahs` | `atlas.searchSurahs(text)` | Search the surah names in Arabic, Latin or English, or by number. |
738
+ | atlas | `qvp_atlas_json` | `atlas.json()` | Return the atlas as JSON. |
739
+ | names | `qvp_name_count` | `engine.nameCount(table)` | Return how many ids a name table has. |
740
+ | names | `qvp_name` | `engine.name(table, id)` | Return the name of an id in a table; empty outside the table. |
741
+ | names | `qvp_name_id` | `engine.nameId(table, name)` | Return the id of a name in a table; 255 when the table has no such name. |
742
+ | names | `qvp_mark_name` | `engine.markName(id)` | Return a mark's name. |
743
+ | names | `qvp_family_name` | `engine.familyName(id)` | Return a mark family's name. |
744
+ | names | `qvp_kind_name` | `engine.kindName(id)` | Return a path kind's name. |
745
+ | names | `qvp_category_name` | `engine.categoryName(id)` | Return a mark category's name. |
746
+ | names | `qvp_mark_from_name` | `engine.markFromName(name)` | Return a mark's id by name; 255 when unknown. |
747
+ | names | `qvp_mark_category` | `engine.markCategory(id)` | Return the category a mark belongs to. |
748
+ | names | `qvp_version` | `engine.version()` | Return the engine version as a string, `0.2.0`. |
749
+ | names | `qvp_format_version` | `engine.formatVersion()` | Return the page format version the engine reads. |
750
+ | names | `qvp_engine_name` | `engine.engineName()` | Return the engine's name, `qvp`. |
222
751
 
223
752
  ## C ABI notes
224
753