@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/web/lite.mjs ADDED
@@ -0,0 +1,465 @@
1
+ /**
2
+ * Dependency-free QVP1 decoder and Canvas2D renderer.
3
+ *
4
+ * This entry point is for displaying original QVP pages with optional word
5
+ * bands. Use the main package when an application also needs the engine's
6
+ * layout, exact hit-testing, search, styling, selection, or masking APIs.
7
+ *
8
+ * @example
9
+ * import { loadPage } from '@quran.ws/engine/lite'
10
+ * const page = await loadPage('/pages/042.qvp')
11
+ * page.draw(canvas.getContext('2d'), page.fit(canvas, 24))
12
+ */
13
+
14
+ import { buildPath } from './lite-path.mjs'
15
+
16
+ const limits = {
17
+ fileBytes: 2 * 1024 * 1024,
18
+ paths: 20000,
19
+ operations: 500000,
20
+ coordinates: 2000000,
21
+ decodedBytes: 16 * 1024 * 1024,
22
+ records: 4096
23
+ }
24
+
25
+ export async function loadPage(url, options) {
26
+ const response = await fetch(url, options)
27
+ if (!response.ok) throw new Error(`Could not load QVP page: HTTP ${response.status}`)
28
+ return decodePage(await response.arrayBuffer())
29
+ }
30
+
31
+ export function decodePage(buffer) {
32
+ return new QvpLitePage(decodeGeometry(buffer))
33
+ }
34
+
35
+ export function decodeGeometry(buffer) {
36
+ const bytes = new Uint8Array(buffer.buffer ?? buffer, buffer.byteOffset ?? 0, buffer.byteLength)
37
+ if (bytes.byteLength < 64) throw new Error('Truncated QVP header')
38
+ if (bytes.byteLength > limits.fileBytes) throw new Error('QVP page is too large')
39
+ const data = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
40
+ const u16 = offset => data.getUint16(offset, true)
41
+ const u32 = offset => data.getUint32(offset, true)
42
+ const f32 = offset => data.getFloat32(offset, true)
43
+ if (u32(0) !== 0x31505651 || u16(4) !== 1) throw new Error('Expected a QVP1 page')
44
+
45
+ const quant = u16(6)
46
+ const number = u16(8)
47
+ const width = f32(12)
48
+ const height = f32(16)
49
+ const nLines = u16(20)
50
+ const nAyahs = u16(22)
51
+ const nWords = u16(24)
52
+ const nDecorations = u16(26)
53
+ const nPaths = u32(28)
54
+ const nStrings = u16(32)
55
+ const nGlyphs = u16(34)
56
+ const nInstances = u16(36)
57
+ if (nPaths > limits.paths || nLines > limits.records || nAyahs > limits.records ||
58
+ nWords > limits.records || nDecorations > limits.records || nStrings > limits.records ||
59
+ nGlyphs > limits.records || nInstances > limits.records) {
60
+ throw new Error('QVP page exceeds decoder limits')
61
+ }
62
+ if (!quant || !Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
63
+ throw new Error('Invalid QVP dimensions')
64
+ }
65
+
66
+ let sectionOffset = 64
67
+ const sections = Array.from({ length: 6 }, (_, i) => {
68
+ const start = sectionOffset
69
+ sectionOffset += u32(40 + i * 4)
70
+ if (sectionOffset > bytes.length) throw new Error('Invalid QVP section length')
71
+ return { start, end: sectionOffset }
72
+ })
73
+ if (sectionOffset !== bytes.length) throw new Error('Invalid QVP length')
74
+
75
+ const reader = section => ({
76
+ position: section.start,
77
+ byte() {
78
+ if (this.position >= section.end) throw new Error('Truncated QVP section')
79
+ return bytes[this.position++]
80
+ },
81
+ varint() {
82
+ let value = 0
83
+ for (let shift = 0; shift < 35; shift += 7) {
84
+ const byte = this.byte()
85
+ if (shift === 28 && byte > 15) throw new Error('Invalid QVP varint')
86
+ value += (byte & 127) * 2 ** shift
87
+ if (byte < 128) return value
88
+ }
89
+ throw new Error('Invalid QVP varint')
90
+ },
91
+ zigzag() {
92
+ const value = this.varint()
93
+ return (value >>> 1) ^ -(value & 1)
94
+ },
95
+ float() {
96
+ if (this.position + 4 > section.end) throw new Error('Truncated QVP float')
97
+ const value = f32(this.position)
98
+ this.position += 4
99
+ return value
100
+ }
101
+ })
102
+
103
+ const metadataSize = sections[0].end - sections[0].start
104
+ const minimumMetadataSize = nLines * 2 + nAyahs * 8 + nWords * 12 +
105
+ nPaths * 5 + nDecorations * 7 + nGlyphs * 5 + nInstances * 26
106
+ if (minimumMetadataSize > metadataSize) throw new Error('Invalid QVP metadata counts')
107
+
108
+ const metadata = reader(sections[0])
109
+ let firstWord = 0
110
+ const lines = Array.from({ length: nLines }, (_, index) => {
111
+ const lineNumber = metadata.byte()
112
+ const nWords = metadata.varint()
113
+ const line = { index, lineNumber, firstWord, nWords }
114
+ firstWord += nWords
115
+ return line
116
+ })
117
+ if (firstWord !== nWords) throw new Error('Invalid QVP line word count')
118
+ firstWord = 0
119
+ let ayahSurah = 0
120
+ let ayahNumber = 0
121
+ const ayahs = Array.from({ length: nAyahs }, (_, index) => {
122
+ ayahSurah += metadata.zigzag()
123
+ ayahNumber += metadata.zigzag()
124
+ const fragment = metadata.byte()
125
+ const fragments = metadata.byte()
126
+ const flags = metadata.byte()
127
+ const nWords = metadata.varint()
128
+ const ayahMarkDecoration = metadata.varint() - 1
129
+ const rubuAlHizb = metadata.varint()
130
+ const ayah = { index, surah: ayahSurah, ayah: ayahNumber, fragment, fragments,
131
+ flags, firstWord, nWords, ayahMarkDecoration, rubuAlHizb }
132
+ firstWord += nWords
133
+ return ayah
134
+ })
135
+ if (firstWord !== nWords) throw new Error('Invalid QVP ayah word count')
136
+ let surah = 0
137
+ let ayah = 0
138
+ let word = 0
139
+ let lineIndex = 0
140
+ let ayahIndex = 0
141
+ const wordRecords = Array.from({ length: nWords }, (_, index) => {
142
+ const firstPathDelta = metadata.zigzag()
143
+ surah += metadata.zigzag()
144
+ ayah += metadata.zigzag()
145
+ word += metadata.zigzag()
146
+ lineIndex += metadata.zigzag()
147
+ ayahIndex += metadata.zigzag()
148
+ const textIndex = metadata.varint() - 1
149
+ for (let i = 0; i < 4; i++) metadata.varint()
150
+ return {
151
+ textIndex,
152
+ index,
153
+ surah,
154
+ ayah,
155
+ word,
156
+ lineIndex,
157
+ ayahIndex,
158
+ firstPathDelta,
159
+ nPaths: metadata.varint()
160
+ }
161
+ })
162
+ const columns = Array.from({ length: 4 }, () =>
163
+ Uint8Array.from({ length: nPaths }, () => metadata.byte()))
164
+ const counts = Uint32Array.from({ length: nPaths }, () => metadata.varint())
165
+ const nInstancePaths = columns[3].reduce((sum, flags) => sum + Boolean(flags & 16), 0)
166
+ const instanceBoxes = Array.from({ length: nInstancePaths }, () => {
167
+ const x0 = metadata.zigzag()
168
+ const y0 = metadata.zigzag()
169
+ const x1 = x0 + metadata.zigzag()
170
+ const y1 = y0 + metadata.zigzag()
171
+ return [Math.fround(x0 / quant), Math.fround(y0 / quant), Math.fround(x1 / quant), Math.fround(y1 / quant)]
172
+ })
173
+ let decorationEnd = 0
174
+ let decorationSurah = 0
175
+ const decorationRecords = Array.from({ length: nDecorations }, (_, index) => {
176
+ const firstPath = decorationEnd + metadata.zigzag()
177
+ const decoration = metadata.byte()
178
+ decorationSurah += metadata.zigzag()
179
+ const ayah = metadata.varint()
180
+ const textIndex = metadata.varint() - 1
181
+ const nPaths = metadata.varint()
182
+ const lineIndex = metadata.varint() - 1
183
+ decorationEnd = firstPath + nPaths
184
+ return { index, firstPath, nPaths, decoration, surah: decorationSurah, ayah, textIndex, lineIndex }
185
+ })
186
+ const glyphCounts = Array.from({ length: nGlyphs }, () => {
187
+ const count = metadata.varint()
188
+ for (let i = 0; i < 4; i++) metadata.zigzag()
189
+ return count
190
+ })
191
+ const instances = Array.from({ length: nInstances }, () => {
192
+ const instance = [metadata.byte() | metadata.byte() << 8,
193
+ ...Array.from({ length: 6 }, () => metadata.float())]
194
+ if (instance.some(value => !Number.isFinite(value))) throw new Error('Invalid QVP transform')
195
+ return instance
196
+ })
197
+ if (metadata.position !== sections[0].end) throw new Error('Invalid QVP metadata')
198
+
199
+ const wireCounts = [...counts.filter((_, i) => !(columns[3][i] & 16)), ...glyphCounts]
200
+ const wireOperations = wireCounts.reduce((sum, count) => sum + count, 0)
201
+ if (!Number.isSafeInteger(wireOperations) || wireOperations > limits.operations ||
202
+ Math.ceil(wireOperations / 4) !== sections[1].end - sections[1].start) {
203
+ throw new Error('Invalid QVP operation count')
204
+ }
205
+
206
+ const closeReader = reader(sections[2])
207
+ let closePosition = 0
208
+ const nCloses = closeReader.varint()
209
+ if (nCloses > sections[2].end - closeReader.position ||
210
+ wireOperations + nCloses > limits.operations) throw new Error('Invalid QVP close count')
211
+ const closePositions = Array.from({ length: nCloses }, () =>
212
+ closePosition += closeReader.varint())
213
+ if (closeReader.position !== sections[2].end) throw new Error('Invalid QVP close paths')
214
+
215
+ const xReader = reader(sections[3])
216
+ const yReader = reader(sections[4])
217
+ let previousX = 0
218
+ let previousY = 0
219
+ let operationIndex = 0
220
+ let closeIndex = 0
221
+ const shapes = wireCounts.map(count => {
222
+ const ops = []
223
+ const pts = []
224
+ for (let i = 0; i < count; i++) {
225
+ const operation = bytes[sections[1].start + (operationIndex >> 2)] >> ((operationIndex % 4) * 2) & 3
226
+ ops.push(operation)
227
+ for (let p = 0; p < [1, 1, 2, 3][operation]; p++) {
228
+ previousX += xReader.zigzag()
229
+ previousY += yReader.zigzag()
230
+ pts.push(previousX, previousY)
231
+ }
232
+ operationIndex++
233
+ while (closePositions[closeIndex] === operationIndex) {
234
+ ops.push(4)
235
+ closeIndex++
236
+ }
237
+ }
238
+ if (ops[0] === 0) {
239
+ previousX = pts[0]
240
+ previousY = pts[1]
241
+ }
242
+ return { ops, pts }
243
+ })
244
+ if (Math.ceil(operationIndex / 4) !== sections[1].end - sections[1].start ||
245
+ closeIndex !== closePositions.length ||
246
+ xReader.position !== sections[3].end || yReader.position !== sections[4].end) {
247
+ throw new Error('Invalid QVP geometry')
248
+ }
249
+
250
+ const strings = reader(sections[5])
251
+ const decoder = new TextDecoder('utf-8', { fatal: true })
252
+ const texts = Array.from({ length: nStrings }, () => {
253
+ const length = strings.varint()
254
+ const start = strings.position
255
+ strings.position += length
256
+ if (strings.position > sections[5].end) throw new Error('Truncated QVP string')
257
+ return decoder.decode(bytes.subarray(start, strings.position))
258
+ })
259
+ if (strings.position !== sections[5].end) throw new Error('Invalid QVP strings')
260
+
261
+ let directShape = 0
262
+ let instanceBox = 0
263
+ let decodedSize = nPaths * 32
264
+ let decodedOperations = 0
265
+ let decodedCoordinates = 0
266
+ const paths = Array.from(counts, (reference, i) => {
267
+ const isInstance = Boolean(columns[3][i] & 16)
268
+ const transform = isInstance ? instances[reference] : null
269
+ if (isInstance && (!transform || transform[0] >= nGlyphs)) throw new Error('Invalid QVP glyph instance')
270
+ const shape = isInstance ? shapes[shapes.length - nGlyphs + transform[0]] : shapes[directShape++]
271
+ if (!shape) throw new Error('Invalid QVP path shape')
272
+ decodedOperations += shape.ops.length
273
+ decodedCoordinates += shape.pts.length
274
+ decodedSize += shape.ops.length + shape.pts.length * 4
275
+ if (!Number.isSafeInteger(decodedSize) || decodedOperations > limits.operations ||
276
+ decodedCoordinates > limits.coordinates || decodedSize > limits.decodedBytes ||
277
+ decodedSize > bytes.length * 64) {
278
+ throw new Error('QVP decoded geometry is too large')
279
+ }
280
+
281
+ const pts = new Float32Array(shape.pts.length)
282
+ const box = isInstance ? instanceBoxes[instanceBox++] : [Infinity, Infinity, -Infinity, -Infinity]
283
+ for (let p = 0; p < pts.length; p += 2) {
284
+ const x = shape.pts[p] / quant
285
+ const y = shape.pts[p + 1] / quant
286
+ let outputX = x
287
+ let outputY = y
288
+ if (isInstance) {
289
+ const [, a, b, c, d, e, f] = transform
290
+ outputX = a * x + c * y + e
291
+ outputY = b * x + d * y + f
292
+ }
293
+ if (!Number.isFinite(outputX) || !Number.isFinite(outputY)) throw new Error('Invalid QVP coordinate')
294
+ pts[p] = outputX
295
+ pts[p + 1] = outputY
296
+ if (!isInstance) {
297
+ box[0] = Math.min(box[0], pts[p])
298
+ box[1] = Math.min(box[1], pts[p + 1])
299
+ box[2] = Math.max(box[2], pts[p])
300
+ box[3] = Math.max(box[3], pts[p + 1])
301
+ }
302
+ }
303
+ if (!box || box.some(value => !Number.isFinite(value))) throw new Error('Invalid QVP path box')
304
+ return {
305
+ ops: Uint8Array.from(shape.ops),
306
+ pts,
307
+ box,
308
+ kind: columns[0][i],
309
+ mark: columns[1][i],
310
+ family: columns[2][i],
311
+ rule: columns[3][i] & 1 ? 'evenodd' : 'nonzero'
312
+ }
313
+ })
314
+
315
+ let previousWordEnd = 0
316
+ const words = wordRecords.map(record => {
317
+ const { firstPathDelta, nPaths: wordPathCount } = record
318
+ const firstPath = previousWordEnd + firstPathDelta
319
+ const endPath = firstPath + wordPathCount
320
+ if (firstPath < 0 || endPath > nPaths) throw new Error('Invalid QVP word path range')
321
+ const box = [Infinity, Infinity, -Infinity, -Infinity]
322
+ for (let path = firstPath; path < endPath; path++) {
323
+ const pathBox = paths[path].box
324
+ box[0] = Math.min(box[0], pathBox[0])
325
+ box[1] = Math.min(box[1], pathBox[1])
326
+ box[2] = Math.max(box[2], pathBox[2])
327
+ box[3] = Math.max(box[3], pathBox[3])
328
+ }
329
+ previousWordEnd = endPath
330
+ if (box.some(value => !Number.isFinite(value))) throw new Error('Invalid QVP word box')
331
+ if (record.lineIndex < 0 || record.lineIndex >= lines.length ||
332
+ record.ayahIndex < 0 || record.ayahIndex >= ayahs.length ||
333
+ record.textIndex < -1 || record.textIndex >= texts.length) {
334
+ throw new Error('Invalid QVP word metadata')
335
+ }
336
+ const line = lines[record.lineIndex]
337
+ const owner = ayahs[record.ayahIndex]
338
+ if (record.index < line.firstWord || record.index >= line.firstWord + line.nWords ||
339
+ record.index < owner.firstWord || record.index >= owner.firstWord + owner.nWords ||
340
+ record.surah !== owner.surah || record.ayah !== owner.ayah) {
341
+ throw new Error('Invalid QVP word ownership')
342
+ }
343
+ return {
344
+ text: texts[record.textIndex] ?? '',
345
+ index: record.index,
346
+ surah: record.surah,
347
+ ayah: record.ayah,
348
+ word: record.word,
349
+ lineIndex: record.lineIndex,
350
+ ayahIndex: record.ayahIndex,
351
+ firstPath,
352
+ nPaths: wordPathCount,
353
+ box
354
+ }
355
+ })
356
+ const decorations = decorationRecords.map(({ textIndex, ...record }) => {
357
+ const { firstPath, nPaths: count, lineIndex } = record
358
+ if (firstPath < 0 || firstPath + count > paths.length || lineIndex < -1 ||
359
+ lineIndex >= lines.length || textIndex < -1 || textIndex >= texts.length) {
360
+ throw new Error('Invalid QVP decoration metadata')
361
+ }
362
+ const box = bounds(paths.slice(firstPath, firstPath + count))
363
+ return { ...record, text: texts[textIndex] ?? '', box }
364
+ })
365
+ for (const line of lines) line.box = bounds(words.slice(line.firstWord, line.firstWord + line.nWords))
366
+ for (const ayah of ayahs) {
367
+ if (ayah.fragment < 1 || ayah.fragment > ayah.fragments ||
368
+ ayah.ayahMarkDecoration < -1 || ayah.ayahMarkDecoration >= decorations.length) {
369
+ throw new Error('Invalid QVP ayah decoration')
370
+ }
371
+ ayah.box = bounds(words.slice(ayah.firstWord, ayah.firstWord + ayah.nWords))
372
+ }
373
+ return { width, height, number, paths, words, lines, ayahs, decorations }
374
+ }
375
+
376
+ function bounds(records) {
377
+ if (!records.length) return null
378
+ const box = [Infinity, Infinity, -Infinity, -Infinity]
379
+ for (const record of records) {
380
+ box[0] = Math.min(box[0], record.box[0])
381
+ box[1] = Math.min(box[1], record.box[1])
382
+ box[2] = Math.max(box[2], record.box[2])
383
+ box[3] = Math.max(box[3], record.box[3])
384
+ }
385
+ return box
386
+ }
387
+
388
+ export class QvpLitePage {
389
+ #paths
390
+ #decorationPaths
391
+
392
+ constructor({ width, height, number, paths, words }) {
393
+ this.width = width
394
+ this.height = height
395
+ this.number = number
396
+ this.words = words
397
+ this.#paths = paths.map(shape => ({ path: buildPath(shape), rule: shape.rule }))
398
+ const wordPaths = new Uint8Array(this.#paths.length)
399
+ for (const { firstPath, nPaths } of words) {
400
+ for (let path = firstPath; path < firstPath + nPaths; path++) {
401
+ if (wordPaths[path]) throw new Error('Overlapping QVP word paths')
402
+ wordPaths[path] = 1
403
+ }
404
+ }
405
+ this.#decorationPaths = Array.from(wordPaths, (owned, path) => owned ? -1 : path)
406
+ .filter(path => path >= 0)
407
+ }
408
+
409
+ fit(canvas, padding = 0) {
410
+ const scale = Math.min((canvas.width - 2 * padding) / this.width, (canvas.height - 2 * padding) / this.height)
411
+ return {
412
+ scale,
413
+ x: (canvas.width - this.width * scale) / 2,
414
+ y: (canvas.height - this.height * scale) / 2
415
+ }
416
+ }
417
+
418
+ draw(ctx, { scale = 1, x = 0, y = 0, ink = '#231f20', highlight = [], band = '#bedbfa' } = {}) {
419
+ ctx.save()
420
+ ctx.setTransform(scale, 0, 0, scale, x, y)
421
+ ctx.fillStyle = band
422
+ for (const index of highlight) {
423
+ const box = this.words[index]?.box
424
+ if (box) ctx.fillRect(box[0], box[1], box[2] - box[0], box[3] - box[1])
425
+ }
426
+ ctx.fillStyle = ink
427
+ for (const { path, rule } of this.#paths) ctx.fill(path, rule)
428
+ ctx.restore()
429
+ }
430
+
431
+ drawWords(ctx, wordIndices, options = {}) {
432
+ const pathIndices = []
433
+ const seen = new Set()
434
+ for (const index of wordIndices) {
435
+ if (!Number.isInteger(index) || index < 0 || index >= this.words.length) {
436
+ throw new RangeError(`Invalid QVP word index: ${index}`)
437
+ }
438
+ if (seen.has(index)) continue
439
+ seen.add(index)
440
+ const { firstPath, nPaths } = this.words[index]
441
+ for (let path = firstPath; path < firstPath + nPaths; path++) pathIndices.push(path)
442
+ }
443
+ this.#drawPaths(ctx, pathIndices, options)
444
+ }
445
+
446
+ drawDecorations(ctx, options = {}) {
447
+ this.#drawPaths(ctx, this.#decorationPaths, options)
448
+ }
449
+
450
+ #drawPaths(ctx, pathIndices, { scale = 1, x = 0, y = 0, ink = '#231f20' } = {}) {
451
+ ctx.save()
452
+ ctx.setTransform(scale, 0, 0, scale, x, y)
453
+ ctx.fillStyle = ink
454
+ for (const index of pathIndices) {
455
+ const { path, rule } = this.#paths[index]
456
+ ctx.fill(path, rule)
457
+ }
458
+ ctx.restore()
459
+ }
460
+
461
+ hitTest(x, y) {
462
+ return this.words.find(({ box: [x0, y0, x1, y1] }) =>
463
+ x >= x0 && x <= x1 && y >= y0 && y <= y1) ?? null
464
+ }
465
+ }