LCOV - code coverage report
Current view: top level - src/utils - SSVGLint.js (source / functions) Hit Total Coverage
Test: lcov.info Lines: 78 124 62.9 %
Date: 2025-06-29 02:18:36 Functions: 11 16 68.8 %

          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             : }

Generated by: LCOV version 1.16