inicontent 1.0.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.
Files changed (205) hide show
  1. package/.github/pull_request.yml +18 -0
  2. package/CONTRIBUTING.md +67 -0
  3. package/LICENSE +21 -0
  4. package/README.md +142 -0
  5. package/app/app.vue +106 -0
  6. package/app/assets/main.css +974 -0
  7. package/app/components/Api/EndpointCard.vue +169 -0
  8. package/app/components/Api/SchemaDocsTable.vue +208 -0
  9. package/app/components/Asset/Card.vue +1363 -0
  10. package/app/components/Asset/Grid.vue +295 -0
  11. package/app/components/Asset/Icon.vue +38 -0
  12. package/app/components/Asset/Preview.vue +486 -0
  13. package/app/components/Asset/Scanner.vue +700 -0
  14. package/app/components/Asset/Thumb.vue +708 -0
  15. package/app/components/Auth/Reset.vue +99 -0
  16. package/app/components/Auth/index.vue +346 -0
  17. package/app/components/ChatInterface.vue +2326 -0
  18. package/app/components/ChatPagePreview.vue +34 -0
  19. package/app/components/Column/Array.vue +29 -0
  20. package/app/components/Column/Asset.vue +21 -0
  21. package/app/components/Column/Boolean.vue +12 -0
  22. package/app/components/Column/Color.vue +9 -0
  23. package/app/components/Column/Date.vue +57 -0
  24. package/app/components/Column/Edit.vue +215 -0
  25. package/app/components/Column/Email.vue +9 -0
  26. package/app/components/Column/Html.vue +27 -0
  27. package/app/components/Column/Id.vue +20 -0
  28. package/app/components/Column/Locale.vue +32 -0
  29. package/app/components/Column/Object.vue +18 -0
  30. package/app/components/Column/Password.vue +7 -0
  31. package/app/components/Column/Role.vue +15 -0
  32. package/app/components/Column/S.vue +14 -0
  33. package/app/components/Column/Table/Single.vue +49 -0
  34. package/app/components/Column/Table/index.vue +42 -0
  35. package/app/components/Column/Tags.vue +31 -0
  36. package/app/components/Column/Text.vue +9 -0
  37. package/app/components/Column/Url.vue +9 -0
  38. package/app/components/Column/index.vue +41 -0
  39. package/app/components/Dashboard/Editor.vue +221 -0
  40. package/app/components/Dashboard/Grid.vue +270 -0
  41. package/app/components/Dashboard/View.vue +131 -0
  42. package/app/components/Dashboard/Widget/BarChart.vue +73 -0
  43. package/app/components/Dashboard/Widget/Counter.vue +51 -0
  44. package/app/components/Dashboard/Widget/LineChart.vue +76 -0
  45. package/app/components/Dashboard/Widget/PieChart.vue +75 -0
  46. package/app/components/Dashboard/Widget/Table.vue +94 -0
  47. package/app/components/Dashboard/WidgetEditor.vue +274 -0
  48. package/app/components/Data/Array.vue +102 -0
  49. package/app/components/Data/Html.vue +9 -0
  50. package/app/components/Data/Icon.vue +9 -0
  51. package/app/components/Data/Object.vue +10 -0
  52. package/app/components/Data/S.vue +16 -0
  53. package/app/components/Data/Table.vue +53 -0
  54. package/app/components/Data/Value.vue +34 -0
  55. package/app/components/Data/index.vue +14 -0
  56. package/app/components/Field/Array.vue +324 -0
  57. package/app/components/Field/Asset/Actions.vue +97 -0
  58. package/app/components/Field/Asset/index.vue +639 -0
  59. package/app/components/Field/Boolean.vue +26 -0
  60. package/app/components/Field/Checkbox.vue +48 -0
  61. package/app/components/Field/Color.vue +27 -0
  62. package/app/components/Field/Date.vue +30 -0
  63. package/app/components/Field/Email.vue +51 -0
  64. package/app/components/Field/EmailTemplate.vue +420 -0
  65. package/app/components/Field/Html/ColorPicker.vue +127 -0
  66. package/app/components/Field/Html/Editor.vue +544 -0
  67. package/app/components/Field/Html/index.vue +25 -0
  68. package/app/components/Field/Icon.vue +114 -0
  69. package/app/components/Field/JSON.vue +113 -0
  70. package/app/components/Field/Mention.vue +36 -0
  71. package/app/components/Field/Number.vue +27 -0
  72. package/app/components/Field/Object.vue +92 -0
  73. package/app/components/Field/Password.vue +55 -0
  74. package/app/components/Field/Radio.vue +47 -0
  75. package/app/components/Field/S.vue +44 -0
  76. package/app/components/Field/Select.vue +49 -0
  77. package/app/components/Field/Table.vue +348 -0
  78. package/app/components/Field/Tags.vue +113 -0
  79. package/app/components/Field/Text.vue +31 -0
  80. package/app/components/Field/Textarea.vue +33 -0
  81. package/app/components/Field/Url.vue +40 -0
  82. package/app/components/Field/Wrapper.vue +128 -0
  83. package/app/components/Field/index.vue +68 -0
  84. package/app/components/FloatingChatbot.vue +73 -0
  85. package/app/components/Form/Card.vue +151 -0
  86. package/app/components/Form/Drawer.vue +195 -0
  87. package/app/components/Form/index.vue +621 -0
  88. package/app/components/Header.vue +328 -0
  89. package/app/components/Offline/ConflictModal.vue +200 -0
  90. package/app/components/Offline/SyncStatus.vue +249 -0
  91. package/app/components/PlatformFeatureLanding.vue +65 -0
  92. package/app/components/Table/Backups.vue +638 -0
  93. package/app/components/Table/Flows.vue +814 -0
  94. package/app/components/Table/Grid.vue +296 -0
  95. package/app/components/Table/Icon.vue +36 -0
  96. package/app/components/Table/Logs.vue +181 -0
  97. package/app/components/Table/Schedules.vue +722 -0
  98. package/app/components/Table/Search/Button.vue +256 -0
  99. package/app/components/Table/Search/Items.vue +286 -0
  100. package/app/components/Table/Search/index.vue +87 -0
  101. package/app/components/Table/Settings/Schema.vue +801 -0
  102. package/app/components/Table/Settings/index.vue +631 -0
  103. package/app/components/Table/TranslateDrawer.vue +526 -0
  104. package/app/components/Table/Views/Kanban.vue +289 -0
  105. package/app/components/Table/Views/Table.vue +1273 -0
  106. package/app/components/Table/index.vue +890 -0
  107. package/app/composables/Translation/define.ts +41 -0
  108. package/app/composables/Translation/fetch.ts +107 -0
  109. package/app/composables/Translation/languages.ts +1 -0
  110. package/app/composables/Translation/loadCoreTranslations.ts +38 -0
  111. package/app/composables/Translation/translate.ts +250 -0
  112. package/app/composables/databaseCookies.ts +79 -0
  113. package/app/composables/fieldValidator.ts +72 -0
  114. package/app/composables/fieldsList.ts +301 -0
  115. package/app/composables/formatDatabase.ts +101 -0
  116. package/app/composables/index.ts +444 -0
  117. package/app/composables/openDrawer.ts +118 -0
  118. package/app/composables/optimizeFile.ts +287 -0
  119. package/app/composables/renderLabel.ts +88 -0
  120. package/app/composables/schemaClipboard.ts +95 -0
  121. package/app/composables/search.ts +353 -0
  122. package/app/composables/setThemeConfig.ts +48 -0
  123. package/app/composables/translationValue.ts +169 -0
  124. package/app/composables/useAssetPreview.ts +84 -0
  125. package/app/composables/useAssetUploader.ts +56 -0
  126. package/app/composables/useDashboardData.ts +310 -0
  127. package/app/composables/useDocumentThumbnail.ts +351 -0
  128. package/app/composables/useEcharts.ts +33 -0
  129. package/app/composables/useLocalDatabaseConfig.ts +51 -0
  130. package/app/composables/useOcr.ts +300 -0
  131. package/app/composables/useOfflineFetch.ts +194 -0
  132. package/app/composables/useOfflineItem.ts +107 -0
  133. package/app/composables/useOfflineQueue.ts +302 -0
  134. package/app/composables/useOfflineSync.ts +235 -0
  135. package/app/composables/usePasskeyAuth.ts +156 -0
  136. package/app/composables/usePdfCompressor.ts +180 -0
  137. package/app/composables/usePlatformRedirect.ts +72 -0
  138. package/app/composables/useRealtimeSync.ts +233 -0
  139. package/app/composables/useReferencedItems.ts +180 -0
  140. package/app/composables/useScanner.ts +664 -0
  141. package/app/composables/useSubscription.ts +650 -0
  142. package/app/composables/useThumbnailCache.ts +136 -0
  143. package/app/composables/useVideoCompressor.ts +194 -0
  144. package/app/error.vue +112 -0
  145. package/app/layouts/api.vue +188 -0
  146. package/app/layouts/dashboard.vue +91 -0
  147. package/app/layouts/default.vue +228 -0
  148. package/app/layouts/table.vue +471 -0
  149. package/app/locales/ar.ts +996 -0
  150. package/app/locales/en.ts +655 -0
  151. package/app/locales/es.ts +996 -0
  152. package/app/locales/fr.ts +997 -0
  153. package/app/middleware/dashboard.ts +33 -0
  154. package/app/middleware/database.ts +83 -0
  155. package/app/middleware/global.ts +1 -0
  156. package/app/middleware/sid-ingest.global.ts +29 -0
  157. package/app/middleware/table.ts +54 -0
  158. package/app/middleware/user.ts +26 -0
  159. package/app/pages/[[database]]/admin/api/auth/index.vue +306 -0
  160. package/app/pages/[[database]]/admin/api/index.vue +18 -0
  161. package/app/pages/[[database]]/admin/api/tables/[table]/index.vue +497 -0
  162. package/app/pages/[[database]]/admin/api/tables/index.vue +87 -0
  163. package/app/pages/[[database]]/admin/billing/index.vue +16 -0
  164. package/app/pages/[[database]]/admin/dashboards/[id]/edit.vue +66 -0
  165. package/app/pages/[[database]]/admin/dashboards/[id]/index.vue +117 -0
  166. package/app/pages/[[database]]/admin/dashboards/index.vue +20 -0
  167. package/app/pages/[[database]]/admin/index.vue +28 -0
  168. package/app/pages/[[database]]/admin/settings.vue +526 -0
  169. package/app/pages/[[database]]/admin/tables/[table]/[id]/edit.vue +143 -0
  170. package/app/pages/[[database]]/admin/tables/[table]/[id]/index.vue +146 -0
  171. package/app/pages/[[database]]/admin/tables/[table]/flows.vue +10 -0
  172. package/app/pages/[[database]]/admin/tables/[table]/index.vue +10 -0
  173. package/app/pages/[[database]]/admin/tables/[table]/new.vue +12 -0
  174. package/app/pages/[[database]]/admin/tables/[table]/schedules.vue +10 -0
  175. package/app/pages/[[database]]/admin/tables/[table]/settings.vue +30 -0
  176. package/app/pages/[[database]]/admin/tables/assets/[...path].vue +24 -0
  177. package/app/pages/[[database]]/admin/tables/assets/flows.vue +10 -0
  178. package/app/pages/[[database]]/admin/tables/backups/index.vue +10 -0
  179. package/app/pages/[[database]]/admin/tables/blocks/index.vue +16 -0
  180. package/app/pages/[[database]]/admin/tables/index.vue +20 -0
  181. package/app/pages/[[database]]/admin/tables/pages/index.vue +16 -0
  182. package/app/pages/[[database]]/admin/tables/templates/index.vue +16 -0
  183. package/app/pages/[[database]]/api/index.vue +120 -0
  184. package/app/pages/[[database]]/auth/index.vue +12 -0
  185. package/app/pages/[[database]]/auth/reset.vue +10 -0
  186. package/app/pages/[[database]]/index.vue +10 -0
  187. package/app/plugins/offline-warmup.client.ts +53 -0
  188. package/app/plugins/template-globals.ts +9 -0
  189. package/app/spa-loading-template.html +93 -0
  190. package/biome.json +53 -0
  191. package/index.d.ts +380 -0
  192. package/modules/naiveui.ts +41 -0
  193. package/nuxt.config.ts +269 -0
  194. package/package.json +56 -0
  195. package/pnpm-workspace.yaml +5 -0
  196. package/public/apple-touch-icon.png +0 -0
  197. package/public/favicon-16x16.png +0 -0
  198. package/public/favicon-32x32.png +0 -0
  199. package/public/favicon.ico +0 -0
  200. package/public/pwa-192x192.png +0 -0
  201. package/public/pwa-512x512.png +0 -0
  202. package/public/pwa-maskable-192x192.png +0 -0
  203. package/public/pwa-maskable-512x512.png +0 -0
  204. package/public/vendor/opencv.js +48 -0
  205. package/tsconfig.json +9 -0
