@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.
@@ -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
+ }