@quran.ws/engine 0.1.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/LICENSE ADDED
@@ -0,0 +1,172 @@
1
+ Copyright 2026 Quran.ws
2
+
3
+ This work is dedicated as a waqf for the sake of Allah, seeking His pleasure
4
+ through the benefit it brings to people. We publish it to enable the widest
5
+ possible beneficial use, with as few restrictions as possible.
6
+
7
+ Quran.ws publishes code under the MIT License, and data and content under the
8
+ Creative Commons Attribution 4.0 International License,
9
+ with a standing waiver of attribution for use inside a product.
10
+
11
+ We license only what we have the right to license; third-party material keeps
12
+ the licence and terms of its own rights holders.
13
+
14
+ Full policy: https://github.com/quran-ws/guidelines
15
+
16
+
17
+ ═══════════════════════════════════════════════════════════════════════════════
18
+ WHAT IS COVERED BY WHAT
19
+ ═══════════════════════════════════════════════════════════════════════════════
20
+
21
+ crates/ MIT
22
+ docs/ CC BY 4.0
23
+ packages/ MIT
24
+ scripts/ MIT
25
+ web/ MIT
26
+
27
+ Where a directory or file carries its own LICENSE or NOTICE, that notice
28
+ governs for that material. Names, logos and trademarks are not licensed.
29
+
30
+
31
+ ═══════════════════════════════════════════════════════════════════════════════
32
+ ATTRIBUTION WAIVED FOR USE IN PRODUCTS
33
+ ═══════════════════════════════════════════════════════════════════════════════
34
+
35
+ As rights holders of our original data and content, we grant a permanent,
36
+ worldwide, royalty-free, irrevocable waiver of the attribution requirement of
37
+ CC BY 4.0 (Section 3(a)) when the material is used within an application,
38
+ website, service, API, bot, tool, research work, or product, whether free or
39
+ commercial. This permission expands what you may do and adds no restriction to
40
+ CC BY 4.0.
41
+
42
+ Attribution is still required on republication — when the data or content
43
+ itself is made available to others as a resource in its own right. The test:
44
+ if a third party can obtain the data AS DATA from what you distribute, that is
45
+ republication, and CC BY 4.0 attribution applies.
46
+
47
+ Suggested attribution:
48
+
49
+ Data from [resource] by Quran.ws, licensed under CC BY 4.0.
50
+ Source: [canonical URL]. Version: [version]. Modified: [yes/no].
51
+
52
+ Use for training, fine-tuning, evaluating, indexing or retrieval by machine
53
+ learning systems is permitted and treated as use, not republication.
54
+
55
+
56
+ ═══════════════════════════════════════════════════════════════════════════════
57
+ THIRD-PARTY MATERIAL — KING FAHD GLORIOUS QUR'AN PRINTING COMPLEX
58
+ ═══════════════════════════════════════════════════════════════════════════════
59
+
60
+ The Qur'anic text editions, mushaf typesettings and fonts in this repository
61
+ derive from material published by the King Fahd Glorious Qur'an Printing
62
+ Complex. That material is not ours to license. It is used under the Complex's
63
+ own published usage rights, reproduced in full below in Arabic and English.
64
+
65
+ Our processing, correction, organisation or transformation of that material
66
+ creates no ownership in it and does not override these terms.
67
+
68
+
69
+ ───────────────────────────────────────────────────────────── حقوق الاستخدام ───
70
+
71
+ بناء على موافقة معالي وزير الشؤون الإسلامية والدعوة والإرشاد، المشرف العام على
72
+ مجمع الملك فهد لطباعة المصحف الشريف، يتشـرَّف المجمع بإتاحة نسخة رقمية كاملة من
73
+ مصحف المدينة النبوية المطبوع في المجمع، وذلك بالصيغ التالية:
74
+
75
+ رسم المتجهات Illustrator
76
+ وثيقة بي دي إف PDF
77
+ صور ذات جودة عالية Images
78
+ خط حاسوبي True Type Font
79
+
80
+ ويمكن استخدام النسخة المذكورة مجاناً في المجالات الشخصية والأعمال الفردية كافة،
81
+ وأعمال الجهات الحكومية والجهات والمؤسسات الخاصة والأهلية، وأعمال الطباعة
82
+ الورقية*، والنشـر المكتبي والإعلامي، ومواقع الإنترنت والبرامج الحاسوبية وغيرها،
83
+ وذلك داخل المملكة العربية السعودية وخارجها.
84
+
85
+ نسأل الله أن ينفع المسلمين بهذا العمل المبارك.
86
+
87
+ والحمد لله رب العالمين
88
+
89
+ * أعمال طباعة المصاحف داخل المملكة واستيرادها من الخارج لغرض البيع التجاري
90
+ تخضع للأمر السامي الكريم ذي الرقم 136/8 المؤرخ في 1/2/1406هـ وأكّد على ذلك
91
+ الأمر السامي الكريم رقم 9/ب/46356 بتاريخ 28/9/1424هـ. والذي وجه بمنع طباعة
92
+ المصاحف في الداخل في غير المجمع، وكذلك منع استيرادها من الخارج لغرض البيع
93
+ التجاري.
94
+
95
+
96
+ ──────────────────────────────────────────────────────────────── Copyright ────
97
+
98
+ Based on the approval of His Excellency the Minister of Islamic Affairs,
99
+ Endowments, Da'wah and Guidance, General Supervisor of the King Fahd Qur'an
100
+ Printing Complex.
101
+
102
+ The Qur'an Printing Complex is honored to present to the Muslim public a
103
+ complete free digital copy of Mus'haf al-Madinah published by the Complex, in
104
+ the following formats:
105
+
106
+ • Adobe Illustrator files
107
+ • PDF files
108
+ • High quality images
109
+ • True Type Font
110
+
111
+ Mus'haf al-Madinah in these previous formats can be used for free in all
112
+ personal, individual businesses, in works of governmental departments &
113
+ agencies, in the publications of both private and national institutions, also
114
+ suitable for Qur'an printing,* digital publishing, & for media use, can be used
115
+ also in websites, software, and other similar intermediates.
116
+
117
+ We ask Allah that He renders this blessed project beneficial to all Muslims.
118
+
119
+ * Printing of Qur'anic copies in the Kingdom, and their import from outside for
120
+ sale are subject to the Royal Decree No 136/8 dated 1/2/1406 AH which has been
121
+ reiterated by the Royal Decree No 9/B/46356 dated 28/9/1424 AH which prohibit
122
+ printing of Qur'anic copies in the Kingdom except in the Complex, likewise
123
+ their import from outside for sale.
124
+
125
+
126
+ ═══════════════════════════════════════════════════════════════════════════════
127
+ ACCURACY AND NO WARRANTY
128
+ ═══════════════════════════════════════════════════════════════════════════════
129
+
130
+ This material is provided AS IS, without warranty of any kind, as stated in
131
+ the MIT License and in CC BY 4.0 (Sections 5 and 6).
132
+
133
+ Qur'anic text, transliteration, translation, recitation and mushaf rendering
134
+ may contain errors, and releases are corrected over time. Anyone publishing
135
+ Qur'anic text to end users is responsible for verifying it against an
136
+ authorised printed mushaf and for tracking our corrections.
137
+
138
+ Corrections: corrections@quran.ws — treated as a priority.
139
+ Licensing questions: legal@quran.ws
140
+
141
+
142
+ ═══════════════════════════════════════════════════════════════════════════════
143
+ FULL LICENCE TEXTS
144
+ ═══════════════════════════════════════════════════════════════════════════════
145
+
146
+ The full CC BY 4.0 legal text is in LICENSES/CC-BY-4.0.txt and at
147
+ https://creativecommons.org/licenses/by/4.0/legalcode
148
+
149
+ MIT License
150
+
151
+ Copyright (c) 2026 Quran.ws
152
+
153
+ Permission is hereby granted, free of charge, to any person obtaining a copy
154
+ of this software and associated documentation files (the "Software"), to deal
155
+ in the Software without restriction, including without limitation the rights
156
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
157
+ copies of the Software, and to permit persons to whom the Software is
158
+ furnished to do so, subject to the following conditions:
159
+
160
+ The above copyright notice and this permission notice shall be included in all
161
+ copies or substantial portions of the Software.
162
+
163
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
164
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
165
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
166
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
167
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
168
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
169
+ SOFTWARE.
170
+
171
+ This notice is published in Arabic and English. Where the two differ, the
172
+ Arabic version governs.
@@ -0,0 +1,156 @@
1
+ Creative Commons Attribution 4.0 International
2
+
3
+ Creative Commons Corporation (“Creative Commons”) is not a law firm and does not provide legal services or legal advice. Distribution of Creative Commons public licenses does not create a lawyer-client or other relationship. Creative Commons makes its licenses and related information available on an “as-is” basis. Creative Commons gives no warranties regarding its licenses, any material licensed under their terms and conditions, or any related information. Creative Commons disclaims all liability for damages resulting from their use to the fullest extent possible.
4
+
5
+ Using Creative Commons Public Licenses
6
+
7
+ Creative Commons public licenses provide a standard set of terms and conditions that creators and other rights holders may use to share original works of authorship and other material subject to copyright and certain other rights specified in the public license below. The following considerations are for informational purposes only, are not exhaustive, and do not form part of our licenses.
8
+
9
+ Considerations for licensors: Our public licenses are intended for use by those authorized to give the public permission to use material in ways otherwise restricted by copyright and certain other rights. Our licenses are irrevocable. Licensors should read and understand the terms and conditions of the license they choose before applying it. Licensors should also secure all rights necessary before applying our licenses so that the public can reuse the material as expected. Licensors should clearly mark any material not subject to the license. This includes other CC-licensed material, or material used under an exception or limitation to copyright. More considerations for licensors.
10
+
11
+ Considerations for the public: By using one of our public licenses, a licensor grants the public permission to use the licensed material under specified terms and conditions. If the licensor’s permission is not necessary for any reason–for example, because of any applicable exception or limitation to copyright–then that use is not regulated by the license. Our licenses grant only permissions under copyright and certain other rights that a licensor has authority to grant. Use of the licensed material may still be restricted for other reasons, including because others have copyright or other rights in the material. A licensor may make special requests, such as asking that all changes be marked or described. Although not required by our licenses, you are encouraged to respect those requests where reasonable. More considerations for the public.
12
+
13
+ Creative Commons Attribution 4.0 International Public License
14
+
15
+ By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions.
16
+
17
+ Section 1 – Definitions.
18
+
19
+ a. Adapted Material means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image.
20
+
21
+ b. Adapter's License means the license You apply to Your Copyright and Similar Rights in Your contributions to Adapted Material in accordance with the terms and conditions of this Public License.
22
+
23
+ c. Copyright and Similar Rights means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights.
24
+
25
+ d. Effective Technological Measures means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements.
26
+
27
+ e. Exceptions and Limitations means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material.
28
+
29
+ f. Licensed Material means the artistic or literary work, database, or other material to which the Licensor applied this Public License.
30
+
31
+ g. Licensed Rights means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license.
32
+
33
+ h. Licensor means the individual(s) or entity(ies) granting rights under this Public License.
34
+
35
+ i. Share means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them.
36
+
37
+ j. Sui Generis Database Rights means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world.
38
+
39
+ k. You means the individual or entity exercising the Licensed Rights under this Public License. Your has a corresponding meaning.
40
+
41
+ Section 2 – Scope.
42
+
43
+ a. License grant.
44
+
45
+ 1. Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to:
46
+
47
+ A. reproduce and Share the Licensed Material, in whole or in part; and
48
+
49
+ B. produce, reproduce, and Share Adapted Material.
50
+
51
+ 2. Exceptions and Limitations. For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions.
52
+
53
+ 3. Term. The term of this Public License is specified in Section 6(a).
54
+
55
+ 4. Media and formats; technical modifications allowed. The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Section 2(a)(4) never produces Adapted Material.
56
+
57
+ 5. Downstream recipients.
58
+
59
+ A. Offer from the Licensor – Licensed Material. Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License.
60
+
61
+ B. No downstream restrictions. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material.
62
+
63
+ 6. No endorsement. Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i).
64
+
65
+ b. Other rights.
66
+
67
+ 1. Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise.
68
+
69
+ 2. Patent and trademark rights are not licensed under this Public License.
70
+
71
+ 3. To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme. In all other cases the Licensor expressly reserves any right to collect such royalties.
72
+
73
+ Section 3 – License Conditions.
74
+
75
+ Your exercise of the Licensed Rights is expressly made subject to the following conditions.
76
+
77
+ a. Attribution.
78
+
79
+ 1. If You Share the Licensed Material (including in modified form), You must:
80
+
81
+ A. retain the following if it is supplied by the Licensor with the Licensed Material:
82
+
83
+ i. identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor (including by pseudonym if designated);
84
+
85
+ ii. a copyright notice;
86
+
87
+ iii. a notice that refers to this Public License;
88
+
89
+ iv. a notice that refers to the disclaimer of warranties;
90
+
91
+ v. a URI or hyperlink to the Licensed Material to the extent reasonably practicable;
92
+
93
+ B. indicate if You modified the Licensed Material and retain an indication of any previous modifications; and
94
+
95
+ C. indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License.
96
+
97
+ 2. You may satisfy the conditions in Section 3(a)(1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information.
98
+
99
+ 3. If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable.
100
+
101
+ 4. If You Share Adapted Material You produce, the Adapter's License You apply must not prevent recipients of the Adapted Material from complying with this Public License.
102
+
103
+ Section 4 – Sui Generis Database Rights.
104
+
105
+ Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material:
106
+
107
+ a. for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database;
108
+
109
+ b. if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material; and
110
+
111
+ c. You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database.
112
+ For the avoidance of doubt, this Section 4 supplements and does not replace Your obligations under this Public License where the Licensed Rights include other Copyright and Similar Rights.
113
+
114
+ Section 5 – Disclaimer of Warranties and Limitation of Liability.
115
+
116
+ a. Unless otherwise separately undertaken by the Licensor, to the extent possible, the Licensor offers the Licensed Material as-is and as-available, and makes no representations or warranties of any kind concerning the Licensed Material, whether express, implied, statutory, or other. This includes, without limitation, warranties of title, merchantability, fitness for a particular purpose, non-infringement, absence of latent or other defects, accuracy, or the presence or absence of errors, whether or not known or discoverable. Where disclaimers of warranties are not allowed in full or in part, this disclaimer may not apply to You.
117
+
118
+ b. To the extent possible, in no event will the Licensor be liable to You on any legal theory (including, without limitation, negligence) or otherwise for any direct, special, indirect, incidental, consequential, punitive, exemplary, or other losses, costs, expenses, or damages arising out of this Public License or use of the Licensed Material, even if the Licensor has been advised of the possibility of such losses, costs, expenses, or damages. Where a limitation of liability is not allowed in full or in part, this limitation may not apply to You.
119
+
120
+ c. The disclaimer of warranties and limitation of liability provided above shall be interpreted in a manner that, to the extent possible, most closely approximates an absolute disclaimer and waiver of all liability.
121
+
122
+ Section 6 – Term and Termination.
123
+
124
+ a. This Public License applies for the term of the Copyright and Similar Rights licensed here. However, if You fail to comply with this Public License, then Your rights under this Public License terminate automatically.
125
+
126
+ b. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates:
127
+
128
+ 1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or
129
+
130
+ 2. upon express reinstatement by the Licensor.
131
+
132
+ c. For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License.
133
+
134
+ d. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License.
135
+
136
+ e. Sections 1, 5, 6, 7, and 8 survive termination of this Public License.
137
+
138
+ Section 7 – Other Terms and Conditions.
139
+
140
+ a. The Licensor shall not be bound by any additional or different terms or conditions communicated by You unless expressly agreed.
141
+
142
+ b. Any arrangements, understandings, or agreements regarding the Licensed Material not stated herein are separate from and independent of the terms and conditions of this Public License.
143
+
144
+ Section 8 – Interpretation.
145
+
146
+ a. For the avoidance of doubt, this Public License does not, and shall not be interpreted to, reduce, limit, restrict, or impose conditions on any use of the Licensed Material that could lawfully be made without permission under this Public License.
147
+
148
+ b. To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to make it enforceable. If the provision cannot be reformed, it shall be severed from this Public License without affecting the enforceability of the remaining terms and conditions.
149
+
150
+ c. No term or condition of this Public License will be waived and no failure to comply consented to unless expressly agreed to by the Licensor.
151
+
152
+ d. Nothing in this Public License constitutes or may be interpreted as a limitation upon, or waiver of, any privileges and immunities that apply to the Licensor or You, including from the legal processes of any jurisdiction or authority.
153
+
154
+ Creative Commons is not a party to its public licenses. Notwithstanding, Creative Commons may elect to apply one of its public licenses to material it publishes and in those instances will be considered the “Licensor.” Except for the limited purpose of indicating that material is shared under a Creative Commons public license or as otherwise permitted by the Creative Commons policies published at creativecommons.org/policies, Creative Commons does not authorize the use of the trademark “Creative Commons” or any other trademark or logo of Creative Commons without its prior written consent including, without limitation, in connection with any unauthorized modifications to any of its public licenses or any other arrangements, understandings, or agreements concerning use of licensed material. For the avoidance of doubt, this paragraph does not form part of the public licenses.
155
+
156
+ Creative Commons may be contacted at creativecommons.org.
package/README.md ADDED
@@ -0,0 +1,53 @@
1
+ <div align="center">
2
+
3
+ <img src=".github/banner.svg" alt="Quran Engine — Rendering, Beta" width="820">
4
+
5
+ **A rendering engine for interactive Mushaf pages that uses each platform's native graphics APIs.**
6
+
7
+ <a href="https://quran.ws/blocks/quran-engine"><img alt="See it work" src="https://img.shields.io/badge/See_it_work-15705D?style=for-the-badge&labelColor=102F29"></a>
8
+ <a href="https://quran.ws/docs/reference/quran-engine"><img alt="Documentation" src="https://img.shields.io/badge/Documentation-102F29?style=for-the-badge&labelColor=102F29"></a>
9
+
10
+ </div>
11
+
12
+ Use it when building Quran applications for mobile or desktop and you need fast, lightweight page rendering with interactive access to page elements.
13
+
14
+ > محرّك لعرض صفحات المصحف التفاعلية بكفاءة باستخدام الرسم الأصلي لكل منصة.
15
+ >
16
+ > استخدمه عند بناء تطبيقات القرآن على الجوال أو سطح المكتب عندما تحتاج عرضًا سريعًا وخفيفًا لصفحات المصحف مع إمكانية التفاعل مع عناصرها.
17
+
18
+ | | |
19
+ |---|---|
20
+ | **Package** | `@quran.ws/engine` · `0.1.0` |
21
+ | **Whole mushaf** | 38.9 MB brotli |
22
+ | **Wasm engine** | 311 KB |
23
+ | **Licence** | MIT (the code) · source bundle terms (the data) |
24
+
25
+ ```sh
26
+ # not published — build the wasm, or take it from the release
27
+ ```
28
+
29
+ ## Where the documentation is
30
+
31
+ Everything about using it lives on the site. This repository is the source.
32
+
33
+ | | |
34
+ |---|---|
35
+ | **Overview and demo** | [quran.ws/blocks/quran-engine](https://quran.ws/blocks/quran-engine) |
36
+ | **Reference** | [quran.ws/docs/reference/quran-engine](https://quran.ws/docs/reference/quran-engine) |
37
+ | **Make words clickable** | [quran.ws/docs/build/clickable-words](https://quran.ws/docs/build/clickable-words) |
38
+ | **Render on iOS, Android, Flutter and React Native** | [quran.ws/docs/build/platforms](https://quran.ws/docs/build/platforms) |
39
+ | **Work offline** | [quran.ws/docs/build/offline](https://quran.ws/docs/build/offline) |
40
+ | **Licensing in full** | [quran.ws/docs/reference/licensing](https://quran.ws/docs/reference/licensing) |
41
+
42
+ ## What is in here
43
+
44
+ | | |
45
+ |---|---|
46
+ | `crates/` | the Rust core: `qvp-format`, `qvp-core`, `qvp-convert`, `qvp-ffi` |
47
+ | `packages/` | the thin platform wrappers — iOS, Android, Flutter, React Native |
48
+ | `web/` | the reference web harness the browser demo runs on |
49
+ | `conformance/` | the gates that must stay green |
50
+ | `scripts/` | build and packaging |
51
+ | `docs/` | the API, the format, and the measured numbers |
52
+
53
+ Issues and pull requests are welcome here. Everything that is not about *changing* this repository is on the site.
package/docs/API.md ADDED
@@ -0,0 +1,228 @@
1
+ # QVP engine API
2
+
3
+ One engine, one contract. The C ABI in `crates/qvp-ffi/include/qvp.h` is the source of
4
+ truth; every wrapper (`web/qvp.js`, Kotlin, Dart, React Native, Swift) exposes the same
5
+ names in the platform's own casing, so this page documents once and applies everywhere.
6
+ Examples are JavaScript; read `page.hitTestEx(...)` as `page.hitTestEx(...)` in Dart,
7
+ `page.hitTestEx(...)` in Kotlin, `qvp_hit_test_ex(...)` in C.
8
+
9
+ **Conventions**
10
+
11
+ - **Colours** are `0xRRGGBBAA`. Alpha 0 means *hidden* in a style and *leave alone* in a theme.
12
+ Wrappers also accept `'#rgb'`, `'#rrggbb'`, `'#rrggbbaa'`.
13
+ - **Page units** are the printed page's viewBox space (345 × 550 for this mushaf, y down).
14
+ Anything named `…View` is in *viewport pixels through the current layout*.
15
+ - **Handles.** Every mutating style/highlight call returns a handle; removing the handle
16
+ undoes exactly that call and nothing else. There is a `clear`, but you never need it.
17
+ - **Targets** resolve to a word list: `'page'`, `'2:255'` (ayah), `'2:255:3'` (word),
18
+ `'2:255-257'`, `'line:7'`, `'surah:2'`, a word index, an array of word indices, or a
19
+ `T.*` constructor.
20
+ - **Selectors** say what a style rule applies to, from a whole page down to *the second
21
+ diacritic of one word*: `Sel.page()`, `Sel.word(i)`, `Sel.ayah(s,a)`, `Sel.line(n)`,
22
+ `Sel.wordBody(i)`, `Sel.wordMarks(i)`, `Sel.wordMark(i, nth)`, `Sel.wordMarkNamed(i, 'fathah', nth)`,
23
+ `Sel.wordPath(i, nth)`, `Sel.path(p)`, `Sel.mark('shaddah')`, `Sel.category('harakah')`,
24
+ `Sel.family('dots')`, `Sel.kind('mark')`, `Sel.deco('ayah-mark')`, `Sel.decoIdx(d)`.
25
+ - **The engine decides, the host draws.** Hit-testing, layout, styling, highlight bands,
26
+ masks and search are engine calls. A wrapper only marshals and paints what it is told.
27
+ - **Data is separate from code.** Pages (`NNN.qvp`), the atlas (`atlas.qva`) and the
28
+ optional text sidecars (`NNN.words.json`) are assets your app loads; no package bundles them.
29
+
30
+ ## Loading
31
+
32
+ ```js
33
+ const engine = await QvpEngine.init(wasmBytes); // native: QvpEngine(library path)
34
+ const page = engine.loadPage(await fetchBytes('pages/042.qvp'));
35
+ const atlas = engine.loadAtlas(await fetchBytes('pages/atlas.qva')); // optional
36
+ page.attachWords(await fetchJson('pages/042.words.json')); // optional forms
37
+ page.free(); atlas.free();
38
+ ```
39
+
40
+ `page.width/height/page/nLines/nAyahs/nWords/nPaths/nDecos`, `page.naturalPitch`.
41
+
42
+ ## Words, ayahs, lines, decorations
43
+
44
+ | | |
45
+ |---|---|
46
+ | `page.words[i]` | `{idx, surah, ayah, word, line, lineIdx, ayahIdx, x0,y0,x1,y1, text, firstPath, nPaths}` |
47
+ | `page.ayahs[i]` | one **fragment** per printed line: `{surah, ayah, fragment, fragments, flags, rubuAlHizb, firstWord, nWords, ayahMarkDeco, bbox}` |
48
+ | `page.lines[i]` | `{lineNo, isHeader, firstWord, nWords, bbox, bandY0, bandY1, centre}` |
49
+ | `page.decos[i]` | `{kind, surah, ayah, line, bbox, text, firstPath, nPaths}` — ayah marks, surah banners, basmalah, division rosettes, sajdah signs, page furniture |
50
+ | `page.findWord(s,a,w)` | index or −1 |
51
+ | `page.resolve(target)` | word indices in reading order |
52
+ | `page.wordForm(i, form)` | `'rasm_uthmani' \| 'rasm_imlai' \| 'qpc' \| 'rasm' \| 'search'` (derived forms need the sidecar; `hasForm(form)`) |
53
+ | `page.attachWords(json)` | attach `NNN.words.json` (`{"s:a:w": {rasm_uthmani, rasm_imlai, qpc, rasm, search}}`); returns words updated |
54
+ | `page.pathKind/Mark/Family/Category(p)`, `pathWord(p)`, `pathLine(p)`, `pathNthMark(p)` | per-path facts from the geometry table |
55
+
56
+ An ayah is several fragments. `resolve('2:255')` gives all its words on the page;
57
+ `ayahWordCount(s,a)` returns `{count, complete}` — `complete` is false when the ayah
58
+ continues on another page.
59
+
60
+ ## Metadata (no database needed)
61
+
62
+ `surahs()` → `{number, arabic, latin, english, place, ayahCount, hasBanner, hasBasmalah}`;
63
+ `divisions()` → juz/hizb/nisf/`rubu_al_hizb` that **start** on the page; `rosettes()` (drawn division
64
+ marks); `sajdahs()`; `ayahMarks()` → real ayah medallions with centre/radius and the
65
+ ornament/numeral path indices (swap or restyle them); `ayahKeys()`; `wordLabel(i)`,
66
+ `ayahLabel(i)` for screen readers.
67
+
68
+ ## Text and search
69
+
70
+ ```js
71
+ page.text('2:255') // with the mushaf's own line breaks
72
+ page.text('page', {form: 'search', wordSep: ' '})
73
+ page.search('الرحمان', {mode: 'includes'}) // [{word, wordKey, text, index, loose}]
74
+ page.citation([12, 13, 14]) // "2:255" / "2:255-257" / "2:286, 3:1"
75
+ engine.strip(s); engine.fold(s); engine.normalize(s); engine.looseKey(s)
76
+ ```
77
+
78
+ Search normalises both sides (strip marks + fold) and, when the strict pass finds
79
+ nothing, retries with the loose key so a typed `الرحمان` finds the printed `الرحمن`.
80
+ Modes: `includes`, `exact`, `prefix`. Without a sidecar it searches the stripped `rasm_uthmani`.
81
+
82
+ ## Hit testing
83
+
84
+ ```js
85
+ page.hitTestViewEx(vx, vy, {maxDistance: 6, gapBias: 0.6})
86
+ // → {word, path, deco, line, distance, exact, wordKey, ayahKey} | null
87
+ ```
88
+
89
+ Exact outline first, then **nearest with direction**: the point is resolved to a line
90
+ by its pitch band, then to a word, with a gap between two words split 60/40 towards the
91
+ preceding (right-hand) word — trailing ink is drawn *into* the following gap in this
92
+ print. `hitBoxes()` returns the same partition as boxes (no dead zones on a line);
93
+ `lineBands()` the pitch bands. `hitTest`/`hitTestView` are the exact-only variants.
94
+
95
+ ## Layout
96
+
97
+ ```js
98
+ const L = page.layout({viewportW, viewportH, padTop, padBottom, padLeft, padRight,
99
+ lineSpacing: 1.0, lineGap: 0, fillHeight: false, nominalLines: 15});
100
+ // L = {scale, ox, oy, contentW, contentH, pitch, lineDy[], slots[]}
101
+ ```
102
+
103
+ Horizontal placement is as printed; each line moves by `lineDy[line]`. Lines are never
104
+ re-spread onto a grid (printed lines are not equally tall or equally pitched, and ink
105
+ crosses into neighbouring lines): every line keeps its printed position and the same
106
+ delta is added between each pair of consecutive lines. `lineSpacing` sets that delta as
107
+ a multiple of the printed pitch (`pitch·(lineSpacing−1)`), `lineGap` adds leading in page
108
+ units, `fillHeight` picks the delta that makes the page fill the padded viewport
109
+ (pages 1–2 stay centred). `slots[]` boundaries sit halfway between neighbouring lines.
110
+ Pure helpers:
111
+ `engine.gapToFill(pageW, pageH, lines, viewW, viewH, max)` and `wastedFraction(...)`.
112
+ `wordBoxView(i)` gives a word's box in viewport px for scroll-into-view.
113
+
114
+ `nominalLines` is the grid the page is laid out *inside*, not the page's own line
115
+ count: it defaults to 15 and is clamped up to `page.nLines`, never down. A short page
116
+ laid out at 15 — al-Fatiha's 7 lines, say — is therefore centred in a full-page box and
117
+ draws at under half the height, with the rest of the viewport left empty. That is the
118
+ spec working, not a rendering bug. Pass `nominalLines: page.nLines` when you want the
119
+ page to fill what you gave it, and keep 15 only when several pages must share one grid.
120
+
121
+ ## Styles
122
+
123
+ ```js
124
+ const h = page.style(Sel.wordMark(w, 1), '#1a73e8', {ms: 200}); // the 2nd diacritic only
125
+ page.styleTarget('2:255', '#0a7d32', {layer: LAYER.HIGHLIGHT});
126
+ page.hide(Sel.kind('mark')); // reading view without tashkil
127
+ page.theme({ink: '#e8e4dc', diacritics: '#7fb0e8', dots: '#ff8a80', ayahMark: '#b8860b', ms: 300});
128
+ page.restyle(h, '#ff0000', 100); page.unstyle(h); page.setDefaultInk('#231f20');
129
+ ```
130
+
131
+ Rules live in **layers** (`LAYER.BASE 0`, `THEME 10`, `HIGHLIGHT 50`, `SELECTION 60`,
132
+ `TOP 100`, or any integer). Resolution per path: highest layer wins, then the most
133
+ specific selector, then the newest rule. Every rule carries a `ms` transition; colours
134
+ fade on the engine clock. Precedence around rules: masked words are hidden above
135
+ everything; the greyed-page reveal sits below rules and above the default ink.
136
+
137
+ ## Clock and display list
138
+
139
+ ```js
140
+ function frame(now) {
141
+ const moving = page.tick(now); // advance fades and band slides
142
+ renderer.draw(page, view, dpr); // bands → base ink → styled ink → mask boxes
143
+ if (moving) requestAnimationFrame(frame);
144
+ }
145
+ ```
146
+
147
+ `Renderer.draw()` calls `page.buildPaths()` for you, and `buildPaths()` memoises, so a
148
+ consumer using the supplied renderer never calls it directly. Call it yourself only when
149
+ you write your own renderer on top of `paint()`/`styled()`: it turns the page's op/point
150
+ arrays into the path objects those lists index, and drawing without it has nothing to
151
+ fill.
152
+
153
+ `buildPaths()` constructs `Path2D`, and so does `drawBoxes()` — `Path2D` is a DOM
154
+ interface, so **everything from `buildPaths()` down is browser-only**. In Node or in a
155
+ worker without a DOM the call fails at its first path:
156
+
157
+ ```console
158
+ $ node -e 'new Path2D()'
159
+ ReferenceError: Path2D is not defined
160
+ ```
161
+
162
+ Everything above drawing is pure wasm and runs anywhere: loading, `words`/`ayahs`/`lines`,
163
+ hit-testing, `layout()`, styles, `paint()` and `styled()` themselves. So a server-side or
164
+ worker consumer can use the engine for everything except the final fill, and should stop
165
+ at the display list. The native bindings build paths against their own platform types
166
+ (`CGPath`, `android.graphics.Path`, `ui.Path`) and have no such restriction.
167
+
168
+ `paint()` is the full display list (a colour per path); `styled()` lists only the
169
+ paths that differ from the default ink, which is what the cached-base-layer renderer
170
+ repaints. `highlightBoxes()` and `maskBoxes()` are viewport-px rectangles.
171
+
172
+ ## Highlights
173
+
174
+ ```js
175
+ const h = page.highlight('2:255', {mode: 'both', ink: '#0a7d32', band: '#0a7d3224',
176
+ height: 'pitch', padX: 1.2, radius: 1.5, seam: 0.25, ms: 250});
177
+ page.rehighlight(h, '2:256'); // the band slides to the new words, ink cross-fades
178
+ page.restyleHighlight(h, {...}); // recolour in place
179
+ page.unhighlight(h); // fades out, then disappears
180
+ ```
181
+
182
+ `mode` is `ink`, `band` or `both`. A band is **one path per highlight** covering every
183
+ printed line the words occupy, with a `seam` overlap so a six-line ayah reads as one
184
+ shape and not six stripes; height is the line pitch or the words' ink. Use one handle
185
+ and `rehighlight` for word-by-word following.
186
+
187
+ ## Selection
188
+
189
+ `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.
191
+
192
+ ## Memorisation
193
+
194
+ ```js
195
+ page.mask('2:255', 'hide' | 'block' | 'blur'); page.revealNext(1); page.hideBack(1);
196
+ page.revealWord(i); page.revealAll(); page.hideAll(); page.unmask(); page.maskHidden()
197
+ const steps = page.revealStart({lit: 2, byAyah: false, grey: '#c9c4b8', ink: '#231f20', ayahMarks: true, ms: 150});
198
+ page.revealGoto(at); page.revealStop();
199
+ ```
200
+
201
+ `hide` keeps the page's shape (ink alpha 0). `block`/`blur` keep the ink and hand the
202
+ host `maskBoxes()` to draw over. The greyed-page reveal lights a window of `lit` steps
203
+ ending at `at`; a medallion lights with the ayah it closes.
204
+
205
+ ## Recitation
206
+
207
+ `reciteMap(s, a, nSegments)` returns the words to pair with `nSegments` timings, or
208
+ `null` when the counts disagree — then follow the ayah whole rather than drift.
209
+ Drive the highlight with `rehighlight(h, T.word(i))`.
210
+
211
+ ## Crop and export
212
+
213
+ `cropBox(target, {pad, keepAyahMarks})`; `cropSvg(target, {pad, keepAyahMarks, background})`
214
+ returns a standalone SVG string with the current colours (masks, themes and highlights'
215
+ ink applied). The medallion is kept only when the whole ayah is inside the crop.
216
+
217
+ ## Atlas (cross-page)
218
+
219
+ `atlas.pageOf(s,a)`, `pageRange(page)`, `surah(n)`, `surahs()`, `pageOfSurah(n)`,
220
+ `juz(n)/hizb(n)/rubuAlHizb(n)` → `{surah, ayah, page}`, `juzAt(s,a)`, `divisionAt(kind, s, a)`,
221
+ `pagesOfJuz(n)`, `findSurah('cow' | 'البقرة' | '2')`.
222
+
223
+ ## C ABI notes
224
+
225
+ Struct layouts, enums and every function signature are in `qvp.h`. Arrays are
226
+ returned through `(out, cap)` and the call returns the total count; strings through
227
+ `QvpStr {ptr, len}` valid until the next string-returning call on the same thread.
228
+ `qvp_alloc/qvp_dealloc` exist for hosts without `malloc` (wasm).
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@quran.ws/engine",
3
+ "version": "0.1.0",
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
+ "license": "MIT",
6
+ "homepage": "https://quran.ws/blocks/quran-engine",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/quran-ws/quran-engine.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/quran-ws/quran-engine/issues"
13
+ },
14
+ "keywords": [
15
+ "quran",
16
+ "mushaf",
17
+ "arabic",
18
+ "wasm",
19
+ "canvas",
20
+ "rendering",
21
+ "qvp"
22
+ ],
23
+ "type": "module",
24
+ "sideEffects": [
25
+ "./web/qvp.js",
26
+ "./web/index.mjs",
27
+ "./web/index.cjs"
28
+ ],
29
+ "exports": {
30
+ ".": {
31
+ "import": "./web/index.mjs",
32
+ "require": "./web/index.cjs"
33
+ },
34
+ "./qvp.js": "./web/qvp.js",
35
+ "./qvp_ffi.wasm": "./web/qvp_ffi.wasm",
36
+ "./package.json": "./package.json"
37
+ },
38
+ "main": "./web/index.cjs",
39
+ "module": "./web/index.mjs",
40
+ "files": [
41
+ "web/index.mjs",
42
+ "web/index.cjs",
43
+ "web/qvp.js",
44
+ "web/qvp_ffi.wasm",
45
+ "docs/API.md",
46
+ "README.md",
47
+ "LICENSE",
48
+ "LICENSES/"
49
+ ],
50
+ "publishConfig": {
51
+ "access": "public"
52
+ },
53
+ "engines": {
54
+ "node": ">=18"
55
+ },
56
+ "scripts": {
57
+ "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
+ "prepack": "npm run build:wasm",
59
+ "test": "node web/smoke.mjs"
60
+ }
61
+ }
package/web/index.cjs ADDED
@@ -0,0 +1,10 @@
1
+ // CommonJS entry for @quran.ws/engine. See index.mjs for why this shim exists.
2
+ require('./qvp.js');
3
+
4
+ const QVP = globalThis.QVP;
5
+ if (!QVP) throw new Error('@quran.ws/engine: qvp.js did not define globalThis.QVP');
6
+
7
+ /** Absolute path of the bundled `qvp_ffi.wasm`, for fs/fetch. */
8
+ QVP.wasmPath = require('node:path').join(__dirname, 'qvp_ffi.wasm');
9
+
10
+ module.exports = QVP;
package/web/index.mjs ADDED
@@ -0,0 +1,21 @@
1
+ // ESM entry for @quran.ws/engine.
2
+ //
3
+ // qvp.js is the reference wrapper and is written as an IIFE that assigns a
4
+ // single global, so that a plain <script> tag works with no build step. That
5
+ // shape cannot be imported, so this file loads it for its side effect and
6
+ // re-exports what it defined. The wrapper itself is unchanged.
7
+ import './qvp.js';
8
+
9
+ const QVP = globalThis.QVP;
10
+ if (!QVP) throw new Error('@quran.ws/engine: qvp.js did not define globalThis.QVP');
11
+
12
+ export const {
13
+ QvpEngine, QvpPage, QvpAtlas, CanvasRenderer,
14
+ Sel, T, KIND, FAMILY, CATEGORY, DECO, FORM, LAYER, MARK, MARKS, NONE,
15
+ css, rgba, parseTarget,
16
+ } = QVP;
17
+
18
+ /** Absolute URL of the bundled `qvp_ffi.wasm`, for fetch/instantiate. */
19
+ export const wasmUrl = new URL('./qvp_ffi.wasm', import.meta.url);
20
+
21
+ export default QVP;
package/web/qvp.js ADDED
@@ -0,0 +1,492 @@
1
+ // QVP web wrapper — the reference thin wrapper over the C ABI (qvp_ffi.wasm).
2
+ // Host renderer: Canvas2D. Everything that decides (hit-testing, layout, styles,
3
+ // highlights, masks, search, text) lives in the engine; this file only marshals
4
+ // and draws. API shape is mirrored by the Kotlin, Dart and React Native wrappers.
5
+ (function (global) {
6
+ 'use strict';
7
+
8
+ const NONE = 0xffffffff;
9
+ const KIND = { BODY: 0, MARK: 1, AYAH_NUMBER: 2, AYAH_MARK_ORNAMENT: 3, HEADER_INK: 4, ORNAMENT: 5, PAGE_NUMBER: 6, RUNNING_HEAD: 7, OTHER: 255 };
10
+ const FAMILY = { NONE: 0, DIACRITIC: 1, TANWIN: 2, DOTS: 3, WAQF: 4, SIFR: 5, SAJDAH: 6, READING_SIGN: 7 };
11
+ const CATEGORY = { NONE: 0, HARAKAH: 1, TANWIN: 2, LETTER_DOT: 3, ORTHOGRAPHIC: 4, DABT: 5, WAQF: 6, READING_SIGN: 7, STANDALONE: 8 };
12
+ const DECO = { AYAH_MARK: 0, SURAH_NAME: 1, BASMALAH: 2, DIVISION_MARK: 3, SAJDAH_MARK: 4, PAGE_NUMBER: 5, RUNNING_HEAD: 6, OTHER: 255 };
13
+ const FORM = { rasm_uthmani: 0, rasm_imlai: 1, qpc: 2, rasm: 3, search: 4 };
14
+ const LAYER = { BASE: 0, THEME: 10, HIGHLIGHT: 50, SELECTION: 60, TOP: 100 };
15
+ const MARKS = ['', 'fathah', 'kasrah', 'dammah', 'tanwin_al_fath', 'tanwin_al_kasr', 'tanwin_al_damm', 'shaddah', 'sukun', 'maddah', 'hamzah', 'hamzat_al_wasl', 'omitted_alif', 'small_waw', 'small_yaa', 'small_noon', 'dot', 'two_dots', 'three_dots', 'rounded_zero', 'rectangular_zero', 'waqf_jaiz_mustawi_al_tarafayn', 'waqf_jaiz_waqf_awla', 'waqf_jaiz_wasl_awla', 'waqf_lazim', 'waqf_al_muanaqah', 'saktah', 'small_meem', 'hizb', 'sajdah', 'sajdah_mark', 'sajdah_line', 'seen_al_qiraah', 'tashil', 'ishmam', 'imalah'];
16
+ const MARK = Object.fromEntries(MARKS.map((n, i) => [n, i]).filter(([n]) => n));
17
+
18
+ // colours: numbers are 0xRRGGBBAA; strings '#rgb', '#rrggbb', '#rrggbbaa'
19
+ function rgba(c, alpha) {
20
+ if (typeof c === 'number') return c >>> 0;
21
+ if (typeof c !== 'string') return 0;
22
+ let h = c.trim().replace('#', '');
23
+ if (h.length === 3 || h.length === 4) h = [...h].map(x => x + x).join('');
24
+ if (h.length === 6) h += alpha !== undefined ? Math.round(alpha * 255).toString(16).padStart(2, '0') : 'ff';
25
+ return parseInt(h, 16) >>> 0;
26
+ }
27
+ const css = c => `rgba(${(c >>> 24) & 255},${(c >>> 16) & 255},${(c >>> 8) & 255},${(c & 255) / 255})`;
28
+
29
+ // Selectors (what a style rule applies to)
30
+ const Sel = {
31
+ page: () => ({ kind: 0 }),
32
+ path: i => ({ kind: 1, a: i }),
33
+ wordPath: (w, n) => ({ kind: 2, a: w, b: n }),
34
+ wordMark: (w, n) => ({ kind: 3, a: w, b: n }), // nth mark of the word (0-based)
35
+ wordMarkNamed: (w, mark, n = 0) => ({ kind: 4, a: w, b: markId(mark), c: n }),
36
+ wordBody: w => ({ kind: 5, a: w }),
37
+ wordMarks: w => ({ kind: 6, a: w }),
38
+ word: w => ({ kind: 7, a: w }),
39
+ ayah: (s, a) => ({ kind: 8, a: s, b: a }),
40
+ line: n => ({ kind: 9, a: n }),
41
+ mark: m => ({ kind: 10, a: markId(m) }),
42
+ category: c => ({ kind: 11, a: typeof c === 'string' ? CATEGORY[c.toUpperCase().replace('-', '_')] : c }),
43
+ family: f => ({ kind: 12, a: typeof f === 'string' ? FAMILY[f.toUpperCase().replace('-', '_')] : f }),
44
+ kind: k => ({ kind: 13, a: typeof k === 'string' ? KIND[k.toUpperCase()] : k }),
45
+ deco: k => ({ kind: 14, a: typeof k === 'string' ? DECO[k.toUpperCase().replace('-', '_')] : k }),
46
+ decoIdx: i => ({ kind: 15, a: i }),
47
+ };
48
+ const markId = m => (typeof m === 'string' ? (MARK[m] ?? 255) : m);
49
+
50
+ // Targets (what resolves to a word list). Strings: 'page', '2:255', '2:255:3', '2:255-257', 'line:7', 'surah:2'
51
+ const T = {
52
+ page: () => ({ kind: 0 }),
53
+ word: i => ({ kind: 1, a: i }),
54
+ words: ws => ({ kind: 2, words: ws }),
55
+ ayah: (s, a) => ({ kind: 3, a: s, b: a }),
56
+ ayahRange: (s, a, b) => ({ kind: 4, a: s, b: a, c: b }),
57
+ line: n => ({ kind: 5, a: n }),
58
+ surah: s => ({ kind: 6, a: s }),
59
+ range: (a, b) => ({ kind: 7, a, b }),
60
+ };
61
+
62
+ class QvpEngine {
63
+ static async init(wasmBytes) {
64
+ const { instance } = await WebAssembly.instantiate(wasmBytes, {});
65
+ return new QvpEngine(instance);
66
+ }
67
+ constructor(instance) {
68
+ this.ex = instance.exports;
69
+ this.mem = this.ex.memory;
70
+ this.scratch = this.ex.qvp_alloc(1 << 16); // 64 KB for outputs
71
+ this.scratch2 = this.ex.qvp_alloc(1 << 16); // 64 KB for inputs
72
+ this.td = new TextDecoder();
73
+ this.te = new TextEncoder();
74
+ }
75
+ dv() { return new DataView(this.mem.buffer); }
76
+ str(ptr, len) { return this.td.decode(new Uint8Array(this.mem.buffer, ptr, len)); }
77
+ qstr(at) { const d = this.dv(); return this.str(d.getUint32(at, true), d.getUint32(at + 4, true)); }
78
+ /** write a JS string into scratch2 at offset; returns [ptr, len] */
79
+ putStr(s, off = 0) { const b = this.te.encode(s); new Uint8Array(this.mem.buffer, this.scratch2 + off, b.length).set(b); return [this.scratch2 + off, b.length]; }
80
+ putU32(arr, off = 0) { const p = this.scratch2 + off; new Uint32Array(this.mem.buffer, p, arr.length).set(arr); return p; }
81
+ nameOf(fn, v) { this.ex[fn](v, this.scratch); return this.qstr(this.scratch); }
82
+ markName(m) { return MARKS[m] || 'unknown'; }
83
+ familyName(f) { return this.nameOf('qvp_family_name', f); }
84
+ kindName(k) { return this.nameOf('qvp_kind_name', k); }
85
+ categoryName(c) { return this.nameOf('qvp_category_name', c); }
86
+ markCategory(m) { return this.ex.qvp_mark_category(markId(m)); }
87
+ /** Arabic text tools */
88
+ strip(s) { return this._arabic(0, s); }
89
+ fold(s) { return this._arabic(1, s); }
90
+ normalize(s) { return this._arabic(2, s); }
91
+ looseKey(s) { return this._arabic(3, s); }
92
+ _arabic(kind, s) { const [p, n] = this.putStr(s); this.ex.qvp_arabic(kind, p, n, this.scratch); return this.qstr(this.scratch); }
93
+ loadPage(bytes) {
94
+ const p = this.ex.qvp_alloc(bytes.length);
95
+ new Uint8Array(this.mem.buffer, p, bytes.length).set(bytes);
96
+ const h = this.ex.qvp_page_load(p, bytes.length);
97
+ this.ex.qvp_dealloc(p, bytes.length);
98
+ if (!h) throw new Error('qvp_page_load failed');
99
+ return new QvpPage(this, h);
100
+ }
101
+ loadAtlas(bytes) {
102
+ const p = this.ex.qvp_alloc(bytes.length);
103
+ new Uint8Array(this.mem.buffer, p, bytes.length).set(bytes);
104
+ const h = this.ex.qvp_atlas_load(p, bytes.length);
105
+ this.ex.qvp_dealloc(p, bytes.length);
106
+ if (!h) throw new Error('qvp_atlas_load failed');
107
+ return new QvpAtlas(this, h);
108
+ }
109
+ gapToFill(pageW, pageH, lines, viewW, viewH, max = 0) { return this.ex.qvp_gap_to_fill(pageW, pageH, lines, viewW, viewH, max); }
110
+ wastedFraction(pageW, pageH, viewW, viewH) { return this.ex.qvp_wasted_fraction(pageW, pageH, viewW, viewH); }
111
+ }
112
+
113
+ // struct writers (wasm32 layouts, see qvp.h)
114
+ function writeTarget(e, at, t) {
115
+ if (typeof t === 'string') t = parseTarget(t);
116
+ const d = e.dv();
117
+ d.setUint8(at, t.kind); d.setUint32(at + 4, t.a >>> 0 || 0, true); d.setUint32(at + 8, t.b >>> 0 || 0, true); d.setUint32(at + 12, t.c >>> 0 || 0, true);
118
+ let wp = 0, wn = 0;
119
+ if (t.words) { wp = e.putU32(Uint32Array.from(t.words), 1024); wn = t.words.length; }
120
+ d.setUint32(at + 16, wp, true); d.setUint32(at + 20, wn, true);
121
+ return at;
122
+ }
123
+ function parseTarget(s) {
124
+ if (s === 'page') return T.page();
125
+ let m;
126
+ if ((m = /^line:(\d+)$/.exec(s))) return T.line(+m[1]);
127
+ if ((m = /^surah:(\d+)$/.exec(s))) return T.surah(+m[1]);
128
+ if ((m = /^(\d+):(\d+)-(\d+)$/.exec(s))) return T.ayahRange(+m[1], +m[2], +m[3]);
129
+ if ((m = /^(\d+):(\d+):(\d+)$/.exec(s))) return { kind: 1, wordKey: [+m[1], +m[2], +m[3]] };
130
+ if ((m = /^(\d+):(\d+)$/.exec(s))) return T.ayah(+m[1], +m[2]);
131
+ throw new Error('bad target ' + s);
132
+ }
133
+ function writeSel(e, at, s) {
134
+ const d = e.dv();
135
+ d.setUint8(at, s.kind); d.setUint32(at + 4, s.a >>> 0 || 0, true); d.setUint32(at + 8, s.b >>> 0 || 0, true); d.setUint32(at + 12, s.c >>> 0 || 0, true);
136
+ return at;
137
+ }
138
+ const HL_DEFAULT = { mode: 'band', height: 'pitch', ink: '#1a73e8', band: rgba('#d6a326', 0.3), padX: 1.2, padY: 0, radius: 0, seam: 0.25, ms: 0, layer: LAYER.HIGHLIGHT };
139
+ function writeHl(e, at, st) {
140
+ st = { ...HL_DEFAULT, ...st };
141
+ const d = e.dv();
142
+ d.setUint8(at, { ink: 0, band: 1, both: 2 }[st.mode] ?? 1); d.setUint8(at + 1, st.height === 'ink' ? 1 : 0);
143
+ d.setUint32(at + 4, rgba(st.ink), true); d.setUint32(at + 8, rgba(st.band), true);
144
+ d.setFloat32(at + 12, st.padX, true); d.setFloat32(at + 16, st.padY, true); d.setFloat32(at + 20, st.radius, true); d.setFloat32(at + 24, st.seam, true);
145
+ d.setUint32(at + 28, st.ms, true); d.setInt32(at + 32, st.layer, true);
146
+ return at;
147
+ }
148
+ function readBoxes(e, at, n) {
149
+ const d = e.dv(), out = new Array(n);
150
+ for (let i = 0; i < n; i++) { const o = at + i * 32; out[i] = { id: d.getUint32(o, true), line: d.getUint32(o + 4, true), x0: d.getFloat32(o + 8, true), y0: d.getFloat32(o + 12, true), x1: d.getFloat32(o + 16, true), y1: d.getFloat32(o + 20, true), color: d.getUint32(o + 24, true), radius: d.getFloat32(o + 28, true) }; }
151
+ return out;
152
+ }
153
+
154
+ class QvpPage {
155
+ constructor(engine, handle) {
156
+ this.e = engine; this.h = handle;
157
+ const ex = engine.ex, s = engine.scratch;
158
+ ex.qvp_page_info(handle, s);
159
+ let d = engine.dv();
160
+ this.width = d.getFloat32(s, true); this.height = d.getFloat32(s + 4, true); this.page = d.getUint32(s + 8, true);
161
+ this.nLines = d.getUint32(s + 12, true); this.nAyahs = d.getUint32(s + 16, true); this.nWords = d.getUint32(s + 20, true); this.nPaths = d.getUint32(s + 24, true); this.nDecos = d.getUint32(s + 28, true);
162
+ ex.qvp_geometry(handle, s);
163
+ d = engine.dv();
164
+ const opsPtr = d.getUint32(s, true), opsLen = d.getUint32(s + 4, true), ptsPtr = d.getUint32(s + 8, true), ptsLen = d.getUint32(s + 12, true), tabPtr = d.getUint32(s + 16, true), nPaths = d.getUint32(s + 20, true);
165
+ this.ops = new Uint8Array(engine.mem.buffer, opsPtr, opsLen).slice();
166
+ this.pts = new Float32Array(engine.mem.buffer.slice(ptsPtr, ptsPtr + ptsLen * 4));
167
+ this.table = new Uint32Array(engine.mem.buffer.slice(tabPtr, tabPtr + nPaths * 32));
168
+ this.paths = null;
169
+ this.words = Array.from({ length: this.nWords }, (_, i) => this._word(i));
170
+ this.ayahs = Array.from({ length: this.nAyahs }, (_, i) => this._ayah(i));
171
+ this.lines = Array.from({ length: this.nLines }, (_, i) => this._line(i));
172
+ this.decos = Array.from({ length: this.nDecos }, (_, i) => this._deco(i));
173
+ this.naturalPitch = ex.qvp_natural_pitch(handle);
174
+ this.currentLayout = null;
175
+ this._defaultInk = 0x231f20ff;
176
+ }
177
+ free() { this.e.ex.qvp_page_free(this.h); this.h = 0; }
178
+
179
+ // ── geometry ──
180
+ pathFlags(i) { return this.table[i * 8 + 4]; }
181
+ pathKind(i) { return this.pathFlags(i) & 0xff; }
182
+ pathMark(i) { return (this.pathFlags(i) >> 8) & 0xff; }
183
+ pathFamily(i) { return (this.pathFlags(i) >> 16) & 0xff; }
184
+ pathEvenOdd(i) { return ((this.pathFlags(i) >> 24) & 1) === 1; }
185
+ pathWord(i) { const w = this.table[i * 8 + 5]; return w === NONE ? -1 : w; }
186
+ pathLine(i) { return this.table[i * 8 + 6]; }
187
+ pathCategory(i) { return this.table[i * 8 + 7] & 0xff; }
188
+ pathNthInWord(i) { return (this.table[i * 8 + 7] >> 8) & 0xff; }
189
+ pathNthMark(i) { const n = (this.table[i * 8 + 7] >> 16) & 0xff; return n === 0xff ? -1 : n; }
190
+ buildPaths() {
191
+ if (this.paths) return this.paths;
192
+ const t = this.table, ops = this.ops, pts = this.pts, out = new Array(this.nPaths);
193
+ for (let i = 0; i < this.nPaths; i++) {
194
+ const p = new Path2D();
195
+ let o = t[i * 8], oe = o + t[i * 8 + 1], k = t[i * 8 + 2];
196
+ for (; o < oe; o++) {
197
+ switch (ops[o]) {
198
+ case 0: p.moveTo(pts[k], pts[k + 1]); k += 2; break;
199
+ case 1: p.lineTo(pts[k], pts[k + 1]); k += 2; break;
200
+ case 2: p.quadraticCurveTo(pts[k], pts[k + 1], pts[k + 2], pts[k + 3]); k += 4; break;
201
+ case 3: p.bezierCurveTo(pts[k], pts[k + 1], pts[k + 2], pts[k + 3], pts[k + 4], pts[k + 5]); k += 6; break;
202
+ case 4: p.closePath(); break;
203
+ }
204
+ }
205
+ out[i] = p;
206
+ }
207
+ return (this.paths = out);
208
+ }
209
+
210
+ // ── info records ──
211
+ _word(i) {
212
+ const ex = this.e.ex, s = this.e.scratch; if (!ex.qvp_word_info(this.h, i, s)) return null;
213
+ const d = this.e.dv();
214
+ return { idx: i, surah: d.getUint16(s, true), ayah: d.getUint16(s + 2, true), word: d.getUint16(s + 4, true), line: d.getUint16(s + 6, true), ayahIdx: d.getUint32(s + 8, true), lineIdx: d.getUint32(s + 12, true),
215
+ x0: d.getFloat32(s + 16, true), y0: d.getFloat32(s + 20, true), x1: d.getFloat32(s + 24, true), y1: d.getFloat32(s + 28, true), text: this.e.qstr(s + 32), firstPath: d.getUint32(s + 40, true), nPaths: d.getUint32(s + 44, true) };
216
+ }
217
+ _ayah(i) {
218
+ const ex = this.e.ex, s = this.e.scratch; if (!ex.qvp_ayah_info(this.h, i, s)) return null;
219
+ const d = this.e.dv();
220
+ return { idx: i, surah: d.getUint16(s, true), ayah: d.getUint16(s + 2, true), fragment: d.getUint8(s + 4), fragments: d.getUint8(s + 5), flags: d.getUint8(s + 6), rubuAlHizb: d.getUint16(s + 8, true),
221
+ firstWord: d.getUint32(s + 12, true), nWords: d.getUint32(s + 16, true), ayahMarkDeco: d.getUint32(s + 20, true), x0: d.getFloat32(s + 24, true), y0: d.getFloat32(s + 28, true), x1: d.getFloat32(s + 32, true), y1: d.getFloat32(s + 36, true) };
222
+ }
223
+ _line(i) {
224
+ const ex = this.e.ex, s = this.e.scratch; if (!ex.qvp_line_info(this.h, i, s)) return null;
225
+ const d = this.e.dv();
226
+ return { idx: i, lineNo: d.getUint8(s), isHeader: !!d.getUint8(s + 1), firstWord: d.getUint32(s + 4, true), nWords: d.getUint32(s + 8, true), x0: d.getFloat32(s + 12, true), y0: d.getFloat32(s + 16, true), x1: d.getFloat32(s + 20, true), y1: d.getFloat32(s + 24, true),
227
+ bandY0: d.getFloat32(s + 28, true), bandY1: d.getFloat32(s + 32, true), centre: d.getFloat32(s + 36, true) };
228
+ }
229
+ _deco(i) {
230
+ const ex = this.e.ex, s = this.e.scratch; if (!ex.qvp_deco_info(this.h, i, s)) return null;
231
+ const d = this.e.dv();
232
+ return { idx: i, kind: d.getUint8(s), surah: d.getUint16(s + 2, true), ayah: d.getUint16(s + 4, true), line: d.getUint32(s + 8, true), x0: d.getFloat32(s + 12, true), y0: d.getFloat32(s + 16, true), x1: d.getFloat32(s + 20, true), y1: d.getFloat32(s + 24, true),
233
+ text: this.e.qstr(s + 28), firstPath: d.getUint32(s + 36, true), nPaths: d.getUint32(s + 40, true) };
234
+ }
235
+ wordForm(i, form = 'rasm_uthmani') { const s = this.e.scratch; if (!this.e.ex.qvp_word_form(this.h, i, FORM[form] ?? 0, s)) return ''; return this.e.qstr(s); }
236
+ findWord(surah, ayah, word) { const i = this.e.ex.qvp_find_word(this.h, surah, ayah, word); return i < 0 ? -1 : i; }
237
+ _target(t) {
238
+ if (typeof t === 'string') t = parseTarget(t);
239
+ if (t.wordKey) { const i = this.findWord(...t.wordKey); t = i >= 0 ? T.word(i) : T.words([]); }
240
+ if (t instanceof Array) t = T.words(t);
241
+ return writeTarget(this.e, this.e.scratch2 + 4096, t);
242
+ }
243
+ resolve(t) { const n = this.e.ex.qvp_resolve(this.h, this._target(t), this.e.scratch, 16384); return Array.from(new Uint32Array(this.e.mem.buffer, this.e.scratch, Math.min(n, 16384))); }
244
+
245
+ // ── metadata ──
246
+ surahs() {
247
+ const ex = this.e.ex, s = this.e.scratch, n = ex.qvp_surahs_count(this.h), out = [];
248
+ for (let i = 0; i < n; i++) { ex.qvp_surah_at(this.h, i, s); const d = this.e.dv();
249
+ out.push({ number: d.getUint16(s, true), ayahCount: d.getUint16(s + 2, true), hasBanner: !!d.getUint8(s + 4), hasBasmalah: !!d.getUint8(s + 5), place: ['makkah', 'madinah'][d.getUint8(s + 6)] || '', bannerDeco: d.getUint32(s + 8, true), arabic: this.e.qstr(s + 12), latin: this.e.qstr(s + 20), english: this.e.qstr(s + 28) }); }
250
+ return out;
251
+ }
252
+ divisions() {
253
+ const ex = this.e.ex, s = this.e.scratch, n = ex.qvp_divisions(this.h, s, 64), d = this.e.dv(), out = [];
254
+ for (let i = 0; i < Math.min(n, 64); i++) { const o = s + i * 12; out.push({ kind: ['juz', 'hizb', 'nisf', 'rubu_al_hizb'][d.getUint8(o)], line: d.getUint8(o + 1), n: d.getUint16(o + 2, true), surah: d.getUint16(o + 4, true), ayah: d.getUint16(o + 6, true), ayahIdx: d.getUint32(o + 8, true) }); }
255
+ return out;
256
+ }
257
+ ayahMarks() {
258
+ const ex = this.e.ex, s = this.e.scratch, n = ex.qvp_ayah_marks(this.h, s, 128), d = this.e.dv(), out = [];
259
+ for (let i = 0; i < Math.min(n, 128); i++) { const o = s + i * 32; out.push({ deco: d.getUint32(o, true), surah: d.getUint16(o + 4, true), ayah: d.getUint16(o + 6, true), line: d.getUint32(o + 8, true), cx: d.getFloat32(o + 12, true), cy: d.getFloat32(o + 16, true), r: d.getFloat32(o + 20, true), ornamentPath: d.getUint32(o + 24, true), numeralPath: d.getUint32(o + 28, true) }); }
260
+ return out;
261
+ }
262
+ rosettes() {
263
+ const ex = this.e.ex, s = this.e.scratch, n = ex.qvp_rosettes(this.h, s, 32), d = this.e.dv(), out = [];
264
+ for (let i = 0; i < Math.min(n, 32); i++) { const o = s + i * 20; out.push({ deco: d.getUint32(o, true), surah: d.getUint16(o + 4, true), ayah: d.getUint16(o + 6, true), juz: d.getUint16(o + 8, true), hizb: d.getUint16(o + 10, true), nisf: d.getUint16(o + 12, true), rubuAlHizb: d.getUint16(o + 14, true), rubuAlHizbInHizb: d.getUint16(o + 16, true) }); }
265
+ return out;
266
+ }
267
+ sajdahs() {
268
+ const ex = this.e.ex, s = this.e.scratch, n = ex.qvp_sajdahs(this.h, s, 16), d = this.e.dv(), out = [];
269
+ for (let i = 0; i < Math.min(n, 16); i++) { const o = s + i * 12; out.push({ deco: d.getUint32(o, true), surah: d.getUint16(o + 4, true), ayah: d.getUint16(o + 6, true), signPath: d.getUint32(o + 8, true) }); }
270
+ return out;
271
+ }
272
+ ayahKeys() { const n = this.e.ex.qvp_ayah_keys(this.h, this.e.scratch, 256); const v = new Uint32Array(this.e.mem.buffer, this.e.scratch, Math.min(n, 256)); return Array.from(v, k => [k >>> 16, k & 0xffff]); }
273
+ ayahWordCount(surah, ayah) { const n = this.e.ex.qvp_ayah_word_count(this.h, surah, ayah, this.e.scratch); return { count: n, complete: !!this.e.dv().getUint32(this.e.scratch, true) }; }
274
+ /** words for n recitation segments, or null when the counts disagree (follow the ayah whole) */
275
+ reciteMap(surah, ayah, nSegments) { const n = this.e.ex.qvp_recite_map(this.h, surah, ayah, nSegments, this.e.scratch, 4096); return n < 0 ? null : Array.from(new Uint32Array(this.e.mem.buffer, this.e.scratch, n)); }
276
+ wordLabel(i) { this.e.ex.qvp_word_label(this.h, i, this.e.scratch); return this.e.qstr(this.e.scratch); }
277
+ ayahLabel(i) { this.e.ex.qvp_ayah_label(this.h, i, this.e.scratch); return this.e.qstr(this.e.scratch); }
278
+
279
+ // ── text & search ──
280
+ text(target = 'page', { form = 'rasm_uthmani', wordSep = ' ', lineSep = '\n' } = {}) {
281
+ const [wp, wn] = this.e.putStr(wordSep, 0), [lp, ln] = this.e.putStr(lineSep, 256);
282
+ this.e.ex.qvp_text_target(this.h, this._target(target), FORM[form] ?? 0, wp, wn, lp, ln, this.e.scratch);
283
+ return this.e.qstr(this.e.scratch);
284
+ }
285
+ search(query, { form = 'search', mode = 'includes', normalize = true, loose = true, limit = 0 } = {}) {
286
+ const [qp, qn] = this.e.putStr(query);
287
+ const n = this.e.ex.qvp_search(this.h, qp, qn, FORM[form] ?? 4, { includes: 0, exact: 1, prefix: 2 }[mode] ?? 0, normalize ? 1 : 0, loose ? 1 : 0, limit, this.e.scratch, 1024);
288
+ const d = this.e.dv(), out = [];
289
+ for (let i = 0; i < Math.min(n, 1024); i++) { const o = this.e.scratch + i * 12; const w = d.getUint32(o, true); out.push({ word: w, index: d.getUint32(o + 4, true), loose: !!d.getUint32(o + 8, true), wordKey: this.wordKey(w), text: this.words[w].text }); }
290
+ return out;
291
+ }
292
+ wordKey(i) { const w = this.words[i]; return `${w.surah}:${w.ayah}:${w.word}`; }
293
+ /** attach a words sidecar (object or JSON string/bytes); returns words updated */
294
+ attachWords(sidecar) { const s = typeof sidecar === 'string' ? sidecar : sidecar instanceof Uint8Array ? this.e.td.decode(sidecar) : JSON.stringify(sidecar); const b = this.e.te.encode(s); const p = this.e.ex.qvp_alloc(b.length); new Uint8Array(this.e.mem.buffer, p, b.length).set(b); const n = this.e.ex.qvp_attach_words(this.h, p, b.length); this.e.ex.qvp_dealloc(p, b.length); return n; }
295
+ hasForm(form) { return !!this.e.ex.qvp_has_form(this.h, FORM[form] ?? 0); }
296
+ citation(words) { const p = this.e.putU32(Uint32Array.from(words)); this.e.ex.qvp_citation(this.h, p, words.length, this.e.scratch); return this.e.qstr(this.e.scratch); }
297
+
298
+ // ── hit testing ──
299
+ _hit(s) { const d = this.e.dv(); const f = v => (v === NONE ? -1 : v); return { word: f(d.getUint32(s, true)), path: f(d.getUint32(s + 4, true)), deco: f(d.getUint32(s + 8, true)) }; }
300
+ hitTest(x, y) { return this.e.ex.qvp_hit_test(this.h, x, y, this.e.scratch) ? this._hit(this.e.scratch) : null; }
301
+ hitTestView(vx, vy) { return this.e.ex.qvp_hit_test_view(this.h, vx, vy, this.e.scratch) ? this._hit(this.e.scratch) : null; }
302
+ _hitOpt(o) { const d = this.e.dv(), at = this.e.scratch2 + 8192; d.setFloat32(at, o.maxDistance ?? 0, true); d.setFloat32(at + 4, o.gapBias ?? 0.6, true); d.setUint32(at + 8, o.exactFirst === false ? 0 : 1, true); return at; }
303
+ _hitEx(s) { const d = this.e.dv(); const f = v => (v === NONE ? -1 : v); const h = { word: f(d.getUint32(s, true)), path: f(d.getUint32(s + 4, true)), deco: f(d.getUint32(s + 8, true)), line: d.getUint32(s + 12, true), distance: d.getFloat32(s + 16, true), exact: !!d.getUint32(s + 20, true) }; if (h.word >= 0) { h.wordKey = this.wordKey(h.word); const w = this.words[h.word]; h.ayahKey = `${w.surah}:${w.ayah}`; } return h; }
304
+ /** gap-aware: every point on a printed line resolves to the word the user meant */
305
+ hitTestEx(x, y, opt = {}) { return this.e.ex.qvp_hit_test_ex(this.h, x, y, this._hitOpt(opt), this.e.scratch) ? this._hitEx(this.e.scratch) : null; }
306
+ hitTestViewEx(vx, vy, opt = {}) { return this.e.ex.qvp_hit_test_view_ex(this.h, vx, vy, this._hitOpt(opt), this.e.scratch) ? this._hitEx(this.e.scratch) : null; }
307
+ lineBands() { const n = this.e.ex.qvp_line_bands(this.h, this.e.scratch, 64), d = this.e.dv(), out = []; for (let i = 0; i < Math.min(n, 64); i++) { const o = this.e.scratch + i * 28; out.push({ line: d.getUint32(o, true), lineNo: d.getUint32(o + 4, true), y0: d.getFloat32(o + 8, true), y1: d.getFloat32(o + 12, true), mid: d.getFloat32(o + 16, true), inkY0: d.getFloat32(o + 20, true), inkY1: d.getFloat32(o + 24, true) }); } return out; }
308
+ hitBoxes(gapBias = 0.6) { const n = this.e.ex.qvp_hit_boxes(this.h, gapBias, this.e.scratch, 1024), d = this.e.dv(), out = []; for (let i = 0; i < Math.min(n, 1024); i++) { const o = this.e.scratch + i * 40; out.push({ word: d.getUint32(o, true), line: d.getUint32(o + 4, true), x0: d.getFloat32(o + 8, true), y0: d.getFloat32(o + 12, true), x1: d.getFloat32(o + 16, true), y1: d.getFloat32(o + 20, true), inkX0: d.getFloat32(o + 24, true), inkY0: d.getFloat32(o + 28, true), inkX1: d.getFloat32(o + 32, true), inkY1: d.getFloat32(o + 36, true) }); } return out; }
309
+
310
+ // ── layout ──
311
+ layout(spec) {
312
+ const ex = this.e.ex, s = this.e.scratch; let d = this.e.dv();
313
+ d.setFloat32(s, spec.viewportW, true); d.setFloat32(s + 4, spec.viewportH, true); d.setFloat32(s + 8, spec.padTop || 0, true); d.setFloat32(s + 12, spec.padBottom || 0, true);
314
+ d.setFloat32(s + 16, spec.padLeft || 0, true); d.setFloat32(s + 20, spec.padRight || 0, true); d.setFloat32(s + 24, spec.lineSpacing ?? 1, true); d.setFloat32(s + 28, spec.lineGap || 0, true);
315
+ d.setUint32(s + 32, spec.fillHeight ? 1 : 0, true); d.setUint32(s + 36, spec.nominalLines || 15, true);
316
+ ex.qvp_layout(this.h, s, s + 64);
317
+ d = this.e.dv(); const o = s + 64;
318
+ const n = d.getUint32(o + 24, true), lp = d.getUint32(o + 28, true);
319
+ const f = new Float32Array(this.e.mem.buffer.slice(lp, lp + n * 12));
320
+ const lineDy = new Float32Array(n), slots = new Array(n);
321
+ for (let i = 0; i < n; i++) { lineDy[i] = f[i * 3]; slots[i] = [f[i * 3 + 1], f[i * 3 + 2]]; }
322
+ return (this.currentLayout = { scale: d.getFloat32(o, true), ox: d.getFloat32(o + 4, true), oy: d.getFloat32(o + 8, true), contentW: d.getFloat32(o + 12, true), contentH: d.getFloat32(o + 16, true), pitch: d.getFloat32(o + 20, true), lineDy, slots });
323
+ }
324
+ wordBoxView(i) { this.e.ex.qvp_word_box_view(this.h, i, this.e.scratch); const f = new Float32Array(this.e.mem.buffer, this.e.scratch, 4); return { x0: f[0], y0: f[1], x1: f[2], y1: f[3] }; }
325
+
326
+ // ── styles (handles undo exactly) ──
327
+ style(sel, color, { ms = 0, layer = LAYER.BASE } = {}) { return this.e.ex.qvp_style_add(this.h, layer, writeSel(this.e, this.e.scratch2 + 12288, sel), rgba(color), ms); }
328
+ styleTarget(target, color, { ms = 0, layer = LAYER.BASE } = {}) { return this.e.ex.qvp_style_add_target(this.h, layer, this._target(target), rgba(color), ms); }
329
+ unstyle(handle) { return this.e.ex.qvp_style_remove(this.h, handle); }
330
+ restyle(handle, color, ms = 0) { return this.e.ex.qvp_style_repaint(this.h, handle, rgba(color), ms); }
331
+ hide(sel) { return this.e.ex.qvp_hide(this.h, writeSel(this.e, this.e.scratch2 + 12288, sel)); }
332
+ clearStyles() { this.e.ex.qvp_style_clear(this.h); }
333
+ clearLayer(layer) { this.e.ex.qvp_style_clear_layer(this.h, layer); }
334
+ setDefaultInk(color) { this._defaultInk = rgba(color); this.e.ex.qvp_style_default(this.h, this._defaultInk); }
335
+ get defaultInk() { return this._defaultInk; }
336
+ /** {ink, diacritics, dots, waqf, sifr, ayahMark, numeral, headers, marks: {name: colour}, ms} → handle */
337
+ theme(t) {
338
+ const d = this.e.dv(), at = this.e.scratch2 + 16384, c = v => (v === undefined || v === null ? 0 : rgba(v));
339
+ ['ink', 'diacritics', 'dots', 'waqf', 'sifr', 'ayahMark', 'numeral', 'headers'].forEach((k, i) => d.setUint32(at + i * 4, c(t[k]), true));
340
+ d.setUint32(at + 32, t.ms || 0, true);
341
+ const pairs = Object.entries(t.marks || {}).flatMap(([m, col]) => [markId(m), rgba(col)]);
342
+ const mp = pairs.length ? this.e.putU32(Uint32Array.from(pairs), 20480) : 0;
343
+ d.setUint32(at + 36, mp, true); d.setUint32(at + 40, pairs.length / 2, true);
344
+ return this.e.ex.qvp_theme(this.h, at);
345
+ }
346
+ styleHandles() { const n = this.e.ex.qvp_style_handles(this.h, this.e.scratch, 4096); return Array.from(new Uint32Array(this.e.mem.buffer, this.e.scratch, Math.min(n, 4096))); }
347
+
348
+ // ── clock & display list ──
349
+ /** advance animations; returns true while something is still moving */
350
+ tick(nowMs) { return !!this.e.ex.qvp_tick(this.h, nowMs); }
351
+ paint() { const p = this.e.ex.qvp_paint(this.h); return new Uint32Array(this.e.mem.buffer.slice(p, p + this.nPaths * 4)); }
352
+ styled() {
353
+ const ex = this.e.ex; const n = ex.qvp_styled(this.h, 0, 0); if (!n) return [];
354
+ const buf = ex.qvp_alloc(n * 8); ex.qvp_styled(this.h, buf, n);
355
+ const v = new Uint32Array(this.e.mem.buffer.slice(buf, buf + n * 8)); ex.qvp_dealloc(buf, n * 8);
356
+ const out = new Array(n); for (let i = 0; i < n; i++) out[i] = [v[i * 2], v[i * 2 + 1]]; return out;
357
+ }
358
+ colorOf(i) { return this.e.ex.qvp_color_of(this.h, i) >>> 0; }
359
+
360
+ // ── highlights ──
361
+ /** style: {mode:'ink'|'band'|'both', height:'pitch'|'ink', ink, band, padX, padY, radius, seam, ms, layer} */
362
+ highlight(target, style = {}) { return this.e.ex.qvp_highlight(this.h, this._target(target), writeHl(this.e, this.e.scratch2 + 24576, style)); }
363
+ rehighlight(handle, target) { return !!this.e.ex.qvp_rehighlight(this.h, handle, this._target(target)); }
364
+ restyleHighlight(handle, style) { return !!this.e.ex.qvp_restyle_highlight(this.h, handle, writeHl(this.e, this.e.scratch2 + 24576, style)); }
365
+ unhighlight(handle) { return !!this.e.ex.qvp_unhighlight(this.h, handle); }
366
+ clearHighlights() { this.e.ex.qvp_clear_highlights(this.h); }
367
+ highlightHandles() { const n = this.e.ex.qvp_highlight_handles(this.h, this.e.scratch, 1024); return Array.from(new Uint32Array(this.e.mem.buffer, this.e.scratch, Math.min(n, 1024))); }
368
+ highlightWords(h) { const n = this.e.ex.qvp_highlight_words(this.h, h, this.e.scratch, 4096); return Array.from(new Uint32Array(this.e.mem.buffer, this.e.scratch, Math.min(n, 4096))); }
369
+ /** animated band boxes in viewport px; draw each id as one nonzero path behind the ink */
370
+ highlightBoxes() { const n = this.e.ex.qvp_highlight_boxes(this.h, this.e.scratch, 1024); return readBoxes(this.e, this.e.scratch, Math.min(n, 1024)); }
371
+ bandBoxes(words, { height = 'pitch', padX = 1.2, padY = 0 } = {}) { const p = this.e.putU32(Uint32Array.from(words)); const n = this.e.ex.qvp_band_boxes(this.h, p, words.length, height === 'ink' ? 1 : 0, padX, padY, this.e.scratch, 64); return readBoxes(this.e, this.e.scratch, Math.min(n, 64)); }
372
+
373
+ // ── selection ──
374
+ select(anchor, focus = anchor) { this.e.ex.qvp_select(this.h, anchor < 0 ? NONE : anchor, focus < 0 ? NONE : focus); }
375
+ clearSelection() { this.e.ex.qvp_select(this.h, NONE, NONE); }
376
+ selection() { const n = this.e.ex.qvp_selection(this.h, this.e.scratch, 4096); return Array.from(new Uint32Array(this.e.mem.buffer, this.e.scratch, Math.min(n, 4096))); }
377
+ selectionText(form = 'rasm_uthmani', citation = false) { this.e.ex.qvp_selection_text(this.h, FORM[form] ?? 0, citation ? 1 : 0, this.e.scratch); return this.e.qstr(this.e.scratch); }
378
+
379
+ // ── memorisation ──
380
+ mask(target, mode = 'hide') { this.e.ex.qvp_mask(this.h, this._target(target), { hide: 0, block: 1, blur: 2 }[mode] ?? 0); }
381
+ maskFrom(i, mode = 'hide') { this.e.ex.qvp_mask_from(this.h, i, { hide: 0, block: 1, blur: 2 }[mode] ?? 0); }
382
+ maskOptions({ blockColor = '#d9d4c8', padX = 0.6, padY = 0.6, radius = 0.8, reverse = false } = {}) { this.e.ex.qvp_mask_options(this.h, rgba(blockColor), padX, padY, radius, reverse ? 1 : 0); }
383
+ revealNext(n = 1) { return this.e.ex.qvp_reveal_next(this.h, n); }
384
+ hideBack(n = 1) { return this.e.ex.qvp_hide_back(this.h, n); }
385
+ revealWord(i) { return !!this.e.ex.qvp_reveal_word(this.h, i); }
386
+ hideWord(i) { return !!this.e.ex.qvp_hide_word(this.h, i); }
387
+ revealAll() { this.e.ex.qvp_reveal_all(this.h); }
388
+ hideAll() { this.e.ex.qvp_hide_all(this.h); }
389
+ unmask() { this.e.ex.qvp_unmask(this.h); }
390
+ maskHidden() { const n = this.e.ex.qvp_mask_hidden(this.h, this.e.scratch, 4096); return Array.from(new Uint32Array(this.e.mem.buffer, this.e.scratch, Math.min(n, 4096))); }
391
+ maskWords() { const n = this.e.ex.qvp_mask_words(this.h, this.e.scratch, 4096); return Array.from(new Uint32Array(this.e.mem.buffer, this.e.scratch, Math.min(n, 4096))); }
392
+ maskBoxes() { const n = this.e.ex.qvp_mask_boxes(this.h, this.e.scratch, 1024); return readBoxes(this.e, this.e.scratch, Math.min(n, 1024)); }
393
+ /** greyed page with a lit window: {lit, byAyah, grey, ink, ayahMarks, ms} → steps */
394
+ revealStart({ lit = 1, byAyah = false, grey = '#c9c4b8', ink = '#231f20', ayahMarks = true, ms = 0 } = {}) { return this.e.ex.qvp_reveal_start(this.h, lit, byAyah ? 1 : 0, rgba(grey), rgba(ink), ayahMarks ? 1 : 0, ms); }
395
+ revealGoto(at) { return !!this.e.ex.qvp_reveal_goto(this.h, BigInt(at)); }
396
+ revealAt() { const v = Number(this.e.ex.qvp_reveal_at(this.h)); return v === -2 ? null : v; }
397
+ revealSteps() { return this.e.ex.qvp_reveal_steps(this.h); }
398
+ revealStepOf(i) { return Number(this.e.ex.qvp_reveal_step_of(this.h, i)); }
399
+ revealStop() { this.e.ex.qvp_reveal_stop(this.h); }
400
+
401
+ // ── crop ──
402
+ cropBox(target, { pad = 2, keepAyahMarks = true } = {}) { if (!this.e.ex.qvp_crop_box(this.h, this._target(target), pad, keepAyahMarks ? 1 : 0, this.e.scratch)) return null; const d = this.e.dv(), s = this.e.scratch; return { x0: d.getFloat32(s, true), y0: d.getFloat32(s + 4, true), x1: d.getFloat32(s + 8, true), y1: d.getFloat32(s + 12, true), nWords: d.getUint32(s + 16, true), ayahMarkDeco: d.getUint32(s + 20, true) }; }
403
+ cropSvg(target, { pad = 2, keepAyahMarks = true, background = null } = {}) { if (!this.e.ex.qvp_crop_svg(this.h, this._target(target), pad, keepAyahMarks ? 1 : 0, background ? rgba(background) : 0, this.e.scratch)) return null; return this.e.qstr(this.e.scratch); }
404
+ }
405
+
406
+ class QvpAtlas {
407
+ constructor(engine, handle) { this.e = engine; this.h = handle; }
408
+ free() { this.e.ex.qvp_atlas_free(this.h); this.h = 0; }
409
+ pageOf(surah, ayah) { const p = this.e.ex.qvp_atlas_page_of(this.h, surah, ayah); return p < 0 ? null : p; }
410
+ pageRange(page) { if (!this.e.ex.qvp_atlas_page_range(this.h, page, this.e.scratch)) return null; const v = new Uint16Array(this.e.mem.buffer, this.e.scratch, 4); return { first: [v[0], v[1]], last: [v[2], v[3]] }; }
411
+ _surah(s) { const d = this.e.dv(); return { n: d.getUint16(s, true), page: d.getUint16(s + 2, true), ayahCount: d.getUint16(s + 4, true), place: ['makkah', 'madinah'][d.getUint8(s + 6)] || '', arabic: this.e.qstr(s + 8), latin: this.e.qstr(s + 16), english: this.e.qstr(s + 24) }; }
412
+ surah(n) { return this.e.ex.qvp_atlas_surah(this.h, n, this.e.scratch) ? this._surah(this.e.scratch) : null; }
413
+ surahs() { const n = this.e.ex.qvp_atlas_surahs(this.h), out = []; for (let i = 0; i < n; i++) { this.e.ex.qvp_atlas_surah_at(this.h, i, this.e.scratch); out.push(this._surah(this.e.scratch)); } return out; }
414
+ pageOfSurah(n) { const s = this.surah(n); return s ? s.page : null; }
415
+ division(kind, n) { if (!this.e.ex.qvp_atlas_division(this.h, { juz: 0, hizb: 1, nisf: 2, rubu_al_hizb: 3 }[kind], n, this.e.scratch)) return null; const v = new Uint16Array(this.e.mem.buffer, this.e.scratch, 4); return { rubuAlHizb: v[0], surah: v[1], ayah: v[2], page: v[3], ayahKey: `${v[1]}:${v[2]}` }; }
416
+ juz(n) { return this.division('juz', n); }
417
+ hizb(n) { return this.division('hizb', n); }
418
+ rubuAlHizb(n) { return this.division('rubu_al_hizb', n); }
419
+ divisionAt(kind, surah, ayah) { const v = this.e.ex.qvp_atlas_division_at(this.h, { juz: 0, hizb: 1, nisf: 2, rubu_al_hizb: 3 }[kind], surah, ayah); return v < 0 ? null : v; }
420
+ juzAt(surah, ayah) { return this.divisionAt('juz', surah, ayah); }
421
+ pagesOfJuz(n) { if (!this.e.ex.qvp_atlas_pages_of_juz(this.h, n, this.e.scratch)) return null; const v = new Uint16Array(this.e.mem.buffer, this.e.scratch, 2); return [v[0], v[1]]; }
422
+ findSurah(text) { const [p, n] = this.e.putStr(text); const c = this.e.ex.qvp_atlas_find_surah(this.h, p, n, this.e.scratch, 128); return Array.from(new Uint16Array(this.e.mem.buffer, this.e.scratch, Math.min(c, 128))).map(k => this.surah(k)); }
423
+ }
424
+
425
+ /**
426
+ * Canvas2D host renderer. Order: highlight bands → base ink (cached) → styled ink → mask boxes.
427
+ * Call `page.tick(performance.now())` before `draw` each frame; keep drawing while it returns true.
428
+ */
429
+ class CanvasRenderer {
430
+ constructor(canvas) {
431
+ this.canvas = canvas; this.ctx = canvas.getContext('2d');
432
+ this.base = document.createElement('canvas'); this.baseKey = '';
433
+ this.stats = { baseMs: 0, overlayMs: 0, basePaths: 0, overlayPaths: 0, bands: 0 };
434
+ }
435
+ lineTransform(page, view, line, dpr) {
436
+ const L = page.currentLayout || { scale: 1, ox: 0, oy: 0, lineDy: null };
437
+ const s = dpr * view.scale * L.scale, dy = L.lineDy ? L.lineDy[line] : 0;
438
+ return [s, dpr * (view.ox + view.scale * L.ox), dpr * (view.oy + view.scale * (L.oy + dy * L.scale))];
439
+ }
440
+ /** boxes are in layout viewport px; view adds pan/zoom on top */
441
+ drawBoxes(c, boxes, view, dpr) {
442
+ c.setTransform(dpr * view.scale, 0, 0, dpr * view.scale, dpr * view.ox, dpr * view.oy);
443
+ let cur = null, col = 0;
444
+ const flush = () => { if (cur) { c.fillStyle = css(col); c.fill(cur, 'nonzero'); cur = null; } };
445
+ for (const b of boxes) {
446
+ if (!cur || b.id !== cur.id || b.color !== col) { flush(); cur = new Path2D(); cur.id = b.id; col = b.color; }
447
+ if (b.radius > 0) cur.roundRect(b.x0, b.y0, b.x1 - b.x0, b.y1 - b.y0, b.radius); else cur.rect(b.x0, b.y0, b.x1 - b.x0, b.y1 - b.y0);
448
+ }
449
+ flush();
450
+ }
451
+ draw(page, view, dpr) {
452
+ const paths = page.buildPaths();
453
+ const styled = page.styled();
454
+ const styledSet = new Set(styled.map(s => s[0]));
455
+ const L = page.currentLayout || { scale: 1, ox: 0, oy: 0, lineDy: null, pitch: 0 };
456
+ const ink = page.defaultInk;
457
+ const key = `${view.scale.toFixed(4)}|${view.ox.toFixed(1)}|${view.oy.toFixed(1)}|${dpr}|${ink}|${L.scale}|${L.pitch}|${L.lineDy ? L.lineDy[0] : ''}|${[...styledSet].sort((a, b) => a - b).join(',')}`;
458
+ const W = this.canvas.width, H = this.canvas.height;
459
+ const setTf = (c, line) => { const [s, tx, ty] = this.lineTransform(page, view, line, dpr); c.setTransform(s, 0, 0, s, tx, ty); };
460
+ if (key !== this.baseKey || this.base.width !== W || this.base.height !== H) {
461
+ const t0 = performance.now();
462
+ this.base.width = W; this.base.height = H;
463
+ const b = this.base.getContext('2d');
464
+ b.fillStyle = css(ink);
465
+ let n = 0, cur = -1;
466
+ for (let i = 0; i < page.nPaths; i++) {
467
+ if (styledSet.has(i)) continue;
468
+ const ln = page.pathLine(i); if (ln !== cur) { setTf(b, ln); cur = ln; }
469
+ b.fill(paths[i], page.pathEvenOdd(i) ? 'evenodd' : 'nonzero'); n++;
470
+ }
471
+ this.baseKey = key; this.stats.baseMs = performance.now() - t0; this.stats.basePaths = n;
472
+ }
473
+ const t1 = performance.now();
474
+ const c = this.ctx;
475
+ c.setTransform(1, 0, 0, 1, 0, 0); c.clearRect(0, 0, W, H);
476
+ const bands = page.highlightBoxes();
477
+ this.drawBoxes(c, bands, view, dpr);
478
+ c.setTransform(1, 0, 0, 1, 0, 0);
479
+ c.drawImage(this.base, 0, 0);
480
+ let cur = -1;
481
+ for (const [i, col] of styled) {
482
+ if ((col & 255) === 0) continue;
483
+ const ln = page.pathLine(i); if (ln !== cur) { setTf(c, ln); cur = ln; }
484
+ c.fillStyle = css(col); c.fill(paths[i], page.pathEvenOdd(i) ? 'evenodd' : 'nonzero');
485
+ }
486
+ this.drawBoxes(c, page.maskBoxes(), view, dpr);
487
+ this.stats.overlayMs = performance.now() - t1; this.stats.overlayPaths = styled.length; this.stats.bands = bands.length;
488
+ }
489
+ }
490
+
491
+ global.QVP = { QvpEngine, QvpPage, QvpAtlas, CanvasRenderer, Sel, T, KIND, FAMILY, CATEGORY, DECO, FORM, LAYER, MARK, MARKS, NONE, css, rgba, parseTarget };
492
+ })(typeof window !== 'undefined' ? window : globalThis);
Binary file