@@ -0,0 +1,664 @@
1
+ /**
2
+ * Document scanner composable powered by jscanify + OpenCV.js.
3
+ *
4
+ * The browser build of jscanify (jscanify/client) relies on a global `cv`
5
+ * object, so OpenCV.js must be loaded first. We self-host it at
6
+ * `/vendor/opencv.js` (its wasm is embedded as a base64 data-URI, so it
7
+ * works offline and through the PWA service worker without extra parts).
8
+ *
9
+ * Everything is lazy: OpenCV.js (~9 MB) and jscanify are only downloaded
10
+ * after `loadScanner()` is called (i.e. when the user clicks "Scan").
11
+ *
12
+ * Detection notes:
13
+ * - jscanify bluntly picks the largest Canny contour, which fails for
14
+ * small/low-contrast documents (e.g. an ID card on a busy desk).
15
+ * `analyzePaper()` adds validation (area share, frame margin, aspect
16
+ * ratio) and `startHighlightLoop()` color-codes the overlay so the
17
+ * user gets live guidance ("move closer", "detected", …).
18
+ * - `smartExtract()` preserves the document's true aspect ratio.
19
+ * - `warpCorners()` applies a perspective transform from manual corners,
20
+ * an escape hatch when auto-detection keeps failing.
21
+ *
22
+ * Designed so it can be extracted into any Vue 3 project (e.g. an
23
+ * ID-card scanner side project).
24
+ */
25
+ import { onBeforeUnmount, shallowRef } from "vue";
26
+
27
+ declare global {
28
+ interface Window {
29
+ cv?: any;
30
+ }
31
+ }
32
+
33
+ export interface Point {
34
+ x: number;
35
+ y: number;
36
+ }
37
+
38
+ export interface Corners {
39
+ topLeft: Point;
40
+ topRight: Point;
41
+ bottomLeft: Point;
42
+ bottomRight: Point;
43
+ }
44
+
45
+ export interface PaperAnalysis {
46
+ /** whether a plausible document quad was found */
47
+ detected: boolean;
48
+ corners: Corners | null;
49
+ /** contour area / frame area (0..1) */
50
+ areaRatio: number;
51
+ /** width/height of the detected quad (0 when no quad) */
52
+ aspectRatio: number;
53
+ reason:
54
+ | "ok"
55
+ | "not-loaded"
56
+ | "no-contour"
57
+ | "too-small"
58
+ | "hits-frame-edge"
59
+ | "bad-aspect"
60
+ | "error";
61
+ }
62
+
63
+ /** jscanify corner point names → normalized {topLeft,topRight,bottomLeft,bottomRight} */
64
+ function normalizeCorners(jCorners: any, imgW: number, imgH: number): Corners {
65
+ const src = jCorners as {
66
+ topLeftCorner: Point;
67
+ topRightCorner: Point;
68
+ bottomLeftCorner: Point;
69
+ bottomRightCorner: Point;
70
+ };
71
+ const bounds = (p: Point | undefined): Point | null => {
72
+ if (!p) return null;
73
+ return {
74
+ x: Math.max(0, Math.min(imgW, p.x)),
75
+ y: Math.max(0, Math.min(imgH, p.y)),
76
+ };
77
+ };
78
+ return {
79
+ topLeft: bounds(src.topLeftCorner)!,
80
+ topRight: bounds(src.topRightCorner)!,
81
+ bottomLeft: bounds(src.bottomLeftCorner)!,
82
+ bottomRight: bounds(src.bottomRightCorner)!,
83
+ };
84
+ }
85
+
86
+ export function useScanner() {
87
+ const scanner = shallowRef<{ [key: string]: any } | null>(null);
88
+ const opencvLoaded = ref(false);
89
+ const loading = ref(false);
90
+ const error = ref<string | null>(null);
91
+
92
+ /** Track in-flight load Promise so concurrent calls await the same load */
93
+ let loadPromise: Promise<any> | null = null;
94
+
95
+ /**
96
+ * Load OpenCV.js into the global `cv` via a <script> tag.
97
+ * Resolves once the runtime is initialized.
98
+ */
99
+ function loadOpenCV(src = "/vendor/opencv.js"): Promise<void> {
100
+ return new Promise((resolve, reject) => {
101
+ // Already loaded?
102
+ if (window.cv?.getBuildInformation) {
103
+ resolve();
104
+ return;
105
+ }
106
+
107
+ const script = document.createElement("script");
108
+ script.src = src;
109
+ script.async = true;
110
+ script.onload = () => {
111
+ const cvGlobal = window.cv;
112
+ if (!cvGlobal) {
113
+ reject(new Error("OpenCV.js loaded but `cv` global is missing"));
114
+ return;
115
+ }
116
+ // Some builds call onRuntimeInitialized immediately, some need wait
117
+ if (cvGlobal.getBuildInformation) {
118
+ resolve();
119
+ } else if (cvGlobal.onRuntimeInitialized) {
120
+ cvGlobal.onRuntimeInitialized = () => resolve();
121
+ } else {
122
+ // Fallback: poll briefly
123
+ const interval = setInterval(() => {
124
+ if (cvGlobal.getBuildInformation) {
125
+ clearInterval(interval);
126
+ resolve();
127
+ }
128
+ }, 500);
129
+ }
130
+ };
131
+ script.onerror = () =>
132
+ reject(new Error(`Failed to load OpenCV.js from ${src}`));
133
+ document.head.appendChild(script);
134
+ });
135
+ }
136
+
137
+ /**
138
+ * Dynamically load OpenCV.js + jscanify (client build).
139
+ * Safe to call multiple times – subsequent calls await the same load.
140
+ */
141
+ async function loadScanner(): Promise<void> {
142
+ if (scanner.value || opencvLoaded.value) return;
143
+ if (loadPromise) return loadPromise;
144
+
145
+ loading.value = true;
146
+ error.value = null;
147
+
148
+ loadPromise = (async () => {
149
+ try {
150
+ await loadOpenCV();
151
+ opencvLoaded.value = true;
152
+
153
+ const module = await import("jscanify/client");
154
+ const JScanify = module.default ?? module;
155
+
156
+ if (!JScanify) throw new Error("jscanify failed to load");
157
+ scanner.value = new JScanify();
158
+ } catch (e: any) {
159
+ error.value = e?.message ?? "Failed to load document scanner";
160
+ loadPromise = null;
161
+ throw e;
162
+ } finally {
163
+ loading.value = false;
164
+ }
165
+ })();
166
+
167
+ return loadPromise;
168
+ }
169
+
170
+ // ── Camera helpers ────────────────────────────────────────────
171
+
172
+ let currentStream: MediaStream | null = null;
173
+ let highlightRAF: number | null = null;
174
+
175
+ /**
176
+ * Start the device camera (rear-facing on mobile).
177
+ * Returns the MediaStream so the caller can attach it to a <video> element.
178
+ */
179
+ async function startCamera(): Promise<MediaStream> {
180
+ if (currentStream) stopCamera();
181
+
182
+ const mediaDevices = navigator.mediaDevices;
183
+ if (!mediaDevices?.getUserMedia) {
184
+ // Safari/iPhone removes mediaDevices entirely on insecure (HTTP)
185
+ // origins — show a helpful message instead of a JS crash.
186
+ throw new Error(
187
+ window.isSecureContext === false
188
+ ? "Camera requires a secure (HTTPS) connection. Open the app via https on this device, or use the Upload tab instead."
189
+ : "Camera is not supported in this browser.",
190
+ );
191
+ }
192
+
193
+ const stream = await mediaDevices.getUserMedia({
194
+ video: {
195
+ facingMode: "environment",
196
+ width: { ideal: 1920 },
197
+ height: { ideal: 1080 },
198
+ },
199
+ audio: false,
200
+ });
201
+
202
+ currentStream = stream;
203
+ return stream;
204
+ }
205
+
206
+ /**
207
+ * Stop all tracks on the current camera stream.
208
+ */
209
+ function stopCamera(): void {
210
+ stopHighlightLoop();
211
+ if (currentStream) {
212
+ for (const track of currentStream.getTracks()) track.stop();
213
+ currentStream = null;
214
+ }
215
+ }
216
+
217
+ // ── Paper analysis ───────────────────────────────────────────
218
+
219
+ /** Distance between two points */
220
+ function dist(a: Point, b: Point): number {
221
+ return Math.hypot(b.x - a.x, b.y - a.y);
222
+ }
223
+
224
+ /**
225
+ * Detect a document quad in the source and validate it.
226
+ *
227
+ * Unlike jscanify's bare `findPaperContour` (largest contour wins),
228
+ * this checks that the detected quad:
229
+ * 1. covers a meaningful share of the frame (≥ minAreaRatio)
230
+ * 2. doesn't run along the frame edges (corners have a margin)
231
+ * 3. has a plausible aspect ratio for a document/card
232
+ *
233
+ * @returns PaperAnalysis with normalized corners (image coordinates)
234
+ */
235
+ function analyzePaper(
236
+ source: CanvasImageSource,
237
+ opts: { minAreaRatio?: number; maxAreaRatio?: number } = {},
238
+ ): PaperAnalysis {
239
+ const { minAreaRatio = 0.06, maxAreaRatio = 0.98 } = opts;
240
+
241
+ const empty: PaperAnalysis = {
242
+ detected: false,
243
+ corners: null,
244
+ areaRatio: 0,
245
+ aspectRatio: 0,
246
+ reason: "not-loaded",
247
+ };
248
+
249
+ if (!scanner.value || !window.cv) return empty;
250
+
251
+ try {
252
+ const img = window.cv.imread(source);
253
+ const imgW = img.cols;
254
+ const imgH = img.rows;
255
+ const contour = scanner.value.findPaperContour(img);
256
+ img.delete();
257
+
258
+ if (!contour) return { ...empty, reason: "no-contour" };
259
+
260
+ const area = window.cv.contourArea(contour);
261
+ const areaRatio = imgW * imgH > 0 ? area / (imgW * imgH) : 0;
262
+
263
+ if (areaRatio < minAreaRatio) {
264
+ contour.delete();
265
+ return { ...empty, areaRatio, reason: "too-small" };
266
+ }
267
+ if (areaRatio > maxAreaRatio) {
268
+ contour.delete();
269
+ return { ...empty, areaRatio, reason: "hits-frame-edge" };
270
+ }
271
+
272
+ const jCorners = scanner.value.getCornerPoints(contour);
273
+ contour.delete();
274
+ const corners = normalizeCorners(jCorners ?? {}, imgW, imgH);
275
+
276
+ // All four corners required
277
+ if (
278
+ !corners.topLeft ||
279
+ !corners.topRight ||
280
+ !corners.bottomLeft ||
281
+ !corners.bottomRight
282
+ ) {
283
+ return { ...empty, areaRatio, reason: "no-contour" };
284
+ }
285
+
286
+ // Frame margin: a corner glued to the image border means the
287
+ // document was cut off — the transform would be garbage.
288
+ const marginX = imgW * 0.01;
289
+ const marginY = imgH * 0.01;
290
+ const hitsEdge = Object.values(corners).some(
291
+ (p) =>
292
+ p.x <= marginX ||
293
+ p.y <= marginY ||
294
+ p.x >= imgW - marginX ||
295
+ p.y >= imgH - marginY,
296
+ );
297
+ if (hitsEdge) {
298
+ return { ...empty, areaRatio, reason: "hits-frame-edge" };
299
+ }
300
+
301
+ // Aspect ratio from average opposite edge lengths
302
+ const top = dist(corners.topLeft, corners.topRight);
303
+ const bottom = dist(corners.bottomLeft, corners.bottomRight);
304
+ const left = dist(corners.topLeft, corners.bottomLeft);
305
+ const right = dist(corners.topRight, corners.bottomRight);
306
+ const quadW = (top + bottom) / 2;
307
+ const quadH = (left + right) / 2;
308
+ const aspectRatio = quadH > 0 ? quadW / quadH : 0;
309
+
310
+ // Documents/cards are roughly 0.4x – 2.8x. Raw edge blobs that
311
+ // slip through area checks rarely land in this band.
312
+ if (aspectRatio < 0.4 || aspectRatio > 2.8) {
313
+ return { ...empty, areaRatio, aspectRatio, reason: "bad-aspect" };
314
+ }
315
+
316
+ return {
317
+ detected: true,
318
+ corners,
319
+ areaRatio,
320
+ aspectRatio,
321
+ reason: "ok",
322
+ };
323
+ } catch {
324
+ return { ...empty, reason: "error" };
325
+ }
326
+ }
327
+
328
+ /**
329
+ * User-friendly guidance for a failed analysis.
330
+ */
331
+ function analysisHint(analysis: PaperAnalysis): string {
332
+ switch (analysis.reason) {
333
+ case "ok":
334
+ return "Document detected";
335
+ case "not-loaded":
336
+ return "Scanner is loading…";
337
+ case "no-contour":
338
+ return "No document found — place it on a plain, contrasting surface";
339
+ case "too-small":
340
+ return "Move the document closer so it fills most of the frame";
341
+ case "hits-frame-edge":
342
+ return "The document touches the frame edge — center it with some margin";
343
+ case "bad-aspect":
344
+ return "Detection looks wrong — center the document with contrast";
345
+ case "error":
346
+ return "Detection error — try again";
347
+ default:
348
+ return "Position the document in view";
349
+ }
350
+ }
351
+
352
+ // ── Live overlay ─────────────────────────────────────────────
353
+
354
+ /**
355
+ * Pipe camera frames to an overlay canvas with a color-coded document
356
+ * highlight and a throttled detection-status callback.
357
+ *
358
+ * Color codes:
359
+ * - green #16a34a → confidently detected
360
+ * - orange #f59e0b → detected but weak (small / near edge)
361
+ * - red #dc2626 → no document
362
+ *
363
+ * `onStatus` receives a `{ label, color, ok }` object roughly every
364
+ * `statusEvery` frames.
365
+ */
366
+ function startHighlightLoop(
367
+ videoEl: HTMLVideoElement,
368
+ overlayCanvas: HTMLCanvasElement,
369
+ onStatus?: (status: { label: string; color: string; ok: boolean }) => void,
370
+ statusEvery = 6,
371
+ ): void {
372
+ const ctx = overlayCanvas.getContext("2d")!;
373
+
374
+ let frame = 0;
375
+ let lastStatus: string | null = null;
376
+
377
+ const tick = () => {
378
+ if (!currentStream || !videoEl.videoWidth) {
379
+ highlightRAF = requestAnimationFrame(tick);
380
+ return;
381
+ }
382
+
383
+ // Keep canvas in sync with the video's intrinsic size
384
+ if (
385
+ overlayCanvas.width !== videoEl.videoWidth ||
386
+ overlayCanvas.height !== videoEl.videoHeight
387
+ ) {
388
+ overlayCanvas.width = videoEl.videoWidth;
389
+ overlayCanvas.height = videoEl.videoHeight;
390
+ }
391
+
392
+ ctx.drawImage(videoEl, 0, 0);
393
+
394
+ if (scanner.value) {
395
+ const analysis = analyzePaper(overlayCanvas);
396
+ if (frame % statusEvery === 0 && onStatus) {
397
+ const label = analysisHint(analysis);
398
+ if (label !== lastStatus) {
399
+ lastStatus = label;
400
+ let color = "#16a34a";
401
+ let ok = true;
402
+ if (!analysis.detected) {
403
+ color = "#dc2626";
404
+ ok = false;
405
+ } else if (analysis.areaRatio < 0.15) {
406
+ color = "#f59e0b";
407
+ }
408
+ onStatus({ label, color, ok });
409
+ }
410
+ }
411
+
412
+ // Draw the detected quad (green/amber) instead of jscanify's
413
+ // full-image highlight, so we stay on one canvas.
414
+ if (analysis.corners) {
415
+ const quad = [
416
+ analysis.corners.topLeft,
417
+ analysis.corners.topRight,
418
+ analysis.corners.bottomRight,
419
+ analysis.corners.bottomLeft,
420
+ ];
421
+ ctx.strokeStyle =
422
+ analysis.detected && analysis.areaRatio >= 0.15
423
+ ? "#16a34a"
424
+ : "#f59e0b";
425
+ ctx.lineWidth = Math.max(3, overlayCanvas.width * 0.005);
426
+ ctx.beginPath();
427
+ ctx.moveTo(quad[0].x, quad[0].y);
428
+ for (let i = 1; i < 4; i++) ctx.lineTo(quad[i].x, quad[i].y);
429
+ ctx.closePath();
430
+ ctx.stroke();
431
+ }
432
+ }
433
+
434
+ frame++;
435
+ highlightRAF = requestAnimationFrame(tick);
436
+ };
437
+
438
+ highlightRAF = requestAnimationFrame(tick);
439
+ }
440
+
441
+ /**
442
+ * Stop the highlight loop without stopping the camera.
443
+ */
444
+ function stopHighlightLoop(): void {
445
+ if (highlightRAF !== null) {
446
+ cancelAnimationFrame(highlightRAF);
447
+ highlightRAF = null;
448
+ }
449
+ }
450
+
451
+ /**
452
+ * Draw the current video frame to an offscreen canvas and return it.
453
+ */
454
+ function captureFrame(videoEl: HTMLVideoElement): HTMLCanvasElement {
455
+ const canvas = document.createElement("canvas");
456
+ canvas.width = videoEl.videoWidth;
457
+ canvas.height = videoEl.videoHeight;
458
+ const ctx = canvas.getContext("2d")!;
459
+ ctx.drawImage(videoEl, 0, 0);
460
+ return canvas;
461
+ }
462
+
463
+ // ── Extraction ───────────────────────────────────────────────
464
+
465
+ /**
466
+ * Detect the paper contour in a canvas/image element.
467
+ * Returns the contour (for getCornerPoints) or null.
468
+ */
469
+ function detectPaper(source: CanvasImageSource): any | null {
470
+ if (!scanner.value || !window.cv) return null;
471
+ try {
472
+ const img = window.cv.imread(source);
473
+ const contour = scanner.value.findPaperContour(img);
474
+ img.delete();
475
+ if (contour) contour.delete();
476
+ return contour;
477
+ } catch {
478
+ return null;
479
+ }
480
+ }
481
+
482
+ /**
483
+ * Extract with perspective correction, preserving the document's
484
+ * true aspect ratio (derived from the detected quad edges).
485
+ * Falls back to a best-effort fixed-ratio extraction when the
486
+ * quad is unknown.
487
+ *
488
+ * @returns { canvas, analysis } — canvas is null when not detected
489
+ */
490
+ function smartExtract(
491
+ source: CanvasImageSource,
492
+ maxWidth = 900,
493
+ ): { canvas: HTMLCanvasElement | null; analysis: PaperAnalysis } {
494
+ const analysis = analyzePaper(source);
495
+
496
+ if (!analysis.detected || !analysis.corners) {
497
+ return { canvas: null, analysis };
498
+ }
499
+
500
+ const c = analysis.corners;
501
+ const top = dist(c.topLeft, c.topRight);
502
+ const bottom = dist(c.bottomLeft, c.bottomRight);
503
+ const left = dist(c.topLeft, c.bottomLeft);
504
+ const right = dist(c.topRight, c.bottomRight);
505
+ const quadW = Math.max(1, (top + bottom) / 2);
506
+ const quadH = Math.max(1, (left + right) / 2);
507
+
508
+ const outW = Math.min(maxWidth, Math.round(quadW));
509
+ const outH = Math.max(1, Math.round(outW * (quadH / quadW)));
510
+
511
+ const canvas = warpCorners(source, analysis.corners, outW, outH);
512
+ return { canvas, analysis };
513
+ }
514
+
515
+ /**
516
+ * Warp-perspective a source into an output canvas using explicit
517
+ * corner points (image coordinates). The core escape hatch for
518
+ * documents auto-detection keeps failing on (e.g. ID cards).
519
+ */
520
+ function warpCorners(
521
+ source: CanvasImageSource,
522
+ corners: Corners,
523
+ outWidth: number,
524
+ outHeight: number,
525
+ ): HTMLCanvasElement | null {
526
+ if (!window.cv) return null;
527
+ try {
528
+ const img = window.cv.imread(source);
529
+ const srcTri = window.cv.matFromArray(4, 1, window.cv.CV_32FC2, [
530
+ corners.topLeft.x,
531
+ corners.topLeft.y,
532
+ corners.topRight.x,
533
+ corners.topRight.y,
534
+ corners.bottomLeft.x,
535
+ corners.bottomLeft.y,
536
+ corners.bottomRight.x,
537
+ corners.bottomRight.y,
538
+ ]);
539
+ const dstTri = window.cv.matFromArray(4, 1, window.cv.CV_32FC2, [
540
+ 0,
541
+ 0,
542
+ outWidth,
543
+ 0,
544
+ 0,
545
+ outHeight,
546
+ outWidth,
547
+ outHeight,
548
+ ]);
549
+ const M = window.cv.getPerspectiveTransform(srcTri, dstTri);
550
+ const warpedDst = new window.cv.Mat();
551
+ window.cv.warpPerspective(
552
+ img,
553
+ warpedDst,
554
+ M,
555
+ new window.cv.Size(outWidth, outHeight),
556
+ window.cv.INTER_LINEAR,
557
+ window.cv.BORDER_CONSTANT,
558
+ new window.cv.Scalar(),
559
+ );
560
+ const canvas = document.createElement("canvas");
561
+ window.cv.imshow(canvas, warpedDst);
562
+ img.delete();
563
+ srcTri.delete();
564
+ dstTri.delete();
565
+ M.delete();
566
+ warpedDst.delete();
567
+ return canvas;
568
+ } catch {
569
+ return null;
570
+ }
571
+ }
572
+
573
+ /**
574
+ * Standard corner layout for the full source bounds (used to seed
575
+ * the manual corner editor when nothing was detected).
576
+ */
577
+ function fullFrameCorners(imgW: number, imgH: number): Corners {
578
+ return {
579
+ topLeft: { x: 0, y: 0 },
580
+ topRight: { x: imgW, y: 0 },
581
+ bottomLeft: { x: 0, y: imgH },
582
+ bottomRight: { x: imgW, y: imgH },
583
+ };
584
+ }
585
+
586
+ /**
587
+ * Average length of the opposite edges of a corner quad.
588
+ * Used to preserve the document's aspect ratio when warping.
589
+ */
590
+ function cornersSize(corners: Corners): { width: number; height: number } {
591
+ const top = dist(corners.topLeft, corners.topRight);
592
+ const bottom = dist(corners.bottomLeft, corners.bottomRight);
593
+ const left = dist(corners.topLeft, corners.bottomLeft);
594
+ const right = dist(corners.topRight, corners.bottomRight);
595
+ return {
596
+ width: (top + bottom) / 2,
597
+ height: (left + right) / 2,
598
+ };
599
+ }
600
+
601
+ /**
602
+ * Render the source canvas into an element (used to seed the
603
+ * manual corner editor).
604
+ */
605
+ function canvasToDataURL(
606
+ canvas: HTMLCanvasElement,
607
+ type = "image/png",
608
+ ): string {
609
+ return canvas.toDataURL(type);
610
+ }
611
+
612
+ /**
613
+ * Convert a canvas to a Blob (PNG by default).
614
+ */
615
+ function canvasToBlob(
616
+ canvas: HTMLCanvasElement,
617
+ type = "image/png",
618
+ quality?: number,
619
+ ): Promise<Blob> {
620
+ return new Promise((resolve, reject) => {
621
+ canvas.toBlob(
622
+ (b) => {
623
+ if (b) resolve(b);
624
+ else reject(new Error("Failed to convert canvas to blob"));
625
+ },
626
+ type,
627
+ quality,
628
+ );
629
+ });
630
+ }
631
+
632
+ // ── Cleanup ───────────────────────────────────────────────────
633
+
634
+ onBeforeUnmount(() => {
635
+ stopCamera();
636
+ });
637
+
638
+ return {
639
+ /** jscanify instance (null until loadScanner resolves) */
640
+ scanner,
641
+ /** True while OpenCV.js is being downloaded */
642
+ loading,
643
+ /** True after OpenCV.js is ready */
644
+ opencvLoaded,
645
+ /** Error message if loading failed */
646
+ error,
647
+
648
+ loadScanner,
649
+ startCamera,
650
+ stopCamera,
651
+ startHighlightLoop,
652
+ stopHighlightLoop,
653
+ captureFrame,
654
+ detectPaper,
655
+ analyzePaper,
656
+ analysisHint,
657
+ smartExtract,
658
+ warpCorners,
659
+ fullFrameCorners,
660
+ cornersSize,
661
+ canvasToBlob,
662
+ canvasToDataURL,
663
+ };
664
+ }