@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.
@@ -0,0 +1,54 @@
1
+ # Lite passages
2
+
3
+ `@quran.ws/engine/lite/passage` lays out a contiguous range of complete ayahs in JavaScript. It loads no Wasm. Words keep their original outlines, dots and reading signs; the layout moves them onto rows of the requested width. It includes every selected ayah medallion and the relevant division and sajdah marks. Page numbers, running heads, surah banners and unnumbered basmalahs belong to the printed page and are not part of an excerpt.
4
+
5
+ This is an optional entry point. Importing `@quran.ws/engine/lite` alone does not load the passage layout. It is not a replacement for the full engine's page reader, zoom control, selection or styles.
6
+
7
+ ```js
8
+ import { decodeGeometry } from '@quran.ws/engine/lite'
9
+ import { QvpPassage } from '@quran.ws/engine/lite/passage'
10
+
11
+ const response = await fetch('https://cdn.quran.ws/qvp/v0.4.0/042.qvp')
12
+ if (!response.ok) throw new Error(`Page: HTTP ${response.status}`)
13
+ const page = decodeGeometry(await response.arrayBuffer())
14
+ const passage = new QvpPassage([page], { surah: 2, from: 255 })
15
+ const layout = passage.layout({ width: 366, scale: 0.9 })
16
+
17
+ const ratio = Math.max(2, window.devicePixelRatio || 1)
18
+ canvas.width = Math.ceil(layout.width * ratio)
19
+ canvas.height = Math.ceil(layout.height * ratio)
20
+ canvas.style.width = `${layout.width}px`
21
+ canvas.style.height = `${layout.height}px`
22
+ passage.draw(canvas.getContext('2d'), layout, { pixelRatio: ratio, ink: '#231f20' })
23
+ ```
24
+
25
+ ## Inputs and results
26
+
27
+ `new QvpPassage(pages, {surah, from, to = from})` accepts the geometry returned by `decodeGeometry()`, not `QvpLitePage`. Supply every page containing the requested range. Pages may arrive in any order. Missing ayahs, incomplete fragments, duplicate pages and passages over 4,096 words throw rather than show partial Quran text. The app decides which pages to fetch and how many ayahs to show; the module does not fetch anything. `passage.ayahs` contains `{surah, ayah, text}` in reading order, with the text from the page's own word records.
28
+
29
+ `passage.layout({width, scale = 1, lineSpacing = 1, padding = 2, align = 'right'})` returns:
30
+
31
+ - `width`, `height`, `scale`: CSS-pixel canvas dimensions and the actual scale from page units. Scale shrinks only if a whole word and its attached signs would exceed the width.
32
+ - `rows`: each row's `box` and `baseline` in CSS pixels.
33
+ - `words`: `{surah, ayah, word, row, box}`. A box includes signs that travel with that word.
34
+ - `ayahs`: `{surah, ayah, text, box}`, suitable for an accessible text layer or scrolling to a cited ayah.
35
+
36
+ `align` accepts `right` or `center`. `lineSpacing` is a multiplier, at least 1. The rows may need more room for tall ink. The layout balances row lengths without stretching the gaps; it is intentionally not the full page reader's fitted breaking or zoom policy. Layout is independent of canvas resolution and can run without the DOM.
37
+
38
+ `passage.draw(context, layout, {pixelRatio = 1, ink = '#231f20'})` draws the layout made by that passage. It saves and restores the context but does not clear or resize its canvas. The caller supplies the backing ratio and caps the canvas dimensions or pixel budget as needed. Drawing requires Canvas2D and `Path2D`; decoding and layout do not.
39
+
40
+ ## Geometry and lifetime
41
+
42
+ The decoder now exposes `lines`, `ayahs`, `decorations`, word `text`, and path `kind`, `mark` and `family` alongside the existing geometry. Indices are zero-based. Optional decoration and line indices are -1 when absent. Empty line/ayah/decoration bounds are `null`. The QVP wire format is unchanged.
43
+
44
+ The passage module measures outlines in thin bands rather than separating word bounding boxes. It keeps deeply interlocked adjacent words at their printed relative position. All pages share the same band height, so a range spanning a page boundary can form one row. Curves are sampled only to measure spacing: drawing uses their unchanged original operations. The reference Rust engine supplies fixtures for line spacing and ink clearance; the clearance test allows 0.75 page units because the Rust reader's band height varies with each page while the passage bands must be shared across pages.
45
+
46
+ Ayah medallions and sajdah signs travel with the word that closes their ayah. A division mark travels with the word it opens. A sajdah stroke belongs to the words geometrically underneath it, which can be in an earlier ayah than the closing sign. If those words wrap, the stroke is drawn on each affected row; only that stroke is stretched horizontally.
47
+
48
+ Decoded pages are immutable inputs. Preparation is bounded per page to two million sample points and 32,768 allocated measurement bands; exceeding either limit throws. This prevents extreme coordinates or repeated geometry from expanding an otherwise bounded QVP file into unbounded work or memory. The complete 604-page dataset is checked against these limits. Preparation and Canvas paths use weak caches keyed by those pages. Keep a bounded app-owned page cache; dropping a passage, its layouts and unused pages releases the associated data. Reuse the passage when its container changes width. Do not lay it out on every scroll event.
49
+
50
+ A canvas is not an accessible text document. Supply a Unicode text layer for screen readers and copying. The module's text is optional to the host; an app may keep its existing verified text corpus. Theme changes require drawing with a new ink colour, not recomputing the layout.
51
+
52
+ ## Checks
53
+
54
+ `pnpm run test:lite` runs synthetic tests and the Rust codec fixture without data. With `dist/pages/` present, it also checks all 604 pages at three widths, all ayah medallions, a range spanning pages and the Rust-generated ink measurements. `scripts/check.sh gates` requires those data tests. `scripts/gen-conformance.sh` regenerates `conformance/lite-passage-metrics.json` from Rust; no Wasm is needed by passage consumers.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quran.ws/engine",
3
- "version": "0.1.0",
3
+ "version": "0.3.1",
4
4
  "description": "Rendering engine for interactive muṣḥaf pages: layout, hit-testing, styling and search over the QVP page format, as wasm with a Canvas2D wrapper.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://quran.ws/blocks/quran-engine",
