@annotorious/annotorious 3.2.0 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,3 +2,4 @@ export declare const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
2
2
  export declare const sanitize: (doc: Element | Document) => Element | Document;
3
3
  /** Helper that forces an un-namespaced node to SVG **/
4
4
  export declare const insertSVGNamespace: (originalDoc: Document) => Element;
5
+ export declare const parseSVGXML: (value: string) => Element;
@@ -0,0 +1,2 @@
1
+ import { MultiPolygonElement } from '../../core';
2
+ export declare const svgPathToMultiPolygonElement: (d: string) => MultiPolygonElement | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@annotorious/annotorious",
3
- "version": "3.2.0",
3
+ "version": "3.2.1",
4
4
  "description": "Add image annotation functionality to any web page with a few lines of JavaScript",
5
5
  "author": "Rainer Simon",
6
6
  "license": "BSD-3-Clause",
@@ -49,8 +49,9 @@
49
49
  "vitest": "^3.0.8"
50
50
  },
51
51
  "dependencies": {
52
- "@annotorious/core": "3.2.0",
52
+ "@annotorious/core": "3.2.1",
53
53
  "rbush": "^4.0.1",
54
+ "svg-pathdata": "^7.1.0",
54
55
  "uuid": "^11.1.0"
55
56
  }
56
57
  }
@@ -33,3 +33,19 @@ export const insertSVGNamespace = (originalDoc: Document): Element => {
33
33
  const namespacedDoc = parser.parseFromString(namespaced, "image/svg+xml");
34
34
  return namespacedDoc.documentElement;
35
35
  }
36
+
37
+ export const parseSVGXML = (value: string): Element => {
38
+ const parser = new DOMParser();
39
+
40
+ const doc = parser.parseFromString(value, 'image/svg+xml');
41
+
42
+ // SVG needs a namespace declaration - check if it's set or insert if not
43
+ const isPrefixDeclared = doc.lookupPrefix(SVG_NAMESPACE); // SVG declared via prefix
44
+ const isDefaultNamespaceSVG = doc.lookupNamespaceURI(null); // SVG declared as default namespace
45
+
46
+ if (isPrefixDeclared || isDefaultNamespaceSVG) {
47
+ return sanitize(doc).firstChild as Element;
48
+ } else {
49
+ return sanitize(insertSVGNamespace(doc)).firstChild as Element;
50
+ }
51
+ }
@@ -1,6 +1,15 @@
1
- import type { Ellipse, EllipseGeometry, Polygon, PolygonGeometry, Shape } from '../../core';
2
- import { boundsFromPoints, ShapeType } from '../../core';
3
- import { insertSVGNamespace, sanitize, SVG_NAMESPACE } from './SVG';
1
+ import { boundsFromPoints, multipolygonElementToPath, ShapeType } from '../../core';
2
+ import { parseSVGXML } from './SVG';
3
+ import { svgPathToMultiPolygonElement } from './pathParser';
4
+ import type {
5
+ Ellipse,
6
+ EllipseGeometry,
7
+ MultiPolygon,
8
+ MultiPolygonGeometry,
9
+ Polygon,
10
+ PolygonGeometry,
11
+ Shape
12
+ } from '../../core';
4
13
 
