@quran.ws/engine 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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/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
|
+
}
|