Line data Source code
1 : // @ts-check
2 : /* eslint-disable max-lines */
3 :
4 : import xmldoc from "xmldoc";
5 : import { get, includes, isNil, keys, map, toString, trim } from "lodash";
6 : import { parser } from "@cern/ssvg-engine";
7 :
8 : /**
9 : * @typedef {import("@codemirror/lint").Diagnostic} Diagnostic
10 : * @typedef {import("@codemirror/lang-xml").ElementSpec} ElementSpec
11 : */
12 :
13 : /**
14 : * @param {*} err
15 : */ // @ts-ignore
16 1 : xmldoc.XmlDocument.prototype._error = function(err) {
17 : // @ts-ignore
18 1 : if (!isNil(this?.parser?.line)) {
19 1 : throw { // @ts-ignore
20 : from: this.parser.position, to: this.parser.position,
21 : severity: "error",
22 : message: toString(err)
23 : };
24 : }
25 0 : throw err;
26 : };
27 :
28 : /**
29 : * @type {{ [name: string]: ElementSpec }}
30 : */
31 1 : export const ssvgElements = {
32 : relation: {
33 : name: "relation",
34 : children: [ "transition" ],
35 : attributes: [
36 : { name: "query-selector" },
37 : {
38 : name: "attribute-name", values: [
39 : "opacity",
40 : "width",
41 : "height",
42 : "color",
43 : "background-color",
44 : "offset",
45 : "cx",
46 : "cy",
47 : "r",
48 : "radius",
49 : "rx",
50 : "ry",
51 : "rotate",
52 : "dx",
53 : "dy",
54 : "fill",
55 : "fill-opacity",
56 : "stroke",
57 : "stroke-width",
58 : "scale",
59 : "x",
60 : "y",
61 : "z"
62 : ]
63 : },
64 : { name: "attribute-type", values: [ "auto", "CSS", "XML" ] },
65 : { name: "from" },
66 : { name: "to" },
67 : { name: "values" },
68 : { name: "key-times" },
69 : { name: "calc-mode", values: [ "discrete", "linear" ] },
70 : { name: "onupdate" }
71 : ]
72 : },
73 : transform: {
74 : name: "transform",
75 : children: [ "transition" ],
76 : attributes: [
77 : { name: "query-selector" },
78 : { name: "attribute-name", values: [ "transform" ] },
79 : { name: "attribute-type", values: [ "auto", "CSS", "XML" ] },
80 : { name: "from" },
81 : { name: "to" },
82 : { name: "values" },
83 : { name: "key-times" },
84 : { name: "calc-mode", values: [ "discrete", "linear" ] },
85 : { name: "onupdate" }
86 : ]
87 : },
88 : direct: {
89 : name: "direct",
90 : children: [ "transition" ],
91 : attributes: [
92 : { name: "query-selector" },
93 : { name: "attribute-name" },
94 : { name: "attribute-type", values: [ "auto", "CSS", "XML" ] },
95 : { name: "onupdate" }
96 : ]
97 : },
98 : property: {
99 : name: "property",
100 : children: [ "relation", "transform", "direct" ],
101 : attributes: [
102 : { name: "name" },
103 : { name: "type", values: [ "boolean", "number", "enum", "auto" ] },
104 : { name: "min" }, { name: "max" },
105 : { name: "values" },
106 : { name: "initial" }
107 : ]
108 : },
109 : computed: {
110 : name: "computed",
111 : children: [ "relation", "transform", "direct" ],
112 : attributes: [
113 : { name: "name" },
114 : { name: "type", values: [ "boolean", "number", "enum", "auto" ] },
115 : { name: "min" }, { name: "max" },
116 : { name: "values" },
117 : { name: "initial" },
118 : { name: "onupdate" }
119 : ]
120 : },
121 : transition: {
122 : name: "transition",
123 : attributes: [
124 : { name: "duration" },
125 : {
126 : name: "timing-function",
127 : values: [ "linear", "ease", "ease-in", "ease-out", "ease-in-out", "cubic-bezier" ]
128 : },
129 : { name: "delay" },
130 : { name: "type", values: [ "direct", "strict" ] }
131 : ]
132 : }
133 : };
134 :
135 1 : const relativeUnits = new Set([
136 : "em", "ex", "cap", "ch", "ic", "rem", "lh", "rlh", "vw", "vh", "vi", "vb", "vmin", "vmax"
137 : ]);
138 1 : const absoluteUnits = new Set([
139 : "cm", "mm", "Q", "in", "pc", "pt", "px"
140 : ]);
141 1 : const angleUnits = new Set([ "deg", "grad", "rad", "turn" ]);
142 1 : const lengthUnits = new Set([ ...relativeUnits, ...absoluteUnits ]);
143 1 : const lengthPercentUnits = new Set([ ...lengthUnits, "%" ]);
144 1 : const numPercentUnits = new Set([ null, "%" ]);
145 :
146 : /**
147 : *
148 : * @param {ssvg.$TransformRange} transformRange
149 : * @param {boolean} isCSS true if transform is a CSS transform (SVG transform on the contrary)
150 : */
151 : export function checkTransformRange(transformRange, isCSS = true) {
152 0 : const spec = get(transformInfo, [ transformRange?.transform ]);
153 0 : if (!spec) { throw new Error("unknown transform type: " + transformRange?.transform); }
154 :
155 0 : if (spec.minArgs === spec.maxArgs) {
156 0 : if (transformRange.units.length !== spec.minArgs) {
157 0 : throw new Error(`transform must have exactly ${spec.minArgs} arguments`);
158 : }
159 : }
160 0 : else if (transformRange.units.length < spec.minArgs) {
161 0 : throw new Error(`transform must have at least ${spec.minArgs} arguments`);
162 : }
163 0 : else if (transformRange.units.length > spec.maxArgs) {
164 0 : throw new Error(`transform must have less than ${spec.maxArgs} arguments`);
165 : }
166 :
167 : /**
168 : *
169 : * @param {null | Set<string>} spec
170 : * @param {string | null} unit
171 : * @param {number} index
172 : */
173 : function checkUnit(spec, unit, index) {
174 0 : if (unit) {
175 0 : if (spec === null) {
176 0 : throw new Error(`transform argument ${index + 1} should have no unit`);
177 : }
178 0 : else if (!spec.has(unit)) {
179 0 : throw new Error(`transform argument ${index + 1} has invalid unit "${unit}"`);
180 : }
181 : }
182 0 : else if (spec && isCSS) {
183 0 : throw new Error(`transform argument ${index + 1} should have a valid unit`);
184 : }
185 : }
186 :
187 0 : transformRange.units?.forEach((unit, index) => checkUnit(
188 : get(spec.units, index, spec.units), unit, index));
189 : }
190 :
191 1 : const transformInfo = {
192 : // https://www.w3.org/TR/css-transforms-1/#typedef-transform-function
193 : matrix: { minArgs: 1, maxArgs: 6, units: null },
194 : translate: { minArgs: 1, maxArgs: 2, units: lengthPercentUnits },
195 : translateX: { minArgs: 1, maxArgs: 1, units: lengthPercentUnits },
196 : translateY: { minArgs: 1, maxArgs: 1, units: lengthPercentUnits },
197 : scale: { minArgs: 1, maxArgs: 2, units: null },
198 : scaleX: { minArgs: 1, maxArgs: 1, units: null },
199 : scaleY: { minArgs: 1, maxArgs: 1, units: null },
200 : rotate: { minArgs: 1, maxArgs: 1, units: angleUnits },
201 : skew: { minArgs: 1, maxArgs: 2, units: angleUnits },
202 : skewX: { minArgs: 1, maxArgs: 1, units: angleUnits },
203 : skewY: { minArgs: 1, maxArgs: 1, units: angleUnits },
204 : // https://drafts.csswg.org/css-transforms-2/#three-d-transform-functions
205 : matrix3d: { minArgs: 1, maxArgs: 6, units: null },
206 : translate3d: { minArgs: 3, maxArgs: 3, units: [ lengthPercentUnits, lengthPercentUnits, lengthUnits ] },
207 : translateZ: { minArgs: 1, maxArgs: 1, units: lengthUnits },
208 : scale3d: { minArgs: 3, maxArgs: 3, units: numPercentUnits },
209 : scaleZ: { minArgs: 1, maxArgs: 1, units: numPercentUnits },
210 : rotate3d: { minArgs: 3, maxArgs: 4, units: [ null, null, null, angleUnits ] },
211 : rotateX: { minArgs: 1, maxArgs: 1, units: angleUnits },
212 : rotateY: { minArgs: 1, maxArgs: 1, units: angleUnits },
213 : rotateZ: { minArgs: 1, maxArgs: 1, units: angleUnits }
214 : // "perspective": "perspective(0); perspective(100px)",
215 : };
216 :
217 1 : export const transformList = keys(transformInfo);
218 :
219 : export class SSVGLinter {
220 : /**
221 : * @param {string} data
222 : */
223 : constructor(data) {
224 17 : this.data = data;
225 : /** @type {Diagnostic[]} */
226 17 : this.diagnostics = [];
227 : }
228 :
229 : /**
230 : * @returns {Diagnostic[]}
231 : */
232 : lint() {
233 17 : try {
234 17 : const doc = new xmldoc.XmlDocument(this.data);
235 16 : if (includes([ "transform", "relation", "direct" ], doc.name)) {
236 16 : this.checkAttrRequired(doc, "query-selector");
237 16 : this.checkAttrValues(doc, "attribute-type", [ "auto", "CSS", "XML" ], false);
238 16 : if (doc.name !== "direct") {
239 16 : this.checkAttrValues(doc, "calc-mode", [ "discrete", "linear" ], false);
240 16 : if (doc.name === "relation") {
241 13 : this.checkTValues(doc);
242 : }
243 3 : else if (doc.name === "transform") {
244 3 : this.checkTTransform(doc);
245 : }
246 : }
247 : }
248 : }
249 : catch (/** @type {any} */ e) {
250 1 : if (isNil(e?.from)) {
251 0 : return [ { from: 0, to: 0, severity: "error", message: toString(e) } ];
252 : }
253 1 : return [ e ];
254 : }
255 16 : return this.diagnostics;
256 : }
257 :
258 : /**
259 : * @param {xmldoc.XmlElement} node
260 : * @param {string} attr
261 : */
262 : attrPosition(node, attr) {
263 7 : const idx = this.data.indexOf(`${attr}=`, node.startTagPosition);
264 7 : return (idx >= 0) ? idx : node.startTagPosition;
265 : }
266 :
267 : /**
268 : * @param {xmldoc.XmlElement} node
269 : * @param {string} name
270 : */
271 : checkAttrRequired(node, name, allowEmpty = false) {
272 16 : if ((allowEmpty) ? isNil(node.attr[name]) : !node.attr[name]) {
273 1 : this.diagnostics.push({
274 : from: node.startTagPosition, to: node.startTagPosition,
275 : severity: "error",
276 : message: `"${name}" attribute missing`
277 : });
278 1 : return false;
279 : }
280 15 : return true;
281 : }
282 :
283 : /**
284 : * @param {xmldoc.XmlElement} node
285 : * @param {string[]} values
286 : * @param {string} name
287 : */
288 : checkAttrValues(node, name, values, required = true) {
289 32 : if (required && !this.checkAttrRequired(node, name, false)) { return false; }
290 :
291 32 : const value = node.attr[name];
292 32 : if (!isNil(value) && !includes(values, value)) {
293 1 : const pos = this.attrPosition(node, name);
294 1 : this.diagnostics.push({
295 : from: pos, to: pos,
296 : severity: "error",
297 : message: `invalid "${name}", allowed-values: [ "${values.join('", "')}" ]`
298 : });
299 1 : return false;
300 : }
301 31 : return true;
302 : }
303 :
304 : /**
305 : * @param {xmldoc.XmlElement} node
306 : */
307 : checkTValues(node) {
308 13 : if (node.attr["values"]) {
309 7 : try {
310 7 : parser.parseTRange(node.attr["values"]);
311 : }
312 : catch (e) {
313 4 : const pos = this.attrPosition(node, "values");
314 4 : this.diagnostics.push({
315 : from: pos, to: pos, severity: "error",
316 : message: "failed to parse \"values\": " + toString(e)
317 : });
318 4 : return false;
319 : }
320 : }
321 6 : else if (node.attr["to"]) {
322 4 : try {
323 4 : if (node.attr["from"]) {
324 1 : parser.makeRange(parser.parseTValue(node.attr["from"]),
325 : parser.parseTValue(node.attr["to"]));
326 : }
327 : else {
328 3 : parser.parseTValue(node.attr["to"]);
329 : }
330 : }
331 : catch (e) {
332 2 : const pos = this.attrPosition(node, "to");
333 2 : this.diagnostics.push({
334 : from: pos, to: pos, severity: "error",
335 : message: "failed to parse \"from/to\": " + toString(e)
336 : });
337 2 : return false;
338 : }
339 : }
340 2 : else if (node.attr["by"]) {
341 0 : try {
342 0 : parser.parseTValue(node.attr["by"]);
343 : }
344 : catch (e) {
345 0 : const pos = this.attrPosition(node, "by");
346 0 : this.diagnostics.push({
347 : from: pos, to: pos, severity: "error",
348 : message: "failed to parse \"by\": " + toString(e)
349 : });
350 0 : return false;
351 : }
352 : }
353 : else {
354 2 : this.diagnostics.push({
355 : from: node.startTagPosition, to: node.startTagPosition,
356 : severity: "error",
357 : message: '"to"/"from", "by" or "values" attribute missing'
358 : });
359 2 : return false;
360 : }
361 5 : return true;
362 : }
363 :
364 :
365 : /**
366 : * @param {xmldoc.XmlElement} node
367 : */ /* eslint-disable-next-line complexity */
368 : checkTTransform(node) {
369 : /** @type {ssvg.$Transform[][]} */
370 3 : let transforms = [];
371 3 : if (node.attr["values"]) {
372 2 : try {
373 2 : transforms = parser.parseTTransformRange(node.attr["values"]) ?? [];
374 : }
375 : catch (e) {
376 0 : const pos = this.attrPosition(node, "values");
377 0 : this.diagnostics.push({
378 : from: pos, to: pos, severity: "error",
379 : message: "failed to parse \"values\": " + toString(e)
380 : });
381 0 : return false;
382 : }
383 : }
384 1 : else if (node.attr["to"]) {
385 0 : try {
386 0 : if (node.attr["from"]) {
387 0 : transforms.push(parser.parseTTransform(node.attr["from"]) ?? []);
388 : }
389 0 : transforms.push(parser.parseTTransform(node.attr["to"]) ?? []);
390 : }
391 : catch (e) {
392 0 : const pos = this.attrPosition(node, "to");
393 0 : this.diagnostics.push({
394 : from: pos, to: pos, severity: "error",
395 : message: "failed to parse \"from/to\": " + toString(e)
396 : });
397 0 : return false;
398 : }
399 : }
400 1 : else if (node.attr["by"]) {
401 0 : try {
402 0 : transforms.push(parser.parseTTransform(node.attr["by"]) ?? []);
403 : }
404 : catch (e) {
405 0 : const pos = this.attrPosition(node, "by");
406 0 : this.diagnostics.push({
407 : from: pos, to: pos, severity: "error",
408 : message: "failed to parse \"by\": " + toString(e)
409 : });
410 0 : return false;
411 : }
412 : }
413 : else {
414 1 : this.diagnostics.push({
415 : from: node.startTagPosition, to: node.startTagPosition,
416 : severity: "error",
417 : message: '"to"/"from", "by" or "values" attribute missing'
418 : });
419 1 : return false;
420 : }
421 2 : try {
422 2 : const trange = parser.normalizeTransform(transforms);
423 0 : try {
424 :
425 : /* auto mode depends on attribute-name */
426 0 : const isCSS = (node.attr["attribute-type"] === "CSS") ||
427 : ((get(node.attr, "attribute-type", "auto") === "auto") && !!node.attr["attribute-name"]);
428 0 : trange?.forEach((trange) => checkTransformRange(trange, isCSS));
429 : }
430 : catch (e) {
431 0 : this.diagnostics.push({
432 : from: node.startTagPosition, to: node.startTagPosition,
433 : severity: "warning",
434 : message: toString(e)
435 : });
436 : }
437 : }
438 : catch (e) {
439 2 : this.diagnostics.push({
440 : from: node.startTagPosition, to: node.startTagPosition,
441 : severity: "error",
442 : message: "failed to normalize transform: " + toString(e)
443 : });
444 2 : return false;
445 : }
446 0 : return true;
447 : }
448 : }
449 :
450 : /**
451 : *
452 : * @param {string} data
453 : * @return {Diagnostic[]}
454 : */
455 : export function ssvgLint(data) {
456 0 : const linter = new SSVGLinter(data);
457 0 : return linter.lint();
458 : }
459 :
460 1 : const strRe = /\s*([^\s]*)[=]"([^"]*)\s*"/g;
461 : /**
462 : * @param {string} text
463 : * @return {string}
464 : */
465 : export function ssvgPrettyPrint(text) {
466 4 : return text
467 : .replace(strRe, (m, key, value) => {
468 10 : if (value.length > 20 && includes([ "key-times", "values" ], key)) {
469 3 : const valueIndent = " ".repeat(key.length + 4);
470 12 : const values = map(value.split(";"), (v) => trim(v));
471 3 : return `\n ${key}="${values.join(";\n" + valueIndent)}"`;
472 : }
473 : else {
474 7 : return `\n ${key}="${value}"`;
475 : }
476 : });
477 : }
|