5
14
  export interface SVGSelector {
6
15
 
@@ -10,24 +19,8 @@ export interface SVGSelector {
10
19
 
11
20
  }
12
21
 
13
- const parseSVGXML = (value: string): Element => {
14
- const parser = new DOMParser();
15
-
16
- const doc = parser.parseFromString(value, "image/svg+xml");
17
-
18
- // SVG needs a namespace declaration - check if it's set or insert if not
19
- const isPrefixDeclared = doc.lookupPrefix(SVG_NAMESPACE); // SVG declared via prefix
20
- const isDefaultNamespaceSVG = doc.lookupNamespaceURI(null); // SVG declared as default namespace
21
-
22
- if (isPrefixDeclared || isDefaultNamespaceSVG) {
23
- return sanitize(doc).firstChild as Element;
24
- } else {
25
- return sanitize(insertSVGNamespace(doc)).firstChild as Element;
26
- }
27
- }
28
-
29
22
  const parseSVGPolygon = (value: string): Polygon => {
30
- const [a, b, str] = value.match(/(<polygon points=["|'])([^("|')]*)/) || [];
23
+ const [_, __, str] = value.match(/(<polygon points=["|'])([^("|')]*)/) || [];
31
24
  const points = str.split(' ').map((p) => p.split(',').map(parseFloat));
32
25
 
33
26
  return {
@@ -66,17 +59,57 @@ const parseSVGEllipse = (value: string): Ellipse => {
66
59
  };
67
60
  }
68
61
 
62
+ const parseSVGPath = (value: string): Polygon | MultiPolygon => {
63
+ const doc = parseSVGXML(value);
64
+
65
+ const paths = doc.nodeName === 'path' ? [doc] : Array.from(doc.querySelectorAll('path'));
66
+ const d = paths.map(path => path.getAttribute('d') || '');
67
+
68
+ const polygons = d.map(d => svgPathToMultiPolygonElement(d)!).filter(Boolean);
69
+
70
+ const outerPoints = polygons.reduce<[number, number][]>((points, element) => {
71
+ return [...points, ...element.rings[0].points]
72
+ }, []);
73
+
74
+ const bounds = boundsFromPoints(outerPoints);
75
+
76
+ // No need to create a MultiPolygon if theres only a single element with an outer ring
77
+ const isSinglePolygon = polygons.length === 1 && polygons[0].rings.length === 1;
78
+ return isSinglePolygon ? {
79
+ type: ShapeType.POLYGON,
80
+ geometry: {
81
+ points: outerPoints,
82
+ bounds
83
+ }
84
+ } : {
85
+ type: ShapeType.MULTIPOLYGLON,
86
+ geometry: {
87
+ polygons,
88
+ bounds
89
+ }
90
+ }
91
+ }
92
+
69
93
  export const parseSVGSelector = <T extends Shape>(valueOrSelector: SVGSelector | string): T => {
70
94
  const value = typeof valueOrSelector === 'string' ? valueOrSelector : valueOrSelector.value;
71
95
 
72
96
  if (value.includes('<polygon points='))
73
97
  return parseSVGPolygon(value) as unknown as T;
98
+ else if (value.includes('<path '))
99
+ return parseSVGPath(value) as unknown as T;
74
100
  else if (value.includes('<ellipse '))
75
101
  return parseSVGEllipse(value) as unknown as T;
76
102
  else
77
103
  throw 'Unsupported SVG shape: ' + value;
78
104
  }
79
105
 
106
+ const serializeMultiPolygon = (geom: MultiPolygonGeometry) => {
107
+ const paths = geom.polygons.map(elem =>
108
+ `<path fill-rule="evenodd" d="${multipolygonElementToPath(elem)}" />`);
109
+
110
+ return `<g>${paths.join('')}</g>`
111
+ }
112
+
80
113
  export const serializeSVGSelector = (shape: Shape): SVGSelector => {
81
114
  let value: string | undefined;
82
115
 
@@ -86,7 +119,10 @@ export const serializeSVGSelector = (shape: Shape): SVGSelector => {
86
119
  value = `<svg><polygon points="${points.map((xy) => xy.join(',')).join(' ')}" /></svg>`;
87
120
  } else if (shape.type === ShapeType.ELLIPSE) {
88
121
  const geom = shape.geometry as EllipseGeometry;
89
- value = `<svg><ellipse cx="${geom.cx}" cy="${geom.cy}" rx="${geom.rx}" ry="${geom.ry}" /></svg>`
122
+ value = `<svg><ellipse cx="${geom.cx}" cy="${geom.cy}" rx="${geom.rx}" ry="${geom.ry}" /></svg>`;
123
+ } else if (shape.type === ShapeType.MULTIPOLYGLON) {
124
+ const geom = shape.geometry as MultiPolygonGeometry;
125
+ value = `<svg>${serializeMultiPolygon(geom)}</svg>`;
90
126
  }
91
127
 
92
128
  if (value) {
@@ -0,0 +1,73 @@
1
+ import { SVGPathData } from 'svg-pathdata';
2
+ import { boundsFromPoints, type MultiPolygonElement, type MultiPolygonRing } from '../../core';
3
+
4
+ export const svgPathToMultiPolygonElement = (d: string): MultiPolygonElement | undefined => {
5
+ const commands = new SVGPathData(d).toAbs().commands;
6
+
7
+ const rings: MultiPolygonRing[] = [];
8
+
9
+ let currentRing: [number, number][] = [];
10
+ let startPoint: [number, number] | null = null;
11
+
12
+ for (const cmd of commands) {
13
+ switch (cmd.type) {
14
+ case SVGPathData.MOVE_TO:
15
+ // Start a new ring
16
+ if (currentRing.length > 0) {
17
+ rings.push({ points: currentRing });
18
+ currentRing = [];
19
+ }
20
+
21
+ startPoint = [cmd.x, cmd.y];
22
+ currentRing.push(startPoint);
23
+ break;
24
+
25
+ case SVGPathData.LINE_TO:
26
+ currentRing.push([cmd.x, cmd.y]);
27
+ break;
28
+
29
+ case SVGPathData.HORIZ_LINE_TO:
30
+ // Get the last Y coordinate since H command only specifies X
31
+ const lastY = currentRing[currentRing.length - 1][1];
32
+ currentRing.push([cmd.x, lastY]);
33
+ break;
34
+
35
+ case SVGPathData.VERT_LINE_TO:
36
+ // Get the last X coordinate since V command only specifies Y
37
+ const lastX = currentRing[currentRing.length - 1][0];
38
+ currentRing.push([lastX, cmd.y]);
39
+ break;
40
+
41
+ /** Might be needed in the future **
42
+ case SVGPathData.CLOSE_PATH:
43
+ if (startPoint &&
44
+ (currentRing[currentRing.length - 1][0] !== startPoint[0] ||
45
+ currentRing[currentRing.length - 1][1] !== startPoint[1])) {
46
+ currentRing.push([...startPoint]);
47
+ }
48
+ rings.push({ points: currentRing });
49
+ currentRing = [];
50
+ startPoint = null;
51
+ break;
52
+ **/
53
+
54
+ // For curve commands, we just add the end point
55
+ case SVGPathData.CURVE_TO:
56
+ case SVGPathData.SMOOTH_CURVE_TO:
57
+ case SVGPathData.QUAD_TO:
58
+ case SVGPathData.SMOOTH_QUAD_TO:
59
+ case SVGPathData.ARC:
60
+ currentRing.push([cmd.x, cmd.y]);
61
+ break;
62
+ }
63
+ }
64
+
65
+ if (currentRing.length > 2)
66
+ rings.push({ points: currentRing });
67
+
68
+ if (rings.length > 0) {
69
+ const bounds = boundsFromPoints(rings[0].points);
70
+ return { rings, bounds };
71
+ }
72
+
73
+ }