@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.
- package/LICENSE +661 -0
- package/README.md +206 -0
- package/cli/export.js +118 -0
- package/cli/import.js +111 -0
- package/cli/index.js +45 -0
- package/cli/migrate.js +131 -0
- package/cli/serve.js +29 -0
- package/cli/utils/fastifyClient.js +86 -0
- package/cli/utils/io.js +225 -0
- package/cli/utils/mongoClient.js +21 -0
- package/cli/utils/progressbar.js +86 -0
- package/cli/xywhToInt.js +99 -0
- package/config/.env.template +48 -0
- package/docker/Dockerfile +39 -0
- package/docker/README.md +5 -0
- package/docker/docker-compose.yaml +49 -0
- package/docker/docker.sh +43 -0
- package/docker/docker_aiiinotate_import.sh +62 -0
- package/docs/api.md +381 -0
- package/docs/cli.md +132 -0
- package/docs/dev_documentation/dev_architecture.md +88 -0
- package/docs/dev_documentation/dev_db.md +58 -0
- package/docs/dev_documentation/dev_iiif_compatibility.md +43 -0
- package/docs/dev_documentation/dev_notes_quirks_and_troubleshooting.md +143 -0
- package/docs/docker.md +98 -0
- package/docs/includes/report_benchmark_aiiinotate_2026-05-28-02:50:48_7steps.png +0 -0
- package/docs/scalability.md +34 -0
- package/docs/specifications/0_w3c_open_annotations.md +332 -0
- package/docs/specifications/1_w3c_web_annotations.md +577 -0
- package/docs/specifications/2_iiif_apis.md +428 -0
- package/docs/specifications/3_iiif_annotations.md +103 -0
- package/docs/specifications/4_search_api.md +135 -0
- package/docs/specifications/5_sas.md +119 -0
- package/docs/specifications/6_mirador.md +119 -0
- package/docs/specifications/7_aikon.md +137 -0
- package/docs/specifications/include/presentation_2.0.webp +0 -0
- package/docs/specifications/include/presentation_2.0_white.png +0 -0
- package/docs/specifications/include/presentation_3.0.png +0 -0
- package/docs/specifications/include/presentation_3.0_resize.png +0 -0
- package/eslint.config.js +30 -0
- package/migrations/baseConfig.js +57 -0
- package/migrations/manageIndex.js +55 -0
- package/migrations/migrate-mongo-config-main.js +8 -0
- package/migrations/migrate-mongo-config-test.js +8 -0
- package/migrations/migrationScripts/20250825185706-collections.js +48 -0
- package/migrations/migrationScripts/20250826194832-annotations2-schema.js +42 -0
- package/migrations/migrationScripts/20250904080710-annotations2-indexes.js +69 -0
- package/migrations/migrationScripts/20251002141951-manifests2-schema.js +43 -0
- package/migrations/migrationScripts/20251006212110-manifests2-indexes.js +35 -0
- package/migrations/migrationTemplate.js +25 -0
- package/package.json +82 -0
- package/scripts/get_version.py +12 -0
- package/scripts/run.sh +36 -0
- package/scripts/setup_mongodb.sh +70 -0
- package/scripts/setup_node.sh +15 -0
- package/scripts/update_version.py +30 -0
- package/scripts/utils.sh +65 -0
- package/src/app.js +116 -0
- package/src/constants.js +73 -0
- package/src/data/annotations/annotations2.js +681 -0
- package/src/data/annotations/annotations3.js +28 -0
- package/src/data/annotations/routes.js +335 -0
- package/src/data/annotations/routes.test.js +271 -0
- package/src/data/collectionAbstract.js +283 -0
- package/src/data/index.js +29 -0
- package/src/data/manifests/manifests2.js +378 -0
- package/src/data/manifests/manifests2.test.js +53 -0
- package/src/data/manifests/manifests3.js +23 -0
- package/src/data/manifests/routes.js +122 -0
- package/src/data/manifests/routes.test.js +70 -0
- package/src/data/routes.js +181 -0
- package/src/data/routes.test.js +166 -0
- package/src/db/index.js +50 -0
- package/src/fixtures/annotations.js +41 -0
- package/src/fixtures/data/annotationList_aikon_wit9_man11_anno165_all.jsonld +827 -0
- package/src/fixtures/data/annotationList_vhs_wit250_man250_anno250_all.jsonld +37514 -0
- package/src/fixtures/data/annotationList_vhs_wit253_man253_anno253_all.jsonld +20111 -0
- package/src/fixtures/data/annotations2Invalid.jsonld +39 -0
- package/src/fixtures/data/annotations2SvgValid.jsonld +81 -0
- package/src/fixtures/data/annotations2Valid.jsonld +39 -0
- package/src/fixtures/data/bnf_invalid_manifest.json +2806 -0
- package/src/fixtures/data/bnf_valid_manifest.json +2817 -0
- package/src/fixtures/data/vhs_wit253_man253_anno253_anno-24.json +1 -0
- package/src/fixtures/generate.js +181 -0
- package/src/fixtures/index.js +69 -0
- package/src/fixtures/manifests.js +14 -0
- package/src/fixtures/utils.js +37 -0
- package/src/schemas/index.js +100 -0
- package/src/schemas/schemasBase.js +19 -0
- package/src/schemas/schemasPresentation2.js +410 -0
- package/src/schemas/schemasPresentation3.js +33 -0
- package/src/schemas/schemasResolver.js +71 -0
- package/src/schemas/schemasRoutes.js +318 -0
- package/src/server.js +25 -0
- package/src/types.js +97 -0
- package/src/utils/iiif2Utils.js +332 -0
- package/src/utils/iiif2Utils.test.js +146 -0
- package/src/utils/iiif3Utils.js +0 -0
- package/src/utils/iiifUtils.js +18 -0
- package/src/utils/logger.js +119 -0
- package/src/utils/routeUtils.js +137 -0
- package/src/utils/svg.js +417 -0
- package/src/utils/testUtils.js +289 -0
- 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
|
+
}
|
package/src/utils/svg.js
ADDED
|
@@ -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
|
+
|