@@ -31,6 +31,8 @@
31
31
  "import": "./web/index.mjs",
32
32
  "require": "./web/index.cjs"
33
33
  },
34
+ "./lite": "./web/lite.mjs",
35
+ "./lite/passage": "./web/lite-passage.mjs",
34
36
  "./qvp.js": "./web/qvp.js",
35
37
  "./qvp_ffi.wasm": "./web/qvp_ffi.wasm",
36
38
  "./package.json": "./package.json"
@@ -40,6 +42,11 @@
40
42
  "files": [
41
43
  "web/index.mjs",
42
44
  "web/index.cjs",
45
+ "web/lite.mjs",
46
+ "web/lite-path.mjs",
47
+ "web/lite-passage.mjs",
48
+ "web/lite-passage-geometry.mjs",
49
+ "docs/LITE-PASSAGES.md",
43
50
  "web/qvp.js",
44
51
  "web/qvp_ffi.wasm",
45
52
  "docs/API.md",
@@ -56,6 +63,7 @@
56
63
  "scripts": {
57
64
  "build:wasm": "cargo build -p qvp-ffi --release --target wasm32-unknown-unknown && cp target/wasm32-unknown-unknown/release/qvp_ffi.wasm web/qvp_ffi.wasm",
58
65
  "prepack": "npm run build:wasm",
59
- "test": "node web/smoke.mjs"
66
+ "test": "node web/smoke.mjs && node web/lite.test.mjs && node web/lite-passage.test.mjs",
67
+ "test:lite": "node web/lite.test.mjs && node web/lite-passage.test.mjs"
60
68
  }
61
69
  }
