@paulhectork/aiiinotate 0.12.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 (104) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +206 -0
  3. package/cli/export.js +118 -0
  4. package/cli/import.js +111 -0
  5. package/cli/index.js +45 -0
  6. package/cli/migrate.js +131 -0
  7. package/cli/serve.js +29 -0
  8. package/cli/utils/fastifyClient.js +86 -0
  9. package/cli/utils/io.js +225 -0
  10. package/cli/utils/mongoClient.js +21 -0
  11. package/cli/utils/progressbar.js +86 -0
  12. package/cli/xywhToInt.js +99 -0
  13. package/config/.env.template +48 -0
  14. package/docker/Dockerfile +39 -0
  15. package/docker/README.md +5 -0
  16. package/docker/docker-compose.yaml +49 -0
  17. package/docker/docker.sh +43 -0
  18. package/docker/docker_aiiinotate_import.sh +62 -0
  19. package/docs/api.md +381 -0
  20. package/docs/cli.md +132 -0
  21. package/docs/dev_documentation/dev_architecture.md +88 -0
  22. package/docs/dev_documentation/dev_db.md +58 -0
  23. package/docs/dev_documentation/dev_iiif_compatibility.md +43 -0
  24. package/docs/dev_documentation/dev_notes_quirks_and_troubleshooting.md +143 -0
  25. package/docs/docker.md +98 -0
  26. package/docs/includes/report_benchmark_aiiinotate_2026-05-28-02:50:48_7steps.png +0 -0
  27. package/docs/scalability.md +34 -0
  28. package/docs/specifications/0_w3c_open_annotations.md +332 -0
  29. package/docs/specifications/1_w3c_web_annotations.md +577 -0
  30. package/docs/specifications/2_iiif_apis.md +428 -0
  31. package/docs/specifications/3_iiif_annotations.md +103 -0
  32. package/docs/specifications/4_search_api.md +135 -0
  33. package/docs/specifications/5_sas.md +119 -0
  34. package/docs/specifications/6_mirador.md +119 -0
  35. package/docs/specifications/7_aikon.md +137 -0
  36. package/docs/specifications/include/presentation_2.0.webp +0 -0
  37. package/docs/specifications/include/presentation_2.0_white.png +0 -0
  38. package/docs/specifications/include/presentation_3.0.png +0 -0
  39. package/docs/specifications/include/presentation_3.0_resize.png +0 -0
  40. package/eslint.config.js +30 -0
  41. package/migrations/baseConfig.js +57 -0
  42. package/migrations/manageIndex.js +55 -0
  43. package/migrations/migrate-mongo-config-main.js +8 -0
  44. package/migrations/migrate-mongo-config-test.js +8 -0
  45. package/migrations/migrationScripts/20250825185706-collections.js +48 -0
  46. package/migrations/migrationScripts/20250826194832-annotations2-schema.js +42 -0
  47. package/migrations/migrationScripts/20250904080710-annotations2-indexes.js +69 -0
  48. package/migrations/migrationScripts/20251002141951-manifests2-schema.js +43 -0
  49. package/migrations/migrationScripts/20251006212110-manifests2-indexes.js +35 -0
  50. package/migrations/migrationTemplate.js +25 -0
  51. package/package.json +82 -0
  52. package/scripts/get_version.py +12 -0
  53. package/scripts/run.sh +36 -0
  54. package/scripts/setup_mongodb.sh +70 -0
  55. package/scripts/setup_node.sh +15 -0
  56. package/scripts/update_version.py +30 -0
  57. package/scripts/utils.sh +65 -0
  58. package/src/app.js +116 -0
  59. package/src/constants.js +73 -0
  60. package/src/data/annotations/annotations2.js +681 -0
  61. package/src/data/annotations/annotations3.js +28 -0
  62. package/src/data/annotations/routes.js +335 -0
  63. package/src/data/annotations/routes.test.js +271 -0
  64. package/src/data/collectionAbstract.js +283 -0
  65. package/src/data/index.js +29 -0
  66. package/src/data/manifests/manifests2.js +378 -0
  67. package/src/data/manifests/manifests2.test.js +53 -0
  68. package/src/data/manifests/manifests3.js +23 -0
  69. package/src/data/manifests/routes.js +122 -0
  70. package/src/data/manifests/routes.test.js +70 -0
  71. package/src/data/routes.js +181 -0
  72. package/src/data/routes.test.js +166 -0
  73. package/src/db/index.js +50 -0
  74. package/src/fixtures/annotations.js +41 -0
  75. package/src/fixtures/data/annotationList_aikon_wit9_man11_anno165_all.jsonld +827 -0
  76. package/src/fixtures/data/annotationList_vhs_wit250_man250_anno250_all.jsonld +37514 -0
  77. package/src/fixtures/data/annotationList_vhs_wit253_man253_anno253_all.jsonld +20111 -0
  78. package/src/fixtures/data/annotations2Invalid.jsonld +39 -0
  79. package/src/fixtures/data/annotations2SvgValid.jsonld +81 -0
  80. package/src/fixtures/data/annotations2Valid.jsonld +39 -0
  81. package/src/fixtures/data/bnf_invalid_manifest.json +2806 -0
  82. package/src/fixtures/data/bnf_valid_manifest.json +2817 -0
  83. package/src/fixtures/data/vhs_wit253_man253_anno253_anno-24.json +1 -0
  84. package/src/fixtures/generate.js +181 -0
  85. package/src/fixtures/index.js +69 -0
  86. package/src/fixtures/manifests.js +14 -0
  87. package/src/fixtures/utils.js +37 -0
  88. package/src/schemas/index.js +100 -0
  89. package/src/schemas/schemasBase.js +19 -0
  90. package/src/schemas/schemasPresentation2.js +410 -0
  91. package/src/schemas/schemasPresentation3.js +33 -0
  92. package/src/schemas/schemasResolver.js +71 -0
  93. package/src/schemas/schemasRoutes.js +318 -0
  94. package/src/server.js +25 -0
  95. package/src/types.js +97 -0
  96. package/src/utils/iiif2Utils.js +332 -0
  97. package/src/utils/iiif2Utils.test.js +146 -0
  98. package/src/utils/iiif3Utils.js +0 -0
  99. package/src/utils/iiifUtils.js +18 -0
  100. package/src/utils/logger.js +119 -0
  101. package/src/utils/routeUtils.js +137 -0
  102. package/src/utils/svg.js +417 -0
  103. package/src/utils/testUtils.js +289 -0
  104. package/src/utils/utils.js +403 -0
