@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/LICENSES/MIT.txt +21 -0
- package/README.md +78 -3
- package/docs/API.md +586 -57
- package/docs/LITE-PASSAGES.md +54 -0
- package/package.json +10 -2
- package/web/index.mjs +1 -1
- package/web/lite-passage-geometry.mjs +174 -0
- package/web/lite-passage.mjs +199 -0
- package/web/lite-path.mjs +16 -0
- package/web/lite.mjs +465 -0
- package/web/qvp.js +457 -130
- package/web/qvp_ffi.wasm +0 -0
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;
|
|
7
|
-
|
|
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.
|
|
25
|
-
- **The engine
|
|
26
|
-
masks and search are engine calls. A wrapper
|
|
27
|
-
- **Data is separate from code.** Pages (`NNN.qvp`), the atlas (`atlas.qva`)
|
|
28
|
-
|
|
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/
|
|
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]` | `{
|
|
47
|
-
| `page.ayahs[i]` | one **fragment** per printed line: `{surah, ayah, fragment, fragments, flags, rubuAlHizb, firstWord, nWords,
|
|
48
|
-
| `page.lines[i]` | `{
|
|
49
|
-
| `page.
|
|
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.
|
|
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. `
|
|
57
|
-
`ayahWordCount(s,a)` returns `{count,
|
|
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
|
|
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,
|
|
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.
|
|
86
|
-
// → {word, path,
|
|
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
|
|
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. `
|
|
93
|
-
`lineBands()` the
|
|
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,
|
|
100
|
-
|
|
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
|
|
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.
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
(
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
`
|
|
113
|
-
|
|
114
|
-
`
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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.
|
|
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 `
|
|
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, `
|
|
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
|
-
`
|
|
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. `
|
|
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: '
|
|
177
|
-
page.
|
|
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.
|
|
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
|
|
185
|
-
and `
|
|
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.
|
|
196
|
-
page.
|
|
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 `
|
|
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 `
|
|
600
|
+
Drive the highlight with `moveHighlight(h, T.word(i))`.
|
|
210
601
|
|
|
211
602
|
## Crop and export
|
|
212
603
|
|
|
213
|
-
`
|
|
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}`, `
|
|
221
|
-
`pagesOfJuz(n)`, `
|
|
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
|
|