package/web/index.mjs CHANGED
@@ -11,7 +11,7 @@ if (!QVP) throw new Error('@quran.ws/engine: qvp.js did not define globalThis.QV
11
11
 
12
12
  export const {
13
13
  QvpEngine, QvpPage, QvpAtlas, CanvasRenderer,
14
- Sel, T, KIND, FAMILY, CATEGORY, DECO, FORM, LAYER, MARK, MARKS, NONE,
14
+ Sel, T, KIND, FAMILY, CATEGORY, DECORATION, FORM, LAYER, NAMES_TABLE, DEFAULTS, NONE,
15
15
  css, rgba, parseTarget,
16
16
  } = QVP;
17
17
 
@@ -0,0 +1,174 @@
1
+ // Measure passage ink; Canvas still draws the untouched curves, not these samples.
2
+ const prepared_pages = new WeakMap()
3
+ const band_height = 0.375
4
+ const curve_steps = 8
5
+
6
+ export function bounds(boxes) {
7
+ const result = [Infinity, Infinity, -Infinity, -Infinity]
8
+ for (const box of boxes) {
9
+ if (!box) continue
10
+ result[0] = Math.min(result[0], box[0])
11
+ result[1] = Math.min(result[1], box[1])
12
+ result[2] = Math.max(result[2], box[2])
13
+ result[3] = Math.max(result[3], box[3])
14
+ }
15
+ return Number.isFinite(result[0]) ? result : null
16
+ }
17
+
18
+ export function median(values, fallback = 0) {
19
+ if (!values.length) return fallback
20
+ values.sort((a, b) => a - b)
21
+ return values[Math.floor(values.length / 2)]
22
+ }
23
+
24
+ // A shared band height lets words from different printed pages share one row.
25
+ function trace(paths, baseline, budget) {
26
+ const bands = new Map()
27
+ const mark = (x, y) => {
28
+ if (--budget.points < 0) throw new RangeError('QVP passage measurement limit exceeded')
29
+ const row = Math.floor((y - baseline) / band_height)
30
+ const span = bands.get(row)
31
+ if (span) {
32
+ span[0] = Math.min(span[0], x)
33
+ span[1] = Math.max(span[1], x)
34
+ } else {
35
+ if (--budget.bands < 0) throw new RangeError('QVP passage measurement limit exceeded')
36
+ bands.set(row, [x, x])
37
+ }
38
+ }
39
+ const segment = (a, b) => {
40
+ const steps = Math.max(1, Math.min(512, Math.ceil(Math.abs(b[1] - a[1]) / band_height)))
41
+ for (let i = 0; i <= steps; i++) {
42
+ const t = i / steps
43
+ mark(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t)
44
+ }
45
+ }
46
+ for (const { ops, pts } of paths) {
47
+ let point = 0
48
+ let here = [0, 0]
49
+ let start = here
50
+ const take = () => [pts[point++], pts[point++]]
51
+ for (const op of ops) {
52
+ if (op === 0) {
53
+ here = take()
54
+ start = here
55
+ mark(...here)
56
+ } else if (op === 1 || op === 4) {
57
+ const to = op === 4 ? start : take()
58
+ segment(here, to)
59
+ here = to
60
+ } else {
61
+ const first = take()
62
+ const second = take()
63
+ const to = op === 2 ? second : take()
64
+ let previous = here
65
+ for (let i = 1; i <= curve_steps; i++) {
66
+ const t = i / curve_steps
67
+ const u = 1 - t
68
+ const next = [0, 1].map(axis => op === 2
69
+ ? u * u * here[axis] + 2 * u * t * first[axis] + t * t * to[axis]
70
+ : u ** 3 * here[axis] + 3 * u * u * t * first[axis] + 3 * u * t * t * second[axis] + t ** 3 * to[axis])
71
+ segment(previous, next)
72
+ previous = next
73
+ }
74
+ here = to
75
+ }
76
+ }
77
+ }
78
+ return [...bands].sort((a, b) => a[0] - b[0]).map(([row, [left, right]]) => [row, left, right])
79
+ }
80
+
81
+ export function clearance(a, b) {
82
+ let air = Infinity
83
+ let i = 0
84
+ let j = 0
85
+ while (i < a.length && j < b.length) {
86
+ if (a[i][0] < b[j][0]) i++
87
+ else if (b[j][0] < a[i][0]) j++
88
+ else {
89
+ air = Math.min(air, a[i][1] - b[j][2])
90
+ i++
91
+ j++
92
+ }
93
+ }
94
+ return Number.isFinite(air) ? air : null
95
+ }
96
+
97
+ function merged_slices(groups) {
98
+ const rows = new Map()
99
+ for (const slices of groups) for (const [row, left, right] of slices) {
100
+ const previous = rows.get(row)
101
+ rows.set(row, previous ? [Math.min(left, previous[0]), Math.max(right, previous[1])] : [left, right])
102
+ }
103
+ return [...rows].sort((a, b) => a[0] - b[0]).map(([row, [left, right]]) => [row, left, right])
104
+ }
105
+
106
+ export function preparePage(page) {
107
+ if (prepared_pages.has(page)) return prepared_pages.get(page)
108
+ // Shared across the page: large coordinates must not amplify bounded QVP input
109
+ // into unbounded maps or work. Current pages use <447k samples and <13k bands.
110
+ const budget = { points: 2000000, bands: 32768 }
111
+ const paths_of = record => page.paths.slice(record.firstPath, record.firstPath + record.nPaths)
112
+ const line_words = page.lines.map(line => page.words.slice(line.firstWord, line.firstWord + line.nWords))
113
+ const centres = page.lines.map((line, i) => {
114
+ let box = bounds(line_words[i].flatMap(word => paths_of(word).filter(path => path.kind === 0).map(path => path.box)))
115
+ if (!box) box = bounds(page.decorations.filter(d => d.lineIndex === i && [1, 2].includes(d.decoration)).map(d => d.box))
116
+ return box ? (box[1] + box[3]) / 2 : 0
117
+ })
118
+ const pitch = median(centres.slice(1).map((y, i) => Math.abs(y - centres[i])).filter(gap => gap > 1), page.height / 15)
119
+ const baselines = line_words.map((words, i) => median(words.map(word => word.box[3]), centres[i]))
120
+ const word_slices = page.words.map(word => trace(paths_of(word), baselines[word.lineIndex], budget))
121
+ const gaps = []
122
+ for (const words of line_words) for (let i = 1; i < words.length; i++) {
123
+ const a = words[i - 1]
124
+ const b = words[i]
125
+ if (a.surah !== b.surah || a.ayah !== b.ayah) continue
126
+ const air = clearance(word_slices[a.index], word_slices[b.index])
127
+ if (air > 0) gaps.push(air)
128
+ }
129
+ const gap = median(gaps, pitch / 4)
130
+ const attached = page.words.map(() => [])
131
+ const strokes = []
132
+ for (const decoration of page.decorations) {
133
+ if (![0, 3, 4].includes(decoration.decoration)) continue
134
+ const path_indices = Array.from({ length: decoration.nPaths }, (_, i) => decoration.firstPath + i)
135
+ for (const index of path_indices.filter(index => page.paths[index].mark === 31)) {
136
+ const box = page.paths[index].box
137
+ const under = page.words.filter(word => word.box[0] < box[2] && word.box[2] > box[0] && word.box[1] >= box[1] - 2)
138
+ .sort((a, b) => a.box[1] - b.box[1])[0]
139
+ if (!under) throw new Error('Sajdah line has no words')
140
+ const words = line_words[under.lineIndex].filter(word => word.box[0] < box[2] && word.box[2] > box[0])
141
+ strokes.push({ path: index, words: words.map(word => word.index), box, baseline: baselines[under.lineIndex] })
142
+ }
143
+ const indices = path_indices.filter(index => page.paths[index].mark !== 31)
144
+ if (!indices.length) continue
145
+ const box = bounds(indices.map(index => page.paths[index].box))
146
+ let word
147
+ if (decoration.decoration === 0 || decoration.decoration === 4) {
148
+ word = page.words.filter(word => word.surah === decoration.surah && word.ayah === decoration.ayah).pop()
149
+ } else {
150
+ const line = decoration.lineIndex >= 0 ? decoration.lineIndex : centres.reduce((best, y, i) =>
151
+ Math.abs(y - (box[1] + box[3]) / 2) < Math.abs(centres[best] - (box[1] + box[3]) / 2) ? i : best, 0)
152
+ const candidates = line_words[line]
153
+ word = candidates.filter(word => word.box[2] <= box[0] + gap).sort((a, b) => b.box[2] - a.box[2])[0]
154
+ word ||= [...candidates].sort((a, b) => Math.min(Math.abs(a.box[0] - box[0]), Math.abs(a.box[2] - box[2])) -
155
+ Math.min(Math.abs(b.box[0] - box[0]), Math.abs(b.box[2] - box[2])))[0]
156
+ }
157
+ if (!word) throw new Error('QVP passage decoration has no word')
158
+ attached[word.index].push({ indices, box, decoration: decoration.index,
159
+ slices: trace(indices.map(index => page.paths[index]), baselines[word.lineIndex], budget) })
160
+ }
161
+ const atoms = page.words.map(word => {
162
+ const decorations = attached[word.index]
163
+ const indices = Array.from({ length: word.nPaths }, (_, i) => word.firstPath + i)
164
+ for (const decoration of decorations) indices.push(...decoration.indices)
165
+ return { page, word, indices, decorations, pitch, gap,
166
+ baseline: baselines[word.lineIndex],
167
+ box: bounds([word.box, ...decorations.map(d => d.box)]),
168
+ slices: merged_slices([word_slices[word.index], ...decorations.map(d => d.slices)]),
169
+ wordSlices: word_slices[word.index] }
170
+ })
171
+ const result = { atoms, strokes, pitch, gap, baselines }
172
+ prepared_pages.set(page, result)
173
+ return result
174
+ }
@@ -0,0 +1,199 @@
1
+ // Lay out complete ayahs as a standalone passage without loading Wasm.
2
+ import { buildPath } from './lite-path.mjs'
3
+ import { bounds, clearance, median, preparePage } from './lite-passage-geometry.mjs'
4
+
5
+ const canvas_paths = new WeakMap()
6
+
7
+ /** Lay out and draw a contiguous ayah range from decoded QVP pages. */
8
+ export class QvpPassage {
9
+ #atoms
10
+ #pages
11
+ #gaps
12
+ #layouts = new WeakMap()
13
+
14
+ /** Select complete ayahs; pages come from decodeGeometry(), in any order. */
15
+ constructor(pages, { surah, from, to = from }) {
16
+ if (!Number.isInteger(surah) || surah < 1 || surah > 114 ||
17
+ !Number.isInteger(from) || from < 1 || !Number.isInteger(to) || to < from || to > 286) {
18
+ throw new RangeError('Invalid passage ayah range')
19
+ }
20
+ if (new Set(pages.map(page => page.number)).size !== pages.length) throw new Error('Duplicate QVP pages')
21
+ this.#pages = pages
22
+ this.#atoms = []
23
+ this.ayahs = []
24
+ for (let ayah = from; ayah <= to; ayah++) {
25
+ const words = pages.flatMap(page => page.words.filter(word => word.surah === surah && word.ayah === ayah)
26
+ .map(word => ({ page, word }))).sort((a, b) => a.word.word - b.word.word)
27
+ const fragments = pages.flatMap(page => page.ayahs.filter(record => record.surah === surah && record.ayah === ayah))
28
+ fragments.sort((a, b) => a.fragment - b.fragment)
29
+ if (!words.length || !fragments.length || fragments.length !== fragments[0].fragments ||
30
+ fragments.some((record, i) => record.fragment !== i + 1 || record.fragments !== fragments.length) ||
31
+ words.length !== fragments.reduce((sum, record) => sum + record.nWords, 0) ||
32
+ words.some(({ word }, i) => word.word !== i + 1)) {
33
+ throw new Error(`Incomplete passage ayah ${surah}:${ayah}`)
34
+ }
35
+ if (this.#atoms.length + words.length > 4096) throw new RangeError('A passage may contain at most 4096 words')
36
+ for (const { page, word } of words) this.#atoms.push(preparePage(page).atoms[word.index])
37
+ this.ayahs.push({ surah, ayah, text: words.map(({ word }) => word.text).join(' ') })
38
+ }
39
+ this.#gaps = this.#atoms.map((atom, index) => {
40
+ if (!index) return 0
41
+ const previous = this.#atoms[index - 1]
42
+ const natural = previous.box[0] - atom.box[2]
43
+ const word_air = clearance(previous.wordSlices, atom.wordSlices)
44
+ const joined = previous.page === atom.page && previous.word.lineIndex === atom.word.lineIndex &&
45
+ previous.word.index + 1 === atom.word.index && word_air !== null && -word_air >= atom.pitch * 0.15
46
+ if (joined) return natural
47
+ const air = clearance(previous.slices, atom.slices)
48
+ const wanted = (previous.gap + atom.gap) / 2
49
+ return air === null ? wanted : natural + wanted - air
50
+ })
51
+ }
52
+
53
+ /** Return rows and word/ayah bounds in CSS pixels, at the requested width and scale. */
54
+ layout({ width, scale = 1, lineSpacing = 1, padding = 2, align = 'right' }) {
55
+ if (!Number.isFinite(width) || width <= 0 || width > 32768 ||
56
+ !Number.isFinite(scale) || scale <= 0 || scale > 32 ||
57
+ !Number.isFinite(padding) || padding < 0 || 2 * padding >= width ||
58
+ !Number.isFinite(lineSpacing) || lineSpacing < 1 || lineSpacing > 4 ||
59
+ !['right', 'center'].includes(align)) throw new RangeError('Invalid passage layout')
60
+ const atoms = this.#atoms
61
+ const widest = Math.max(...atoms.map(atom => atom.box[2] - atom.box[0]))
62
+ // An indivisible word and its signs must fit even at an unusually narrow width.
63
+ scale = Math.min(scale, (width - 2 * padding) / widest)
64
+ const row_width = (width - 2 * padding) / scale
65
+ const rows = break_rows(atoms, this.#gaps, row_width)
66
+ const drawings = []
67
+ const placements = new Map()
68
+ const result = { width, height: 0, scale, rows: [], words: [], ayahs: [] }
69
+ const pitch = median(atoms.map(atom => atom.pitch)) * lineSpacing
70
+ let last_bottom = 0
71
+ let last_baseline = 0
72
+ for (const indices of rows) {
73
+ const used = indices.reduce((sum, index, i) => sum + atoms[index].box[2] - atoms[index].box[0] + (i ? this.#gaps[index] : 0), 0)
74
+ const ascent = Math.max(...indices.map(index => atoms[index].baseline - atoms[index].box[1]))
75
+ const descent = Math.max(...indices.map(index => atoms[index].box[3] - atoms[index].baseline))
76
+ // Signs and tall marks have the same right to vertical space as the letters.
77
+ const baseline = result.rows.length ? Math.max(last_baseline + pitch, last_bottom + ascent + 1) : ascent
78
+ let cursor = align === 'center' ? (row_width + used) / 2 : row_width
79
+ const row = result.rows.length
80
+ const row_bounds = []
81
+ for (let i = 0; i < indices.length; i++) {
82
+ const index = indices[i]
83
+ const atom = atoms[index]
84
+ if (i) cursor -= this.#gaps[index]
85
+ const dx = cursor - atom.box[2]
86
+ const dy = baseline - atom.baseline
87
+ const placement = { dx, dy, row }
88
+ placements.set(atom, placement)
89
+ for (const path of atom.indices) drawings.push({ page: atom.page, path, dx, dy, kx: 1, row })
90
+ const box = [atom.box[0] + dx, atom.box[1] + dy, atom.box[2] + dx, atom.box[3] + dy]
91
+ row_bounds.push(box)
92
+ result.words.push({ surah: atom.word.surah, ayah: atom.word.ayah, word: atom.word.word, row, box })
93
+ cursor -= atom.box[2] - atom.box[0]
94
+ }
95
+ result.rows.push({ box: bounds(row_bounds), baseline })
96
+ last_baseline = baseline
97
+ last_bottom = baseline + descent
98
+ }
99
+ // A sajdah line belongs to the words underneath it, not necessarily to the ayah
100
+ // carrying the sign. Its stroke is repeated only when those words span rows.
101
+ for (const page of this.#pages) for (const stroke of preparePage(page).strokes) {
102
+ const spans = new Map()
103
+ for (const index of stroke.words) {
104
+ const atom = preparePage(page).atoms[index]
105
+ const placement = placements.get(atom)
106
+ if (!placement) continue
107
+ const box = [atom.word.box[0] + placement.dx, atom.word.box[1] + placement.dy,
108
+ atom.word.box[2] + placement.dx, atom.word.box[3] + placement.dy]
109
+ const old = spans.get(placement.row)
110
+ spans.set(placement.row, { box: bounds([old?.box, box]), dy: placement.dy })
111
+ }
112
+ for (const [row, { box, dy }] of spans) {
113
+ const kx = (box[2] - box[0]) / (stroke.box[2] - stroke.box[0])
114
+ const dx = box[0] - kx * stroke.box[0]
115
+ drawings.push({ page, path: stroke.path, dx, dy, kx, row })
116
+ result.rows[row].box = bounds([result.rows[row].box, [box[0], stroke.box[1] + dy, box[2], stroke.box[3] + dy]])
117
+ }
118
+ }
119
+ // Include the strokes in row spacing too. Moving whole rows cannot reshape ink.
120
+ for (let row = 1; row < result.rows.length; row++) {
121
+ const needed = result.rows[row - 1].box[3] + 1 - result.rows[row].box[1]
122
+ if (needed <= 0) continue
123
+ for (let i = row; i < result.rows.length; i++) {
124
+ result.rows[i].box[1] += needed
125
+ result.rows[i].box[3] += needed
126
+ result.rows[i].baseline += needed
127
+ }
128
+ for (const word of result.words) if (word.row >= row) {
129
+ word.box[1] += needed
130
+ word.box[3] += needed
131
+ }
132
+ for (const drawing of drawings) if (drawing.row >= row) drawing.dy += needed
133
+ }
134
+ const ink = bounds(result.rows.map(row => row.box))
135
+ const offset = padding / scale - ink[1]
136
+ for (const drawing of drawings) {
137
+ drawing.dx += padding / scale
138
+ drawing.dy += offset
139
+ }
140
+ const css_box = box => [box[0] * scale + padding, (box[1] + offset) * scale,
141
+ box[2] * scale + padding, (box[3] + offset) * scale]
142
+ for (const word of result.words) word.box = css_box(word.box)
143
+ for (const row of result.rows) {
144
+ row.box = css_box(row.box)
145
+ row.baseline = (row.baseline + offset) * scale
146
+ }
147
+ result.height = (ink[3] - ink[1]) * scale + 2 * padding
148
+ result.ayahs = this.ayahs.map(ayah => ({ ...ayah,
149
+ box: bounds(result.words.filter(word => word.surah === ayah.surah && word.ayah === ayah.ayah).map(word => word.box)) }))
150
+ this.#layouts.set(result, drawings)
151
+ return result
152
+ }
153
+
154
+ /** Draw a layout without clearing or sizing the canvas; pixelRatio is its backing ratio. */
155
+ draw(ctx, layout, { pixelRatio = 1, ink = '#231f20' } = {}) {
156
+ const drawings = this.#layouts.get(layout)
157
+ if (!drawings) throw new Error('Layout belongs to another passage')
158
+ if (!Number.isFinite(pixelRatio) || pixelRatio <= 0) throw new RangeError('Invalid pixel ratio')
159
+ const scale = layout.scale * pixelRatio
160
+ ctx.save()
161
+ ctx.fillStyle = ink
162
+ for (const { page, path, dx, dy, kx } of drawings) {
163
+ let paths = canvas_paths.get(page)
164
+ if (!paths) canvas_paths.set(page, paths = new Map())
165
+ if (!paths.has(path)) paths.set(path, buildPath(page.paths[path]))
166
+ ctx.setTransform(scale * kx, 0, 0, scale, dx * scale, dy * scale)
167
+ ctx.fill(paths.get(path), page.paths[path].rule)
168
+ }
169
+ ctx.restore()
170
+ }
171
+ }
172
+
173
+ // Balance a bounded passage without stretching its gaps. The last row may be short.
174
+ function break_rows(atoms, gaps, width) {
175
+ const n = atoms.length
176
+ const best = new Float64Array(n + 1).fill(Infinity)
177
+ const previous = new Uint32Array(n + 1)
178
+ best[0] = 0
179
+ for (let end = 1; end <= n; end++) {
180
+ let used = 0
181
+ for (let start = end - 1; start >= 0; start--) {
182
+ used += atoms[start].box[2] - atoms[start].box[0] + (start < end - 1 ? gaps[start + 1] : 0)
183
+ if (used > width + 0.001) break
184
+ const slack = width - used
185
+ const cost = best[start] + (end === n ? 0 : slack * slack) + 1
186
+ if (cost < best[end]) {
187
+ best[end] = cost
188
+ previous[end] = start
189
+ }
190
+ }
191
+ }
192
+ const rows = []
193
+ for (let end = n; end > 0;) {
194
+ const start = previous[end]
195
+ rows.push(Array.from({ length: end - start }, (_, i) => start + i))
196
+ end = start
197
+ }
198
+ return rows.reverse()
199
+ }
@@ -0,0 +1,16 @@
1
+ // Build a Canvas path without changing the decoded outline or its fill rule.
2
+ export function buildPath({ ops, pts }) {
3
+ const path = new Path2D()
4
+ let point = 0
5
+ for (const operation of ops) {
6
+ switch (operation) {
7
+ case 0: path.moveTo(pts[point++], pts[point++]); break
8
+ case 1: path.lineTo(pts[point++], pts[point++]); break
9
+ case 2: path.quadraticCurveTo(pts[point++], pts[point++], pts[point++], pts[point++]); break
10
+ case 3: path.bezierCurveTo(pts[point++], pts[point++], pts[point++], pts[point++], pts[point++], pts[point++]); break
11
+ case 4: path.closePath(); break
12
+ default: throw new Error(`Unknown QVP operation: ${operation}`)
13
+ }
14
+ }
15
+ return path
16
+ }