@@ -0,0 +1,137 @@
1
+ import { inspectObj, isNonEmptyArray, mergeObjects } from "#utils/utils.js";
2
+ import logger from "#utils/logger.js";
3
+
4
+ import { PAGE_SIZE } from "#constants";
5
+
6
+ /** @typedef {import("mongodb").UpdateResult} MongoUpdateResultType */
7
+ /** @typedef {import("#types").InsertResponseType} InsertResponseType */
8
+ /** @typedef {import("#types").UpdateResponseType} UpdateResponseType */
9
+ /** @typedef {import("#types").DeleteResponseType} DeleteResponseType */
10
+ /** @typedef {import("#types").FastifyInstanceType} FastifyInstanceType */
11
+
12
+ /**
13
+ * @param {{ insertedIds: string[]?, preExistingIds: string[]?, fetchErrorIds: string[]?, rejectedIds: string[]? }} insertResponseData
14
+ * @returns {InsertResponseType}
15
+ */
16
+ const formatInsertResponse = ({
17
+ insertedIds = [],
18
+ preExistingIds = [],
19
+ fetchErrorIds = [],
20
+ rejectedIds = []
21
+ }) => {
22
+ const out = {
23
+ insertedCount: insertedIds?.length || 0,
24
+ insertedIds: insertedIds || [],
25
+ };
26
+ if (fetchErrorIds?.length) {
27
+ out.fetchErrorIds = fetchErrorIds;
28
+ }
29
+ if (rejectedIds?.length) {
30
+ out.rejectedIds = rejectedIds;
31
+ }
32
+ if (preExistingIds?.length) {
33
+ out.preExistingIds = preExistingIds;
34
+ };
35
+ return out;
36
+ }
37
+
38
+ /**
39
+ * @param {UpdateResponseType} mongoRes
40
+ * @returns {UpdateResponseType}
41
+ */
42
+ const formatUpdateResponse = (mongoRes) => ({
43
+ matchedCount: mongoRes.matchedCount,
44
+ modifiedCount: mongoRes.modifiedCount,
45
+ upsertedCount: mongoRes.upsertedCount,
46
+ upsertedId: mongoRes.upsertedId
47
+ });
48
+
49
+ /**
50
+ * @param {DeleteResponseType} mongoRes
51
+ * @returns {DeleteResponseType}
52
+ */
53
+ const formatDeleteResponse = (mongoRes) => ({
54
+ deletedCount: mongoRes.deletedCount
55
+ });
56
+
57
+ /**
58
+ * NOTE: fastify only implements top-level `$ref` in responses, using $ref in response schemas is not allowed.
59
+ * in turn, we can't use `{ $ref: makeSchemaUri }` and must resolve schemas instead.
60
+ * @param {FastifyInstanceType} fastify
61
+ * @param {object} okResponseSchema - expected response schema
62
+ */
63
+ const makeResponseSchema = (fastify, okResponseSchema) => ({
64
+ 200: okResponseSchema,
65
+ 500: fastify.schemasRoutes.getSchema("routeResponseError")
66
+ })
67
+
68
+ const makeResponsePostSchema = (fastify) => makeResponseSchema(
69
+ fastify,
70
+ {
71
+ anyOf: [
72
+ fastify.schemasRoutes.getSchema("routeResponseInsert"),
73
+ fastify.schemasRoutes.getSchema("routeResponseUpdate"),
74
+ fastify.schemasRoutes.getSchema("routeResponseDelete"),
75
+ ]
76
+ }
77
+ );
78
+
79
+ /**
80
+ *
81
+ * @param {import("fastify").FastifyRequest} request
82
+ * @param {import("fastify").FastifyReply} reply
83
+ * @param {Error} err: the error we're returning
84
+ * @param {number} statusCode - the status code (defaults to 500)
85
+ * @param {any?} requestBody: the data on which the error occurred, for POST requests
86
+ */
87
+ const returnError = (request, reply, err, requestBody={}, statusCode=500) => {
88
+ // otherwise, the error is not logged, bad for debugging.
89
+ logger.error(inspectObj(err));
90
+
91
+ const error = {
92
+ message: `failed ${request.method.toLocaleUpperCase()} request because of error: ${err.message}`,
93
+ info: err.info || {},
94
+ method: request.method,
95
+ url: request.url
96
+ };
97
+ if (requestBody !== undefined) {
98
+ error.requestBody = requestBody
99
+ }
100
+ reply
101
+ .status(statusCode)
102
+ .header("Content-Type", "application/json; charset=utf-8")
103
+ .send(error);
104
+ }
105
+
106
+ const paginationSchema = {
107
+ page: {
108
+ type: "integer",
109
+ default: 1,
110
+ minimum: 1,
111
+ },
112
+ pageSize: {
113
+ type: "integer",
114
+ default: PAGE_SIZE || 5000,
115
+ minimum: 1,
116
+ }
117
+ }
118
+ /**
119
+ * add pagination to a route's query parameters in the route's schema definition.
120
+ *
121
+ * @param {Object} queryObj - a JSONSchema defined in a route's `schema.query` field
122
+ * @returns
123
+ */
124
+ const addPagination = (queryObj) => {
125
+ queryObj.properties = mergeObjects(queryObj.properties, paginationSchema, true);
126
+ return queryObj;
127
+ }
128
+
129
+ export {
130
+ formatInsertResponse,
131
+ formatUpdateResponse,
132
+ formatDeleteResponse,
133
+ makeResponsePostSchema,
134
+ makeResponseSchema,
135
+ returnError,
136
+ addPagination
137
+ }
@@ -0,0 +1,417 @@
1
+ /**
2
+ * module to compute XYWH bounding box of any SVG.
3
+ *
4
+ * we do not compute the bounding box from `svg/@width` and `svg/@height` attributes:
5
+ * in Mirador, those are used to store a canvas' full width and height.
6
+ * instead, we compute from child elements: circles, rectangles, paths...
7
+ *
8
+ * NOTE: the following things are not supported
9
+ * - Rotation transforms (requires full matrix math)
10
+ * - CSS styles (stroke-width in <style> tags)
11
+ * - Text elements (would need font metrics)
12
+ * - Nested transforms (stack management is simplified)
13
+ */
14
+
15
+ import { svgPathBbox } from "svg-path-bbox";
16
+
17
+ import { visibleLog } from "#utils/utils.js";
18
+ import logger from "#utils/logger.js";
19
+
20
+
21
+ /**
22
+ * Extract bbox from circle element
23
+ * <circle cx='100' cy='100' r='50'/>
24
+ */
25
+ const circleBbox = (attrs) => {
26
+ const cx = parseFloat(attrs.cx || 0);
27
+ const cy = parseFloat(attrs.cy || 0);
28
+ const r = parseFloat(attrs.r || 0);
29
+
30
+ if (isNaN(cx) || isNaN(cy) || isNaN(r)) return null;
31
+
32
+ return [ cx - r, cy - r, cx + r, cy + r ];
33
+ }
34
+
35
+ /**
36
+ * Extract bbox from ellipse element
37
+ * <ellipse cx='100' cy='100' rx='60' ry='40'/>
38
+ */
39
+ const ellipseBbox = (attrs) => {
40
+ const cx = parseFloat(attrs.cx || 0);
41
+ const cy = parseFloat(attrs.cy || 0);
42
+ const rx = parseFloat(attrs.rx || 0);
43
+ const ry = parseFloat(attrs.ry || 0);
44
+
45
+ if (isNaN(cx) || isNaN(cy) || isNaN(rx) || isNaN(ry)) return null;
46
+
47
+ return [ cx - rx, cy - ry, cx + rx, cy + ry ];
48
+ }
49
+
50
+ /**
51
+ * Extract bbox from rect element
52
+ * <rect x='10' y='10' width='80' height='60'/>
53
+ */
54
+ const rectBbox = (attrs) => {
55
+ const x = parseFloat(attrs.x || 0);
56
+ const y = parseFloat(attrs.y || 0);
57
+ const width = parseFloat(attrs.width || 0);
58
+ const height = parseFloat(attrs.height || 0);
59
+
60
+ if (isNaN(x) || isNaN(y) || isNaN(width) || isNaN(height)) return null;
61
+
62
+ return [ x, y, x + width, y + height ];
63
+ }
64
+
65
+ /**
66
+ * Extract bbox from line element
67
+ * <line x1='10' y1='10' x2='90' y2='90'/>
68
+ */
69
+ const lineBbox = (attrs) => {
70
+ const x1 = parseFloat(attrs.x1 || 0);
71
+ const y1 = parseFloat(attrs.y1 || 0);
72
+ const x2 = parseFloat(attrs.x2 || 0);
73
+ const y2 = parseFloat(attrs.y2 || 0);
74
+
75
+ if (isNaN(x1) || isNaN(y1) || isNaN(x2) || isNaN(y2)) return null;
76
+
77
+ return [
78
+ Math.min(x1, x2),
79
+ Math.min(y1, y2),
80
+ Math.max(x1, x2),
81
+ Math.max(y1, y2),
82
+ ];
83
+ }
84
+
85
+ /**
86
+ * Extract bbox from polyline/polygon element
87
+ * <polyline points='10,10 90,90 10,90'/>
88
+ */
89
+ const polylineBbox = (attrs) => {
90
+ const points = attrs.points;
91
+ if (!points || typeof points !== "string") return null;
92
+
93
+ const coords = points
94
+ .trim()
95
+ .split(/[\s,]+/)
96
+ .map(Number)
97
+ .filter(n => !isNaN(n));
98
+
99
+ if (coords.length < 2) return null;
100
+
101
+ let minX = Infinity;
102
+ let minY = Infinity;
103
+ let maxX = -Infinity;
104
+ let maxY = -Infinity;
105
+
106
+ for (let i = 0; i < coords.length; i += 2) {
107
+ const x = coords[i];
108
+ const y = coords[i + 1];
109
+ if (!isNaN(x) && !isNaN(y)) {
110
+ minX = Math.min(minX, x);
111
+ minY = Math.min(minY, y);
112
+ maxX = Math.max(maxX, x);
113
+ maxY = Math.max(maxY, y);
114
+ }
115
+ }
116
+
117
+ return minX === Infinity ? null : [ minX, minY, maxX, maxY ];
118
+ }
119
+
120
+ /**
121
+ * Extract bbox from path element
122
+ * <path d='M 10 10 L 90 90'/>
123
+ */
124
+ const pathBbox = (pathD) => {
125
+ if (!pathD || typeof pathD !== "string") return null;
126
+
127
+ try {
128
+ return svgPathBbox(pathD);
129
+ } catch (e) {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Parse element attributes from XML string
136
+ */
137
+ const parseAttributes = (elementString) => {
138
+ const attrs = {};
139
+ const attrRegex = /(\w+(?:[-:][\w]+)*)\s*=\s*["']([^"']*)["']/g;
140
+ let match;
141
+
142
+ while ((match = attrRegex.exec(elementString)) !== null) {
143
+ attrs[match[1]] = match[2];
144
+ }
145
+
146
+ return attrs;
147
+ }
148
+
149
+ /**
150
+ * Parse transform attribute and apply to bbox
151
+ * Supports: translate, scale, rotate (simplified)
152
+ */
153
+ const applyTransform = (bbox, transformStr) => {
154
+ if (!bbox || !transformStr) return bbox;
155
+
156
+ const transforms = [];
157
+ const regex = /(\w+)\s*\(\s*([^)]*)\s*\)/g;
158
+ let match;
159
+
160
+ while ((match = regex.exec(transformStr)) !== null) {
161
+ const [ , func, args ] = match;
162
+ const nums = args.split(/[\s,]+/).map(Number).filter(n => !isNaN(n));
163
+ transforms.push({ func: func.toLowerCase(), nums });
164
+ }
165
+
166
+ let [ x1, y1, x2, y2 ] = bbox;
167
+
168
+ for (const { func, nums } of transforms) {
169
+ switch (func) {
170
+ case "translate": {
171
+ const tx = nums[0] || 0;
172
+ const ty = nums[1] || 0;
173
+ x1 += tx;
174
+ x2 += tx;
175
+ y1 += ty;
176
+ y2 += ty;
177
+ break;
178
+ }
179
+ case "scale": {
180
+ const sx = nums[0] || 1;
181
+ const sy = nums[1] ?? sx;
182
+ x1 *= sx;
183
+ x2 *= sx;
184
+ y1 *= sy;
185
+ y2 *= sy;
186
+ break;
187
+ }
188
+ case "rotate": {
189
+ // Simplified: for rotation, we'd need to calculate 4 corners
190
+ // For now, we'll skip proper rotation (requires matrix math)
191
+ logger.warn("Rotation not fully supported in bbox calculation");
192
+ break;
193
+ }
194
+ case "matrix": {
195
+ // a c e, b d f
196
+ if (nums.length >= 6) {
197
+ const [ a, b, c, d, e, f ] = nums;
198
+ const x = x1 * a + y1 * c + e;
199
+ const y = x1 * b + y1 * d + f;
200
+ const x2new = x2 * a + y2 * c + e;
201
+ const y2new = x2 * b + y2 * d + f;
202
+ x1 = Math.min(x, x2new);
203
+ x2 = Math.max(x, x2new);
204
+ y1 = Math.min(y, y2new);
205
+ y2 = Math.max(y, y2new);
206
+ }
207
+ break;
208
+ }
209
+ }
210
+ }
211
+
212
+ return [ x1, y1, x2, y2 ];
213
+ }
214
+
215
+ /**
216
+ * Compute bounding box for single element
217
+ */
218
+ const computeElementBbox = (elementString, parentTransform = null) => {
219
+ let bbox = null;
220
+ const attrs = parseAttributes(elementString);
221
+
222
+ // Determine element type and compute bbox
223
+ if (elementString.includes("<circle")) {
224
+ bbox = circleBbox(attrs);
225
+ } else if (elementString.includes("<ellipse")) {
226
+ bbox = ellipseBbox(attrs);
227
+ } else if (elementString.includes("<rect")) {
228
+ bbox = rectBbox(attrs);
229
+ } else if (elementString.includes("<line")) {
230
+ bbox = lineBbox(attrs);
231
+ } else if (elementString.includes("<polyline") || elementString.includes("<polygon")) {
232
+ bbox = polylineBbox(attrs);
233
+ } else if (elementString.includes("<path")) {
234
+ bbox = pathBbox(attrs.d);
235
+ }
236
+
237
+ if (!bbox) return null;
238
+
239
+ // Apply element's own transform
240
+ if (attrs.transform) {
241
+ bbox = applyTransform(bbox, attrs.transform);
242
+ }
243
+
244
+ // Apply parent transform
245
+ if (parentTransform) {
246
+ bbox = applyTransform(bbox, parentTransform);
247
+ }
248
+
249
+ return bbox;
250
+ }
251
+
252
+ /**
253
+ * Recursively extract all element bboxes from SVG string
254
+ */
255
+ const extractAllBboxes = (svgString) => {
256
+ const bboxes = [];
257
+ const elementRegex = /<(circle|ellipse|rect|line|polyline|polygon|path|g|svg)\s*([^>]*?)(?:>|\/\s*>)/gi;
258
+ const groupStack = []; // Track group transforms
259
+
260
+ let match;
261
+ while ((match = elementRegex.exec(svgString)) !== null) {
262
+ const [ fullMatch, tagName, attrs ] = match;
263
+ const normalizedTag = tagName.toLowerCase();
264
+
265
+ if (normalizedTag === "g") {
266
+ // Handle group transform
267
+ const gAttrs = parseAttributes(fullMatch);
268
+ if (gAttrs.transform) {
269
+ groupStack.push(gAttrs.transform);
270
+ }
271
+ } else {
272
+ // Compute bbox for element
273
+ const parentTransform = groupStack.length > 0 ? groupStack[groupStack.length - 1] : null;
274
+ const bbox = computeElementBbox(fullMatch, parentTransform);
275
+
276
+ if (bbox) {
277
+ bboxes.push(bbox);
278
+ }
279
+ }
280
+ }
281
+
282
+ return bboxes;
283
+ }
284
+
285
+ /**
286
+ * Main method: compute union bbox for entire SVG.
287
+ *
288
+ * see documentation on top of this file for more info.
289
+ *
290
+ * @param {string} svgString
291
+ * @returns {number[]?}
292
+ */
293
+ const computeXywh = (svgString) => {
294
+
295
+ // Quick check: try viewBox first
296
+ const viewBoxMatch = svgString.match(/viewBox\s*=\s*["']([^"']+)["']/i);
297
+ if (viewBoxMatch) {
298
+ const nums = viewBoxMatch[1].split(/[\s,]+/).map(Number);
299
+ if (nums.length === 4 && nums.every(n => !isNaN(n))) {
300
+ return nums;
301
+ }
302
+ }
303
+
304
+ // Full element extraction
305
+ const bboxes = extractAllBboxes(svgString);
306
+
307
+ if (bboxes.length === 0) {
308
+ return null;
309
+ }
310
+
311
+ // Compute union
312
+ const [ minX, minY, maxX, maxY ] = bboxes.reduce(
313
+ ([ x1, y1, x2, y2 ], [ nx1, ny1, nx2, ny2 ]) => [
314
+ Math.min(x1, nx1),
315
+ Math.min(y1, ny1),
316
+ Math.max(x2, nx2),
317
+ Math.max(y2, ny2),
318
+ ]
319
+ );
320
+
321
+ return [ minX, minY, maxX - minX, maxY - minY ];
322
+ }
323
+
324
+ /**
325
+ * compute the XYWH bounds of an SVG.
326
+ *
327
+ * since `svgStringToXywh` receives SVG strings from clients,
328
+ * we add some dirty solutions to mitigate malicious input
329
+ * that could cause a ReDos.
330
+ *
331
+ * @param {string} svgString
332
+ * @returns {Promise<number[]?>}
333
+ */
334
+ async function svgStringToXywh(svgString) {
335
+ const timeoutMs = 500;
336
+
337
+ return new Promise((res, rej) => {
338
+ const timeout = setTimeout(() => {
339
+ rej(new Error(`svgStringToXywh: SVG processing timeout after ${timeoutMs}ms`))
340
+ }, timeoutMs);
341
+
342
+ try {
343
+ // sanity checks
344
+ if (!(typeof svgString === "string" || svgString instanceof String) || !svgString?.length) {
345
+ rej(new Error("svgStringToXywh: SVG must be a string"))
346
+ }
347
+ if (svgString.length > 10000000) {
348
+ throw new Error("svgStringToXywh: SVG exceeds maximum size (10MB)");
349
+ }
350
+ if (!/<svg[^>]*>/i.test(svgString)) {
351
+ throw new Error("svgStringToXywh: invalid SVG: missing <svg> tag");
352
+ }
353
+ // compute xywh
354
+ const xywh = computeXywh(svgString);
355
+ clearTimeout(timeout);
356
+ res(xywh);
357
+
358
+ } catch (err) {
359
+ clearTimeout(timeout);
360
+ rej(err);
361
+ }
362
+ })
363
+ }
364
+
365
+ export { svgStringToXywh };
366
+
367
+ // QUICK PEFORMANCE TESTING: ~80ms / 10K SVGs
368
+ // const n = 10000
369
+ // const testSVGs = Array(10000).fill().map((_, i) => `
370
+ // <svg xmlns='http://www.w3.org/2000/svg'>
371
+ // <circle cx='${100 + i}' cy='100' r='50'/>
372
+ // <rect x='10' y='10' width='80' height='60'/>
373
+ // <polyline points='10,10 90,90 10,90'/>
374
+ // <circle cx='${100 + i}' cy='100' r='50'/>
375
+ // <rect x='10' y='10' width='80' height='60'/>
376
+ // <polyline points='10,10 90,90 10,90'/>
377
+ // <circle cx='${100 + i}' cy='100' r='50'/>
378
+ // <rect x='10' y='10' width='80' height='60'/>
379
+ // <polyline points='10,10 90,90 10,90'/>
380
+ // </svg>
381
+ // `);
382
+ // const msg = `Compute bbox for ${n} SVGs`;
383
+ // console.time(msg);
384
+ // testSVGs.forEach(svg => computeXywh(svg));
385
+ // console.timeEnd(msg);
386
+
387
+ // OTHER USE CASES:
388
+ // const testCases = [
389
+ // // Circle
390
+ // `<svg xmlns='http://www.w3.org/2000/svg'>
391
+ // <circle cx='100' cy='100' r='50'/>
392
+ // </svg>`,
393
+ //
394
+ // // Rectangle
395
+ // `<svg xmlns='http://www.w3.org/2000/svg'>
396
+ // <rect x='10' y='10' width='80' height='60'/>
397
+ // </svg>`,
398
+ //
399
+ // // Polyline
400
+ // `<svg xmlns='http://www.w3.org/2000/svg'>
401
+ // <polyline points='10,10 90,90 10,90'/>
402
+ // </svg>`,
403
+ //
404
+ // // Mixed with transform
405
+ // `<svg xmlns='http://www.w3.org/2000/svg'>
406
+ // <g transform='translate(100, 100)'>
407
+ // <circle cx='0' cy='0' r='50'/>
408
+ // <rect x='-40' y='-30' width='80' height='60'/>
409
+ // </g>
410
+ // </svg>`,
411
+ // ];
412
+ //
413
+ // testCases.forEach((svg, i) => {
414
+ // const bbox = svgStringToXywh(svg);
415
+ // console.log(`Test ${i + 1}:`, bbox);
416
+ // });
417
+