@emasoft/svg-matrix 1.3.4 → 1.3.6
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/README.md +56 -0
- package/bin/svg-matrix.js +67 -3
- package/bin/svglinter.cjs +84 -0
- package/bin/svgm.js +37 -0
- package/dist/svg-matrix.global.min.js +2 -2
- package/dist/svg-matrix.min.js +2 -2
- package/dist/svg-toolbox.global.min.js +110 -110
- package/dist/svg-toolbox.min.js +110 -110
- package/dist/svgm.min.js +112 -112
- package/dist/version.json +8 -8
- package/package.json +1 -1
- package/src/animation-optimization.js +29 -0
- package/src/index.js +2 -2
- package/src/inkscape-support.js +768 -0
- package/src/svg-matrix-lib.js +2 -2
- package/src/svg-toolbox-lib.js +2 -2
- package/src/svg-toolbox.js +290 -3
- package/src/svg-validation-data.js +1 -0
- package/src/svgm-lib.js +2 -2
package/src/inkscape-support.js
CHANGED
|
@@ -16,6 +16,426 @@ export const SODIPODI_NS = "http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd";
|
|
|
16
16
|
// Inkscape-specific element and attribute prefixes
|
|
17
17
|
export const INKSCAPE_PREFIXES = ["inkscape", "sodipodi"];
|
|
18
18
|
|
|
19
|
+
// ============================================================================
|
|
20
|
+
// COMPLETE INKSCAPE NAMESPACE SCHEMA
|
|
21
|
+
// Sources:
|
|
22
|
+
// - https://gitlab.com/inkscape/inkscape/-/blob/master/src/attributes.cpp
|
|
23
|
+
// - https://github.com/validator/validator/blob/main/schema/svg11/inkscape-draft.rnc
|
|
24
|
+
// - https://github.com/validator/validator/blob/main/schema/svg11/inkscape.rnc
|
|
25
|
+
// - https://wiki.inkscape.org/wiki/Inkscape-specific_XML_attributes
|
|
26
|
+
// ============================================================================
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Complete list of valid inkscape: namespace attributes.
|
|
30
|
+
* Organized by category for documentation and validation.
|
|
31
|
+
*/
|
|
32
|
+
export const INKSCAPE_ATTRIBUTES = {
|
|
33
|
+
// Layer and grouping
|
|
34
|
+
groupmode: { type: "string", values: ["layer"], description: "Identifies a group as a layer" },
|
|
35
|
+
label: { type: "string", description: "Human-readable label for objects/layers" },
|
|
36
|
+
expanded: { type: "boolean", description: "UI state - whether group is expanded in layers panel" },
|
|
37
|
+
|
|
38
|
+
// Document and version info
|
|
39
|
+
version: { type: "string", description: "Inkscape version that created/edited the file" },
|
|
40
|
+
"document-units": { type: "string", values: ["px", "pt", "pc", "mm", "cm", "in"], description: "Default document units" },
|
|
41
|
+
|
|
42
|
+
// View state (stored in sodipodi:namedview)
|
|
43
|
+
zoom: { type: "number", description: "Current zoom level" },
|
|
44
|
+
rotation: { type: "number", description: "Current view rotation in degrees" },
|
|
45
|
+
cx: { type: "number", description: "View center X coordinate" },
|
|
46
|
+
cy: { type: "number", description: "View center Y coordinate" },
|
|
47
|
+
"window-width": { type: "number", description: "Window width in pixels" },
|
|
48
|
+
"window-height": { type: "number", description: "Window height in pixels" },
|
|
49
|
+
"window-x": { type: "number", description: "Window X position" },
|
|
50
|
+
"window-y": { type: "number", description: "Window Y position" },
|
|
51
|
+
"window-maximized": { type: "boolean", description: "Whether window is maximized" },
|
|
52
|
+
"current-layer": { type: "string", description: "ID of the currently active layer" },
|
|
53
|
+
|
|
54
|
+
// Page and desk appearance
|
|
55
|
+
pageopacity: { type: "number", min: 0, max: 1, description: "Page opacity" },
|
|
56
|
+
pageshadow: { type: "number", description: "Page shadow intensity" },
|
|
57
|
+
showpageshadow: { type: "boolean", description: "Whether to show page shadow" },
|
|
58
|
+
deskcolor: { type: "color", description: "Desk (canvas background) color" },
|
|
59
|
+
deskopacity: { type: "number", min: 0, max: 1, description: "Desk opacity" },
|
|
60
|
+
pagecheckerboard: { type: "boolean", description: "Show checkerboard pattern for transparency" },
|
|
61
|
+
// Alternate hyphenated forms also accepted by Inkscape
|
|
62
|
+
"desk-color": { type: "color", description: "Desk (canvas background) color (alternate form)" },
|
|
63
|
+
"desk-opacity": { type: "number", min: 0, max: 1, description: "Desk opacity (alternate form)" },
|
|
64
|
+
"desk-checkerboard": { type: "boolean", description: "Show checkerboard pattern (alternate form)" },
|
|
65
|
+
"clip-to-page": { type: "boolean", description: "Clip rendering to page bounds" },
|
|
66
|
+
"clip-to-page-rendering": { type: "boolean", description: "Clip rendering to page bounds (rendering mode)" },
|
|
67
|
+
"antialias-rendering": { type: "boolean", description: "Enable antialiasing in rendering" },
|
|
68
|
+
|
|
69
|
+
// Page dimensions
|
|
70
|
+
margin: { type: "string", description: "Page margin (CSS-like format)" },
|
|
71
|
+
bleed: { type: "string", description: "Page bleed area" },
|
|
72
|
+
"page-size": { type: "string", description: "Named page size (A4, Letter, etc.)" },
|
|
73
|
+
"svg-dpi": { type: "number", description: "DPI for SVG export" },
|
|
74
|
+
"origin-correction": { type: "string", description: "Origin correction for page positioning" },
|
|
75
|
+
"y-axis-down": { type: "boolean", description: "Y axis direction (true=down, false=up)" },
|
|
76
|
+
|
|
77
|
+
// Guides
|
|
78
|
+
lockguides: { type: "boolean", description: "Lock all guides from editing" },
|
|
79
|
+
color: { type: "color", description: "Guide color" },
|
|
80
|
+
|
|
81
|
+
// Object state
|
|
82
|
+
locked: { type: "boolean", description: "Object is locked from editing" },
|
|
83
|
+
pinned: { type: "boolean", description: "Object is pinned in place" },
|
|
84
|
+
collect: { type: "string", values: ["always", "never"], description: "Garbage collection behavior for defs" },
|
|
85
|
+
"highlight-color": { type: "color", description: "Highlight color for object selection" },
|
|
86
|
+
swatch: { type: "boolean", description: "Gradient is a color swatch" },
|
|
87
|
+
|
|
88
|
+
// Transform
|
|
89
|
+
"transform-center-x": { type: "number", description: "Custom rotation center X offset" },
|
|
90
|
+
"transform-center-y": { type: "number", description: "Custom rotation center Y offset" },
|
|
91
|
+
|
|
92
|
+
// Live Path Effects (LPE)
|
|
93
|
+
"path-effect": { type: "string", description: "Reference to inkscape:path-effect element (#id or #id;#id2)" },
|
|
94
|
+
"original-d": { type: "string", description: "Original path data before LPE application" },
|
|
95
|
+
original: { type: "string", description: "Original element reference" },
|
|
96
|
+
|
|
97
|
+
// Connectors
|
|
98
|
+
"connector-type": { type: "string", values: ["polyline", "orthogonal"], description: "Connector line type" },
|
|
99
|
+
"connector-curvature": { type: "number", description: "Connector curve amount" },
|
|
100
|
+
"connector-spacing": { type: "number", description: "Spacing around connectors" },
|
|
101
|
+
"connector-avoid": { type: "boolean", description: "Other connectors avoid this object" },
|
|
102
|
+
"connection-points": { type: "string", description: "Custom connection point definitions" },
|
|
103
|
+
"connection-start": { type: "string", description: "Start connection point reference" },
|
|
104
|
+
"connection-end": { type: "string", description: "End connection point reference" },
|
|
105
|
+
"connection-start-point": { type: "string", description: "Child-object reference for start connection" },
|
|
106
|
+
"connection-end-point": { type: "string", description: "Child-object reference for end connection" },
|
|
107
|
+
|
|
108
|
+
// 3D Box and Perspective
|
|
109
|
+
perspectiveID: { type: "string", description: "Reference to perspective element" },
|
|
110
|
+
"box3d-perspectiveID": { type: "string", description: "Reference to perspective element (3D box alternate)" },
|
|
111
|
+
"box3d-perspective-id": { type: "string", description: "Reference to perspective element (hyphenated form)" },
|
|
112
|
+
corner0: { type: "string", description: "3D box corner 0 coordinates" },
|
|
113
|
+
corner7: { type: "string", description: "3D box corner 7 coordinates" },
|
|
114
|
+
box3dsidetype: { type: "string", description: "3D box side type" },
|
|
115
|
+
persp3d: { type: "string", description: "3D perspective definition" },
|
|
116
|
+
vp_x: { type: "string", description: "X vanishing point" },
|
|
117
|
+
vp_y: { type: "string", description: "Y vanishing point" },
|
|
118
|
+
vp_z: { type: "string", description: "Z vanishing point" },
|
|
119
|
+
"persp3d-origin": { type: "string", description: "Perspective origin point" },
|
|
120
|
+
|
|
121
|
+
// Star/Polygon shapes
|
|
122
|
+
flatsided: { type: "boolean", description: "Polygon has flat sides (not star)" },
|
|
123
|
+
rounded: { type: "number", description: "Corner rounding amount" },
|
|
124
|
+
randomized: { type: "number", description: "Random vertex displacement" },
|
|
125
|
+
radius: { type: "number", description: "Shape radius" },
|
|
126
|
+
|
|
127
|
+
// References
|
|
128
|
+
href: { type: "string", description: "Inkscape-specific href reference" },
|
|
129
|
+
|
|
130
|
+
// Font and text
|
|
131
|
+
"font-specification": { type: "string", description: "Full font specification string (fontconfig format)" },
|
|
132
|
+
"font-spec": { type: "string", description: "Full font specification (alternate form)" },
|
|
133
|
+
|
|
134
|
+
// Text flow (SVG 1.2 draft)
|
|
135
|
+
srcNoMarkup: { type: "string", description: "Text source without markup" },
|
|
136
|
+
srcPango: { type: "string", description: "Text source in Pango markup" },
|
|
137
|
+
dstShape: { type: "string", description: "Text flow destination shape" },
|
|
138
|
+
dstPath: { type: "string", description: "Text flow destination path" },
|
|
139
|
+
dstBox: { type: "string", description: "Text flow destination box" },
|
|
140
|
+
dstColumn: { type: "string", description: "Text flow column settings" },
|
|
141
|
+
excludeShape: { type: "string", description: "Shapes to exclude from text flow" },
|
|
142
|
+
layoutOptions: { type: "string", description: "Text layout options" },
|
|
143
|
+
"auto-region": { type: "boolean", description: "Auto-create text flow region" },
|
|
144
|
+
|
|
145
|
+
// Export
|
|
146
|
+
"export-filename": { type: "string", description: "Default export filename" },
|
|
147
|
+
"export-xdpi": { type: "number", description: "Export X DPI" },
|
|
148
|
+
"export-ydpi": { type: "number", description: "Export Y DPI" },
|
|
149
|
+
|
|
150
|
+
// Spray tool
|
|
151
|
+
"spray-origin": { type: "string", description: "Spray tool origin reference" },
|
|
152
|
+
|
|
153
|
+
// Tiled clones
|
|
154
|
+
"tiled-clone-of": { type: "string", description: "Source element for tiled clone" },
|
|
155
|
+
"tile-cx": { type: "number", description: "Tile clone center X" },
|
|
156
|
+
"tile-cy": { type: "number", description: "Tile clone center Y" },
|
|
157
|
+
"tile-w": { type: "number", description: "Tile clone width" },
|
|
158
|
+
"tile-h": { type: "number", description: "Tile clone height" },
|
|
159
|
+
"tile-x0": { type: "number", description: "Tile origin X coordinate" },
|
|
160
|
+
"tile-y0": { type: "number", description: "Tile origin Y coordinate" },
|
|
161
|
+
|
|
162
|
+
// Grid and guide display (from inkscape-draft.rnc)
|
|
163
|
+
"grid-bbox": { type: "boolean", description: "Show grid bounding box" },
|
|
164
|
+
"grid-points": { type: "boolean", description: "Show grid points" },
|
|
165
|
+
"guide-bbox": { type: "boolean", description: "Show guide bounding box" },
|
|
166
|
+
"guide-points": { type: "boolean", description: "Show guide points" },
|
|
167
|
+
"object-bbox": { type: "boolean", description: "Show object bounding box" },
|
|
168
|
+
"object-nodes": { type: "boolean", description: "Show object nodes" },
|
|
169
|
+
"object-paths": { type: "boolean", description: "Show object paths" },
|
|
170
|
+
"object-points": { type: "boolean", description: "Show object points" },
|
|
171
|
+
|
|
172
|
+
// Markers and stock resources
|
|
173
|
+
marker: { type: "string", description: "Marker reference" },
|
|
174
|
+
stockid: { type: "string", description: "Stock marker/pattern ID" },
|
|
175
|
+
isstock: { type: "boolean", description: "Element is a stock resource (pattern, symbol, etc.)" },
|
|
176
|
+
menu: { type: "string", description: "Menu category for stock resources" },
|
|
177
|
+
"menu-tooltip": { type: "string", description: "Tooltip text for stock resource menu item" },
|
|
178
|
+
|
|
179
|
+
// Data handling
|
|
180
|
+
dataloss: { type: "boolean", description: "Indicates data loss on save" },
|
|
181
|
+
"has_abs_tolerance": { type: "boolean", description: "Has absolute tolerance" },
|
|
182
|
+
"output_extension": { type: "string", description: "Output extension ID" },
|
|
183
|
+
|
|
184
|
+
// Offset path
|
|
185
|
+
offset: { type: "number", description: "Offset distance for offset paths" },
|
|
186
|
+
|
|
187
|
+
// Stroke extensions (CSS-like)
|
|
188
|
+
"-inkscape-stroke": { type: "string", values: ["hairline"], description: "Inkscape stroke rendering mode" },
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Complete list of valid sodipodi: namespace attributes.
|
|
193
|
+
* Source: https://github.com/validator/validator/blob/main/schema/svg11/inkscape-draft.rnc
|
|
194
|
+
*/
|
|
195
|
+
export const SODIPODI_ATTRIBUTES = {
|
|
196
|
+
// Document info
|
|
197
|
+
docname: { type: "string", description: "Document filename" },
|
|
198
|
+
docbase: { type: "string", description: "Document base directory (absolute path)" },
|
|
199
|
+
version: { type: "string", description: "Sodipodi version that saved the document" },
|
|
200
|
+
modified: { type: "boolean", description: "Internal: document modified since last save" },
|
|
201
|
+
|
|
202
|
+
// Shape type
|
|
203
|
+
type: { type: "string", values: ["arc", "star", "spiral", "inkscape:offset"], description: "Sodipodi shape type" },
|
|
204
|
+
insensitive: { type: "boolean", description: "Object cannot be selected with mouse" },
|
|
205
|
+
nonprintable: { type: "boolean", description: "Object should not be printed" },
|
|
206
|
+
|
|
207
|
+
// Arc/Ellipse parameters
|
|
208
|
+
cx: { type: "number", description: "Arc center X" },
|
|
209
|
+
cy: { type: "number", description: "Arc center Y" },
|
|
210
|
+
rx: { type: "number", description: "Arc radius X" },
|
|
211
|
+
ry: { type: "number", description: "Arc radius Y" },
|
|
212
|
+
start: { type: "number", description: "Arc start angle (radians)" },
|
|
213
|
+
end: { type: "number", description: "Arc end angle (radians)" },
|
|
214
|
+
open: { type: "boolean", description: "Arc is open (not closed)" },
|
|
215
|
+
"arc-type": { type: "string", values: ["arc", "slice", "chord"], description: "Arc rendering type" },
|
|
216
|
+
|
|
217
|
+
// Star/Polygon parameters
|
|
218
|
+
star: { type: "boolean", description: "Shape is a star (vs polygon)" },
|
|
219
|
+
sides: { type: "number", min: 3, description: "Number of polygon/star sides" },
|
|
220
|
+
r1: { type: "number", description: "Star outer radius" },
|
|
221
|
+
r2: { type: "number", description: "Star inner radius" },
|
|
222
|
+
arg1: { type: "number", description: "Star angle argument 1 (radians)" },
|
|
223
|
+
arg2: { type: "number", description: "Star angle argument 2 (radians)" },
|
|
224
|
+
|
|
225
|
+
// Spiral parameters
|
|
226
|
+
spiral: { type: "boolean", description: "Shape is a spiral" },
|
|
227
|
+
expansion: { type: "number", description: "Spiral expansion rate" },
|
|
228
|
+
revolution: { type: "number", description: "Number of spiral revolutions" },
|
|
229
|
+
radius: { type: "number", description: "Spiral radius" },
|
|
230
|
+
argument: { type: "number", description: "Spiral argument (start angle in radians)" },
|
|
231
|
+
t0: { type: "number", min: 0, max: 1, description: "Spiral start parameter (0-1)" },
|
|
232
|
+
|
|
233
|
+
// Path and reference
|
|
234
|
+
original: { type: "string", description: "Original path or element reference" },
|
|
235
|
+
nodetypes: { type: "string", pattern: /^[csza]+$/, description: "Path node types (c=corner, s=smooth, z=symmetric, a=auto)" },
|
|
236
|
+
absref: { type: "string", description: "Absolute native path to external resource" },
|
|
237
|
+
|
|
238
|
+
// Text
|
|
239
|
+
role: { type: "string", values: ["line"], description: "Text span role (line = separate line)" },
|
|
240
|
+
linespacing: { type: "string", description: "Line spacing (percentage like '125%' or absolute)" },
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Valid sodipodi: namespace elements.
|
|
245
|
+
*/
|
|
246
|
+
export const SODIPODI_ELEMENTS = ["namedview", "guide"];
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Valid inkscape: namespace elements.
|
|
250
|
+
*/
|
|
251
|
+
export const INKSCAPE_ELEMENTS = [
|
|
252
|
+
"path-effect", // Live Path Effects
|
|
253
|
+
"perspective", // 3D perspective definitions
|
|
254
|
+
"page", // Multi-page document support
|
|
255
|
+
"clipboard", // Clipboard data container
|
|
256
|
+
"grid", // Grid definitions
|
|
257
|
+
"box3dside", // 3D box side element
|
|
258
|
+
];
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* SVG 1.2 draft elements used by Inkscape for flowed text.
|
|
262
|
+
* These are in the SVG namespace but are Inkscape-specific features.
|
|
263
|
+
*/
|
|
264
|
+
export const FLOW_TEXT_ELEMENTS = ["flowRoot", "flowPara", "flowRegion", "flowSpan", "flowDiv", "flowLine"];
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* SVG 2 features requiring browser polyfills.
|
|
268
|
+
* Source: https://gitlab.com/inkscape/inkscape/-/blob/master/src/extension/internal/polyfill/README.md
|
|
269
|
+
*
|
|
270
|
+
* Note on Mesh Gradients (SVG 2 CR 2016 §13.8):
|
|
271
|
+
* Stop elements inside meshpatch use 'path' attribute instead of 'offset'.
|
|
272
|
+
* Reference: https://www.w3.org/TR/2016/CR-SVG2-20160915/pservers.html
|
|
273
|
+
* - "offset - does not apply to mesh gradients"
|
|
274
|
+
* - "path - applies only to mesh gradients"
|
|
275
|
+
* The 'path' attribute contains a single c/C/l/L bezier command defining one
|
|
276
|
+
* edge of the Coons patch quadrilateral. Mesh gradients were later removed
|
|
277
|
+
* from the SVG 2 Recommendation but Inkscape still supports them.
|
|
278
|
+
*/
|
|
279
|
+
export const POLYFILL_FEATURES = {
|
|
280
|
+
meshGradient: {
|
|
281
|
+
elements: ["meshgradient", "meshrow", "meshpatch"],
|
|
282
|
+
description: "Bicubic mesh gradients (SVG 2 CR 2016, removed from final spec)",
|
|
283
|
+
polyfill: "inkscape-mesh-polyfill.js",
|
|
284
|
+
browserSupport: "None",
|
|
285
|
+
stopAttribute: "path", // NOT 'offset' - mesh stops define patch edges with bezier commands
|
|
286
|
+
},
|
|
287
|
+
hatchPaint: {
|
|
288
|
+
elements: ["hatch", "hatchpath"],
|
|
289
|
+
description: "Hatch paint server for patterns",
|
|
290
|
+
polyfill: "hatch.js",
|
|
291
|
+
browserSupport: "None",
|
|
292
|
+
limitations: "Relative path support incomplete",
|
|
293
|
+
},
|
|
294
|
+
hairlineStroke: {
|
|
295
|
+
cssProperty: "-inkscape-stroke",
|
|
296
|
+
values: ["hairline"],
|
|
297
|
+
description: "1-device-unit stroke regardless of zoom",
|
|
298
|
+
browserSupport: "None",
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Namespace URIs for validation and serialization.
|
|
304
|
+
*/
|
|
305
|
+
export const NAMESPACE_URIS = {
|
|
306
|
+
inkscape: INKSCAPE_NS,
|
|
307
|
+
sodipodi: SODIPODI_NS,
|
|
308
|
+
svg: "http://www.w3.org/2000/svg",
|
|
309
|
+
xlink: "http://www.w3.org/1999/xlink",
|
|
310
|
+
xml: "http://www.w3.org/XML/1998/namespace",
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Validate an inkscape: attribute value.
|
|
315
|
+
* @param {string} attrName - Attribute name without prefix
|
|
316
|
+
* @param {string} value - Attribute value
|
|
317
|
+
* @returns {{valid: boolean, error?: string}} Validation result
|
|
318
|
+
*/
|
|
319
|
+
export function validateInkscapeAttribute(attrName, value) {
|
|
320
|
+
const schema = INKSCAPE_ATTRIBUTES[attrName];
|
|
321
|
+
if (!schema) {
|
|
322
|
+
return { valid: true }; // Unknown attributes are allowed (future compatibility)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (schema.values && !schema.values.includes(value)) {
|
|
326
|
+
return { valid: false, error: `Invalid value "${value}" for inkscape:${attrName}. Expected: ${schema.values.join(", ")}` };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (schema.type === "number") {
|
|
330
|
+
const num = parseFloat(value);
|
|
331
|
+
if (isNaN(num)) {
|
|
332
|
+
return { valid: false, error: `inkscape:${attrName} must be a number` };
|
|
333
|
+
}
|
|
334
|
+
if (schema.min !== undefined && num < schema.min) {
|
|
335
|
+
return { valid: false, error: `inkscape:${attrName} must be >= ${schema.min}` };
|
|
336
|
+
}
|
|
337
|
+
if (schema.max !== undefined && num > schema.max) {
|
|
338
|
+
return { valid: false, error: `inkscape:${attrName} must be <= ${schema.max}` };
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (schema.type === "boolean") {
|
|
343
|
+
// Inkscape accepts: "true", "false", or any integer (0=false, non-zero=true)
|
|
344
|
+
if (value === "true" || value === "false") {
|
|
345
|
+
return { valid: true };
|
|
346
|
+
}
|
|
347
|
+
// Check if it's a valid integer
|
|
348
|
+
const intVal = parseInt(value, 10);
|
|
349
|
+
if (isNaN(intVal) || String(intVal) !== value) {
|
|
350
|
+
return { valid: false, error: `inkscape:${attrName} must be a boolean (true/false) or integer` };
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (schema.pattern && !schema.pattern.test(value)) {
|
|
355
|
+
return { valid: false, error: `inkscape:${attrName} has invalid format` };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return { valid: true };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Validate a sodipodi: attribute value.
|
|
363
|
+
* @param {string} attrName - Attribute name without prefix
|
|
364
|
+
* @param {string} value - Attribute value
|
|
365
|
+
* @returns {{valid: boolean, error?: string}} Validation result
|
|
366
|
+
*/
|
|
367
|
+
export function validateSodipodiAttribute(attrName, value) {
|
|
368
|
+
const schema = SODIPODI_ATTRIBUTES[attrName];
|
|
369
|
+
if (!schema) {
|
|
370
|
+
return { valid: true }; // Unknown attributes are allowed (future compatibility)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (schema.values && !schema.values.includes(value)) {
|
|
374
|
+
return { valid: false, error: `Invalid value "${value}" for sodipodi:${attrName}. Expected: ${schema.values.join(", ")}` };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (schema.type === "number") {
|
|
378
|
+
const num = parseFloat(value);
|
|
379
|
+
if (isNaN(num)) {
|
|
380
|
+
return { valid: false, error: `sodipodi:${attrName} must be a number` };
|
|
381
|
+
}
|
|
382
|
+
if (schema.min !== undefined && num < schema.min) {
|
|
383
|
+
return { valid: false, error: `sodipodi:${attrName} must be >= ${schema.min}` };
|
|
384
|
+
}
|
|
385
|
+
if (schema.max !== undefined && num > schema.max) {
|
|
386
|
+
return { valid: false, error: `sodipodi:${attrName} must be <= ${schema.max}` };
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (schema.pattern && !schema.pattern.test(value)) {
|
|
391
|
+
return { valid: false, error: `sodipodi:${attrName} has invalid format` };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return { valid: true };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Check if a document is an Inkscape SVG file.
|
|
399
|
+
* Checks for Inkscape namespaces and version attribute.
|
|
400
|
+
* @param {Object} doc - Parsed SVG document
|
|
401
|
+
* @returns {{isInkscape: boolean, version?: string, hasFlowText: boolean}}
|
|
402
|
+
*/
|
|
403
|
+
export function detectInkscapeDocument(doc) {
|
|
404
|
+
if (!doc) return { isInkscape: false, hasFlowText: false };
|
|
405
|
+
|
|
406
|
+
const svg = doc.documentElement || doc;
|
|
407
|
+
if (!svg || typeof svg.getAttribute !== "function") {
|
|
408
|
+
return { isInkscape: false, hasFlowText: false };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const hasInkscapeNs = svg.getAttribute("xmlns:inkscape") === INKSCAPE_NS;
|
|
412
|
+
const hasSodipodiNs = svg.getAttribute("xmlns:sodipodi") === SODIPODI_NS;
|
|
413
|
+
const inkscapeVersion = svg.getAttribute("inkscape:version");
|
|
414
|
+
|
|
415
|
+
// Check for flowRoot elements (Inkscape flowed text)
|
|
416
|
+
let hasFlowText = false;
|
|
417
|
+
const checkFlowText = (el) => {
|
|
418
|
+
if (!el) return;
|
|
419
|
+
if (FLOW_TEXT_ELEMENTS.includes(el.tagName)) {
|
|
420
|
+
hasFlowText = true;
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
if (el.children && Array.isArray(el.children)) {
|
|
424
|
+
for (const child of el.children) {
|
|
425
|
+
checkFlowText(child);
|
|
426
|
+
if (hasFlowText) return;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
checkFlowText(svg);
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
isInkscape: hasInkscapeNs || hasSodipodiNs || !!inkscapeVersion,
|
|
434
|
+
version: inkscapeVersion || undefined,
|
|
435
|
+
hasFlowText,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
19
439
|
/**
|
|
20
440
|
* Check if an element is an Inkscape layer.
|
|
21
441
|
* Inkscape uses `<g inkscape:groupmode="layer">` for layers.
|
|
@@ -774,3 +1194,351 @@ export function analyzeLayerDependencies(doc) {
|
|
|
774
1194
|
totalDefs: defsMap.size,
|
|
775
1195
|
};
|
|
776
1196
|
}
|
|
1197
|
+
|
|
1198
|
+
// ============================================================================
|
|
1199
|
+
// COMPREHENSIVE INKSCAPE DOCUMENT VALIDATION
|
|
1200
|
+
// ============================================================================
|
|
1201
|
+
|
|
1202
|
+
/**
|
|
1203
|
+
* Validation issue severity levels.
|
|
1204
|
+
*/
|
|
1205
|
+
export const InkscapeValidationSeverity = {
|
|
1206
|
+
ERROR: "error",
|
|
1207
|
+
WARNING: "warning",
|
|
1208
|
+
INFO: "info",
|
|
1209
|
+
};
|
|
1210
|
+
|
|
1211
|
+
/**
|
|
1212
|
+
* Validate all Inkscape/Sodipodi attributes in a document.
|
|
1213
|
+
* @param {Object} doc - Parsed SVG document
|
|
1214
|
+
* @param {Object} [options] - Validation options
|
|
1215
|
+
* @param {boolean} [options.strict=false] - Fail on unknown attributes
|
|
1216
|
+
* @param {boolean} [options.warnFlowText=true] - Warn about SVG 1.2 flowText
|
|
1217
|
+
* @param {boolean} [options.checkPolyfillNeeds=true] - Check for features needing polyfills
|
|
1218
|
+
* @returns {{
|
|
1219
|
+
* isValid: boolean,
|
|
1220
|
+
* isInkscape: boolean,
|
|
1221
|
+
* version?: string,
|
|
1222
|
+
* issues: Array<{severity: string, type: string, element: string, attribute?: string, message: string, line?: number}>,
|
|
1223
|
+
* summary: {errors: number, warnings: number, info: number},
|
|
1224
|
+
* polyfillsNeeded: string[],
|
|
1225
|
+
* hasFlowText: boolean
|
|
1226
|
+
* }}
|
|
1227
|
+
*/
|
|
1228
|
+
export function validateInkscapeDocument(doc, options = {}) {
|
|
1229
|
+
const {
|
|
1230
|
+
strict = false,
|
|
1231
|
+
warnFlowText = true,
|
|
1232
|
+
checkPolyfillNeeds = true,
|
|
1233
|
+
} = options;
|
|
1234
|
+
|
|
1235
|
+
const issues = [];
|
|
1236
|
+
const polyfillsNeeded = new Set();
|
|
1237
|
+
let hasFlowText = false;
|
|
1238
|
+
let hasMeshGradient = false;
|
|
1239
|
+
let hasHatch = false;
|
|
1240
|
+
|
|
1241
|
+
// Detect if this is an Inkscape document
|
|
1242
|
+
const detection = detectInkscapeDocument(doc);
|
|
1243
|
+
hasFlowText = detection.hasFlowText;
|
|
1244
|
+
|
|
1245
|
+
// Helper to add issue
|
|
1246
|
+
const addIssue = (severity, type, element, attribute, message) => {
|
|
1247
|
+
issues.push({ severity, type, element, attribute, message });
|
|
1248
|
+
};
|
|
1249
|
+
|
|
1250
|
+
// Check namespace declarations
|
|
1251
|
+
const svg = doc.documentElement || doc;
|
|
1252
|
+
if (svg && typeof svg.getAttribute === "function") {
|
|
1253
|
+
const inkscapeNs = svg.getAttribute("xmlns:inkscape");
|
|
1254
|
+
const sodipodiNs = svg.getAttribute("xmlns:sodipodi");
|
|
1255
|
+
|
|
1256
|
+
// Validate namespace URIs are correct
|
|
1257
|
+
if (inkscapeNs && inkscapeNs !== INKSCAPE_NS) {
|
|
1258
|
+
addIssue(
|
|
1259
|
+
InkscapeValidationSeverity.ERROR,
|
|
1260
|
+
"invalid_namespace_uri",
|
|
1261
|
+
"svg",
|
|
1262
|
+
"xmlns:inkscape",
|
|
1263
|
+
`Invalid Inkscape namespace URI. Expected: ${INKSCAPE_NS}, got: ${inkscapeNs}`
|
|
1264
|
+
);
|
|
1265
|
+
}
|
|
1266
|
+
if (sodipodiNs && sodipodiNs !== SODIPODI_NS) {
|
|
1267
|
+
addIssue(
|
|
1268
|
+
InkscapeValidationSeverity.ERROR,
|
|
1269
|
+
"invalid_namespace_uri",
|
|
1270
|
+
"svg",
|
|
1271
|
+
"xmlns:sodipodi",
|
|
1272
|
+
`Invalid Sodipodi namespace URI. Expected: ${SODIPODI_NS}, got: ${sodipodiNs}`
|
|
1273
|
+
);
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
// Walk the document tree and validate attributes
|
|
1278
|
+
const walkAndValidate = (el) => {
|
|
1279
|
+
if (!el || typeof el.getAttributeNames !== "function") return;
|
|
1280
|
+
|
|
1281
|
+
const tagName = el.tagName || "unknown";
|
|
1282
|
+
|
|
1283
|
+
// Check for elements needing polyfills
|
|
1284
|
+
if (checkPolyfillNeeds) {
|
|
1285
|
+
const tagLower = tagName.toLowerCase();
|
|
1286
|
+
if (tagLower === "meshgradient" || tagLower === "meshrow" || tagLower === "meshpatch") {
|
|
1287
|
+
hasMeshGradient = true;
|
|
1288
|
+
}
|
|
1289
|
+
if (tagLower === "hatch" || tagLower === "hatchpath") {
|
|
1290
|
+
hasHatch = true;
|
|
1291
|
+
}
|
|
1292
|
+
if (FLOW_TEXT_ELEMENTS.includes(tagName)) {
|
|
1293
|
+
hasFlowText = true;
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
// Check for sodipodi: elements
|
|
1298
|
+
if (tagName.startsWith("sodipodi:")) {
|
|
1299
|
+
const elementName = tagName.substring(9);
|
|
1300
|
+
if (!SODIPODI_ELEMENTS.includes(elementName)) {
|
|
1301
|
+
addIssue(
|
|
1302
|
+
InkscapeValidationSeverity.ERROR,
|
|
1303
|
+
"unknown_sodipodi_element",
|
|
1304
|
+
tagName,
|
|
1305
|
+
null,
|
|
1306
|
+
`Unknown sodipodi element: ${tagName} - not in Sodipodi namespace schema`
|
|
1307
|
+
);
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
// Check for inkscape: elements
|
|
1312
|
+
if (tagName.startsWith("inkscape:")) {
|
|
1313
|
+
const elementName = tagName.substring(9);
|
|
1314
|
+
if (!INKSCAPE_ELEMENTS.includes(elementName)) {
|
|
1315
|
+
addIssue(
|
|
1316
|
+
InkscapeValidationSeverity.ERROR,
|
|
1317
|
+
"unknown_inkscape_element",
|
|
1318
|
+
tagName,
|
|
1319
|
+
null,
|
|
1320
|
+
`Unknown inkscape element: ${tagName} - not in Inkscape namespace schema`
|
|
1321
|
+
);
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
// Validate attributes
|
|
1326
|
+
for (const attrName of el.getAttributeNames()) {
|
|
1327
|
+
const value = el.getAttribute(attrName);
|
|
1328
|
+
|
|
1329
|
+
// Validate inkscape: attributes
|
|
1330
|
+
if (attrName.startsWith("inkscape:")) {
|
|
1331
|
+
const attrLocalName = attrName.substring(9);
|
|
1332
|
+
const validation = validateInkscapeAttribute(attrLocalName, value);
|
|
1333
|
+
if (!validation.valid) {
|
|
1334
|
+
addIssue(
|
|
1335
|
+
InkscapeValidationSeverity.ERROR,
|
|
1336
|
+
"invalid_inkscape_attribute",
|
|
1337
|
+
tagName,
|
|
1338
|
+
attrName,
|
|
1339
|
+
validation.error
|
|
1340
|
+
);
|
|
1341
|
+
} else if (strict && !INKSCAPE_ATTRIBUTES[attrLocalName]) {
|
|
1342
|
+
addIssue(
|
|
1343
|
+
InkscapeValidationSeverity.ERROR,
|
|
1344
|
+
"unknown_inkscape_attribute",
|
|
1345
|
+
tagName,
|
|
1346
|
+
attrName,
|
|
1347
|
+
`Unknown inkscape attribute: ${attrName} - not in Inkscape namespace schema`
|
|
1348
|
+
);
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
// Validate sodipodi: attributes
|
|
1353
|
+
if (attrName.startsWith("sodipodi:")) {
|
|
1354
|
+
const attrLocalName = attrName.substring(9);
|
|
1355
|
+
const validation = validateSodipodiAttribute(attrLocalName, value);
|
|
1356
|
+
if (!validation.valid) {
|
|
1357
|
+
addIssue(
|
|
1358
|
+
InkscapeValidationSeverity.ERROR,
|
|
1359
|
+
"invalid_sodipodi_attribute",
|
|
1360
|
+
tagName,
|
|
1361
|
+
attrName,
|
|
1362
|
+
validation.error
|
|
1363
|
+
);
|
|
1364
|
+
} else if (strict && !SODIPODI_ATTRIBUTES[attrLocalName]) {
|
|
1365
|
+
addIssue(
|
|
1366
|
+
InkscapeValidationSeverity.ERROR,
|
|
1367
|
+
"unknown_sodipodi_attribute",
|
|
1368
|
+
tagName,
|
|
1369
|
+
attrName,
|
|
1370
|
+
`Unknown sodipodi attribute: ${attrName} - not in Sodipodi namespace schema`
|
|
1371
|
+
);
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
// Check for -inkscape-stroke CSS property in style
|
|
1376
|
+
if (attrName === "style" && value.includes("-inkscape-stroke")) {
|
|
1377
|
+
polyfillsNeeded.add("hairlineStroke");
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
// Recurse into children
|
|
1382
|
+
if (el.children && Array.isArray(el.children)) {
|
|
1383
|
+
for (const child of el.children) {
|
|
1384
|
+
walkAndValidate(child);
|
|
1385
|
+
}
|
|
1386
|
+
} else if (el.childNodes) {
|
|
1387
|
+
for (const child of el.childNodes) {
|
|
1388
|
+
if (child.nodeType === 1) { // Element node
|
|
1389
|
+
walkAndValidate(child);
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
};
|
|
1394
|
+
|
|
1395
|
+
walkAndValidate(svg);
|
|
1396
|
+
|
|
1397
|
+
// Add polyfill needs
|
|
1398
|
+
if (hasMeshGradient) polyfillsNeeded.add("meshGradient");
|
|
1399
|
+
if (hasHatch) polyfillsNeeded.add("hatchPaint");
|
|
1400
|
+
if (hasFlowText && warnFlowText) {
|
|
1401
|
+
addIssue(
|
|
1402
|
+
InkscapeValidationSeverity.WARNING,
|
|
1403
|
+
"flowtext_compatibility",
|
|
1404
|
+
"flowRoot",
|
|
1405
|
+
null,
|
|
1406
|
+
"SVG contains flowRoot elements (SVG 1.2 draft). These are not supported by browsers and should be converted to regular text."
|
|
1407
|
+
);
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
// Count issues by severity
|
|
1411
|
+
const summary = {
|
|
1412
|
+
errors: issues.filter(i => i.severity === InkscapeValidationSeverity.ERROR).length,
|
|
1413
|
+
warnings: issues.filter(i => i.severity === InkscapeValidationSeverity.WARNING).length,
|
|
1414
|
+
info: issues.filter(i => i.severity === InkscapeValidationSeverity.INFO).length,
|
|
1415
|
+
};
|
|
1416
|
+
|
|
1417
|
+
return {
|
|
1418
|
+
isValid: summary.errors === 0,
|
|
1419
|
+
isInkscape: detection.isInkscape,
|
|
1420
|
+
version: detection.version,
|
|
1421
|
+
issues,
|
|
1422
|
+
summary,
|
|
1423
|
+
polyfillsNeeded: [...polyfillsNeeded],
|
|
1424
|
+
hasFlowText,
|
|
1425
|
+
};
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
/**
|
|
1429
|
+
* Get the list of polyfills needed for browser rendering.
|
|
1430
|
+
* @param {Object} doc - Parsed SVG document
|
|
1431
|
+
* @returns {{
|
|
1432
|
+
* meshGradient: boolean,
|
|
1433
|
+
* hatchPaint: boolean,
|
|
1434
|
+
* hairlineStroke: boolean,
|
|
1435
|
+
* flowText: boolean,
|
|
1436
|
+
* polyfillScripts: string[]
|
|
1437
|
+
* }}
|
|
1438
|
+
*/
|
|
1439
|
+
export function getPolyfillRequirements(doc) {
|
|
1440
|
+
const result = {
|
|
1441
|
+
meshGradient: false,
|
|
1442
|
+
hatchPaint: false,
|
|
1443
|
+
hairlineStroke: false,
|
|
1444
|
+
flowText: false,
|
|
1445
|
+
polyfillScripts: [],
|
|
1446
|
+
};
|
|
1447
|
+
|
|
1448
|
+
const svg = doc.documentElement || doc;
|
|
1449
|
+
if (!svg) return result;
|
|
1450
|
+
|
|
1451
|
+
const walk = (el) => {
|
|
1452
|
+
if (!el) return;
|
|
1453
|
+
|
|
1454
|
+
const tagName = (el.tagName || "").toLowerCase();
|
|
1455
|
+
|
|
1456
|
+
// Check for mesh gradient elements
|
|
1457
|
+
if (tagName === "meshgradient" || tagName === "meshrow" || tagName === "meshpatch") {
|
|
1458
|
+
result.meshGradient = true;
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
// Check for hatch elements
|
|
1462
|
+
if (tagName === "hatch" || tagName === "hatchpath") {
|
|
1463
|
+
result.hatchPaint = true;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
// Check for flowText elements
|
|
1467
|
+
if (FLOW_TEXT_ELEMENTS.includes(el.tagName)) {
|
|
1468
|
+
result.flowText = true;
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
// Check for hairline stroke in style
|
|
1472
|
+
if (typeof el.getAttribute === "function") {
|
|
1473
|
+
const style = el.getAttribute("style");
|
|
1474
|
+
if (style && style.includes("-inkscape-stroke")) {
|
|
1475
|
+
result.hairlineStroke = true;
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
// Recurse
|
|
1480
|
+
if (el.children && Array.isArray(el.children)) {
|
|
1481
|
+
for (const child of el.children) {
|
|
1482
|
+
walk(child);
|
|
1483
|
+
}
|
|
1484
|
+
} else if (el.childNodes) {
|
|
1485
|
+
for (const child of el.childNodes) {
|
|
1486
|
+
if (child.nodeType === 1) walk(child);
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
};
|
|
1490
|
+
|
|
1491
|
+
walk(svg);
|
|
1492
|
+
|
|
1493
|
+
// Build polyfill script list
|
|
1494
|
+
if (result.meshGradient) {
|
|
1495
|
+
result.polyfillScripts.push("inkscape-mesh-polyfill.min.js");
|
|
1496
|
+
}
|
|
1497
|
+
if (result.hatchPaint) {
|
|
1498
|
+
result.polyfillScripts.push("inkscape-hatch-polyfill.min.js");
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
return result;
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
/**
|
|
1505
|
+
* Inject polyfill scripts into an SVG document for browser rendering.
|
|
1506
|
+
* @param {Object} doc - Parsed SVG document
|
|
1507
|
+
* @param {Object} [options] - Options
|
|
1508
|
+
* @param {boolean} [options.minified=true] - Use minified polyfills
|
|
1509
|
+
* @param {string} [options.polyfillPath=""] - Base path for polyfill scripts
|
|
1510
|
+
* @returns {Object} Modified document with polyfills injected
|
|
1511
|
+
*/
|
|
1512
|
+
export function injectInkscapePolyfills(doc, options = {}) {
|
|
1513
|
+
const { minified = true, polyfillPath = "" } = options;
|
|
1514
|
+
|
|
1515
|
+
const requirements = getPolyfillRequirements(doc);
|
|
1516
|
+
const svg = doc.documentElement || doc;
|
|
1517
|
+
|
|
1518
|
+
if (!svg || typeof svg.appendChild !== "function") {
|
|
1519
|
+
return doc;
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
// Add polyfill scripts
|
|
1523
|
+
for (const script of requirements.polyfillScripts) {
|
|
1524
|
+
const scriptName = minified ? script : script.replace(".min.js", ".js");
|
|
1525
|
+
const scriptPath = polyfillPath ? `${polyfillPath}/${scriptName}` : scriptName;
|
|
1526
|
+
|
|
1527
|
+
// Create script element - using the SVGElement class if available
|
|
1528
|
+
if (typeof SVGElement !== "undefined" && SVGElement.prototype) {
|
|
1529
|
+
const scriptEl = new SVGElement("script", {
|
|
1530
|
+
type: "text/javascript",
|
|
1531
|
+
href: scriptPath,
|
|
1532
|
+
}, [], "");
|
|
1533
|
+
|
|
1534
|
+
// Insert at the beginning of SVG
|
|
1535
|
+
if (svg.children && svg.children.length > 0) {
|
|
1536
|
+
svg.children.unshift(scriptEl);
|
|
1537
|
+
} else if (svg.insertBefore) {
|
|
1538
|
+
svg.insertBefore(scriptEl, svg.firstChild);
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
return doc;
|
|
1544
|
+
}
|