skip to Main Content

I have a SVG that contains a single <path> element that draws a certain shape. The coordinates of this path are contained within the path’s 'd' attribute’s value. I need this shape flipped horizontally. When I try to accomplish this in Adobe Illustrator, using Reflect tool for example, I get double the size of data in the 'd' attribute value and therefore double the size of SVG file and that is just too painful to do. I could use transform and scale functions to flip the shape without changing the coordinates in 'd' but then I would increase the rendering time and CPU usage since I added extra work for browser or whichever software renders the SVG.
The logical thing to do is just change the coordinates themselves within the 'd' to their ‘opposites’ to achieve the flipping of the shape.

I could write a script that does this but alas I do not know the format of how these coordinates are stored and what they actually represent. There are both letters and numbers used.

So, my question is, how would one change the coordinates of a <path> element’s 'd' in order to achieve a horizontal flip of the entire shape?

Here is my example SVG for illustration:

<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px">

  <path id="example" class="st0" d="M492 534h-96q-37 0 -53.5 -12.5t-30.5 -50.5q-20 -54 -44 -165q-17 -79 -17 -105q0 -49 38 -58q17 -3 51 -3h67q41 0 56 7.5t23 32.5h27l-24 -106q-10 -42 -27 -58q-18 -15 -50 -19t-139 -4q-89 0 -128 5.5t-63 21.5q-54 35 -54 122q0 53 25 177q31 146 62 218t76 101 t124 29h258l-18 -80q-7 -34 -19 -43.5t-44 -9.5z"/>

</svg>

2

Answers


  1. Here you have an example of how the path can be flipped. I do it in two different functions because moveElm() could be useful in other situations. So, first flipping the path over the y-axes and then moving the path back over the y-axes.

    For both functions the mathematics is not that hard, it is basically just a matter of understanding what the different commands in the d attribute does. The implementation is not complete, but it works for the example given in the question.

    The question did not say anything about what programming language you would like to use. The only language specific I used in the example is getBBox(). In Python you can find something similar in the package svgpathtools where you can find the method path.bbox() (see, this answer: https://stackoverflow.com/a/76076555/322084). Finding the position and size of the path is necessary for moving (and scaling) the path.

    let path = document.getElementById('example');
    let path_bbox = path.getBBox();
    let x = Math.round(path_bbox.width + 2 * path_bbox.x);
    
    flipElm(path);
    moveElm(path, x, 0);
    
    function moveElm(path_elm, x, y){
      let d = path_elm.getAttribute('d');
      let regexp = /([a-zA-Z])([sd-.]*)/g;
      let new_d = [...d.matchAll(regexp)].map(command => {
        let arr = command[2].trim().split(/s/).map(val => parseFloat(val));
        let return_arr = arr;
        switch(command[1]){
          case 'M':
          case 'L':
          case 'H':
          case 'V':
          case 'Q':
          case 'T':
            return_arr = [arr[0] + x, arr[1] + y];
            break;
          case 'z':
          case 'Z':
            return_arr = [];
            break;
        }
        return `${command[1]}${return_arr.join(' ')}`;
      }).join(' ');
      path_elm.setAttribute('d', new_d);
    }
    
    function flipElm(path_elm) {
      let d = path_elm.getAttribute('d');
      let regexp = /([a-zA-Z])([sd-.]*)/g;
      let new_d = [...d.matchAll(regexp)].map(command => {
        let arr = command[2].trim().split(/s/).map(val => parseFloat(val));
        let return_arr = [];
        switch (command[1]) {
          case 'A':
          case 'a':
            return_arr = arr.map((num, i) => "not implemented");
            break;
          case 'z':
          case 'Z':
            return_arr = [];
            break;
          default:
            return_arr = arr.map((num, i) => (i % 2) ? num : num * -1);
            break;
        }
        return `${command[1]}${return_arr.join(' ')}`;
      }).join(' ');
      path_elm.setAttribute('d', new_d);
    }
    svg {
      border: solid thin black;
    }
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -10 600 680" height="200">
      <path class="st0" d="M492 534h-96q-37 0 -53.5 -12.5t-30.5 -50.5q-20 -54 -44 -165q-17 -79 -17 -105q0 -49 38 -58q17 -3 51 -3h67q41 0 56 7.5t23 32.5h27l-24 -106q-10 -42 -27 -58q-18 -15 -50 -19t-139 -4q-89 0 -128 5.5t-63 21.5q-54 35 -54 122q0 53 25 177q31 146 62 218t76 101 t124 29h258l-18 -80q-7 -34 -19 -43.5t-44 -9.5z"/>
    </svg>
    
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -10 600 680" height="200">
      <path id="example" class="st0" d="M492 534h-96q-37 0 -53.5 -12.5t-30.5 -50.5q-20 -54 -44 -165q-17 -79 -17 -105q0 -49 38 -58q17 -3 51 -3h67q41 0 56 7.5t23 32.5h27l-24 -106q-10 -42 -27 -58q-18 -15 -50 -19t-139 -4q-89 0 -128 5.5t-63 21.5q-54 35 -54 122q0 53 25 177q31 146 62 218t76 101 t124 29h258l-18 -80q-7 -34 -19 -43.5t-44 -9.5z"/>
    </svg>
    Login or Signup to reply.
  2. Adobe applications prefer cubic béziers

    Adobe Illustrator won’t necessarily add additional commands but it converts quadratic béziers (q or t commands) to cubic (c or s).
    This also applies to quite a few graphic applications/vector editors.

    This will result in additional coordinate/point data.
    Your example graphic probably originates from a font-to-svg conversion based on a truetype font (which natively use quadratic béziers).

    Besides, your example seems to be quite optimized – for instance you see "shorthand" t commands (reflecting the previous quadratic bézier control point) – a lot of applications or libraries tend to convert them to "longhand" (so t to q – adding an explicit control point) equivalents when editing.

    As suggested by Yuri Khristich: Inkscape can retain these commands better as it is using svg as the object model. However, most editing operations will also convert your quadratic commands to cubics.

    Custom JavaScript converters

    As illustrated by chrwahl leveraging natively supported JS methods can be incredibly helpful if you need to retain the original data without adding unnecessary overhead – unfortunately most vector editor will add some data (e.g genererator meta data, non optimized pathdata with unnecessarily high floating point accuracy).

    Example: flip/mirror graphic

    // parse path data
    let d = example.getAttribute('d')
    let pathData = parsePathData(d);
    
    //floating point accuracy
    let decimals = 1;
    
    // get x offset to adjust x-axis flip
    let {
      x,
      y,
      width,
      height
    } = example.getBBox();
    let flipX = true;
    let flipY = false;
    let offsetX = flipX ? x + width : 0;
    let offsetY = flipY ? y + height : 0;
    
    
    let pathDataFlipped = flipPathData(pathData, flipX, flipY);
    
    // convert to relative for easy offset shifting
    pathDataFlipped = pathDataToRelative(pathDataFlipped, decimals)
    
    // shift pathdata
    pathDataFlipped = shiftRelativePathData(pathDataFlipped, offsetX, offsetY);
    
    //apply new pathData
    let dNew = pathDataToD(pathDataFlipped, decimals);
    example.setAttribute('d', dNew);
    
    
    /**
     * provided we have all relative commands:
     * we only need to shift the `M` starting point command
     */
    function shiftRelativePathData(pathData, offsetX = 0, offsetY = 0) {
      pathData[0].values[0] += offsetX;
      pathData[0].values[1] += offsetY;
      return pathData;
    }
    
    function flipPathData(pathData, flipX = false, flipY = false) {
      let pathDataScaled = [];
      pathData.forEach((com, i) => {
        let {
          type,
          values
        } = com;
        let comT = {
          type: type,
          values: []
        }
        let scaleX = flipX ? -1 : 1;
        let scaleY = flipY ? -1 : 1;
    
        switch (type.toLowerCase()) {
          // lineto shorthands
          case 'h':
            comT.values = [values[0] * scaleX]; // horizontal - x-only
            break;
          case 'v':
            comT.values = [values[0] * scaleY]; // vertical - x-only
            break;
    
            // arcto 
          case 'a':
            // adjust angle and sweep if flipped
            let angle = values[2] * scaleX * scaleY;
            let sweep = values[4]
            if (flipX != flipY) {
              sweep = sweep === 0 ? 1 : 0;
            }
    
            comT.values = [
              values[0], // rx
              values[1], // ry
              angle, // x-rotation
              values[3], // largeArc - boolean
              sweep, // sweep - boolean
              values[5] * scaleX, // final onpath x
              values[6] * scaleY // final onpath y
            ];
            break;
    
            // L, C, S, Q, T
          default:
            if (values.length) {
              comT.values = values.map((val, i) => {
                return i % 2 === 0 ? val * scaleX : val * scaleY
              })
            }
        }
        pathDataScaled.push(comT)
      })
      return pathDataScaled;
    }
    
    function parsePathData(d) {
      d = d
        // remove new lines, tabs an comma with whitespace
        .replace(/[nrt|,]/g, " ")
        // pre trim left and right whitespace
        .trim()
        // add space before minus sign
        .replace(/(d)-/g, '$1 -')
        // decompose multiple adjacent decimal delimiters like 0.5.5.5 => 0.5 0.5 0.5
        .replace(/(.)(?=(d+.d+)+)(d+)/g, "$1$3 ")
    
      let pathData = [];
      let cmdRegEx = /([mlcqazvhst])([^mlcqazvhst]*)/gi;
      let commands = d.match(cmdRegEx);
    
      // valid command value lengths
      let comLengths = {
        m: 2,
        a: 7,
        c: 6,
        h: 1,
        l: 2,
        q: 4,
        s: 4,
        t: 2,
        v: 1,
        z: 0
      };
      commands.forEach(com => {
    
        let type = com.substring(0, 1);
        let typeRel = type.toLowerCase();
        let chunkSize = comLengths[typeRel];
    
        // split values to array
        let values = com.substring(1, com.length)
          .trim()
          .split(" ").filter(Boolean);
    
        /**
         * A - Arc commands
         * large arc and sweep flags
         * are boolean and can be concatenated like
         * 11 or 01
         * or be concatenated with the final on path points like
         * 1110 10 => 1 1 10 10
         */
        if (typeRel === "a" && values.length != comLengths.a) {
    
          let n = 0,
            arcValues = [];
          for (let i = 0; i < values.length; i++) {
            let value = values[i];
    
            // reset counter
            if (n >= chunkSize) {
              n = 0;
            }
            // if 3. or 4. parameter longer than 1
            if ((n === 3 || n === 4) && value.length > 1) {
              let largeArc = n === 3 ? value.substring(0, 1) : "";
              let sweep = n === 3 ? value.substring(1, 2) : value.substring(0, 1);
              let finalX = n === 3 ? value.substring(2) : value.substring(1);
              let comN = [largeArc, sweep, finalX].filter(Boolean);
              arcValues.push(comN);
              n += comN.length;
    
            } else {
              // regular
              arcValues.push(value);
              n++;
            }
          }
          values = arcValues.flat().filter(Boolean);
        }
    
        // string  to number
        values = values.map(Number)
    
        // if string contains repeated shorthand commands - split them
        let hasMultiple = values.length > chunkSize;
        let chunk = hasMultiple ? values.slice(0, chunkSize) : values;
        let comChunks = [{
          type: type,
          values: chunk
        }];
    
        // has implicit or repeated commands – split into chunks
        if (hasMultiple) {
          let typeImplicit = typeRel === "m" ? (isRel ? "l" : "L") : type;
          for (let i = chunkSize; i < values.length; i += chunkSize) {
            let chunk = values.slice(i, i + chunkSize);
            comChunks.push({
              type: typeImplicit,
              values: chunk
            });
          }
        }
        comChunks.forEach((com) => {
          pathData.push(com);
        });
      });
    
      /**
       * first M is always absolute/uppercase -
       * unless it adds relative linetos
       * (facilitates d concatenating)
       */
      pathData[0].type = "M";
      return pathData;
    }
    
    
    /**
     * A port of Dmitry Baranovskiy's 
     * pathToRelative method used in snap.svg
     * https://github.com/adobe-webplatform/Snap.svg/
     */
    
    // convert to relative commands
    function pathDataToRelative(pathData, decimals = 1) {
    
      // round coordinates to prevent distortions for lower floating point accuracy
      if (decimals > -1 && decimals < 2) {
        pathData.forEach(com => {
          //com.values = com.values.map(val => { return +val.toFixed(decimals) })
        })
      }
    
      let M = pathData[0].values;
      let x = M[0],
        y = M[1],
        mx = x,
        my = y;
    
    
      // loop through commands
      pathData.forEach((com, i) => {
    
        let {
          type,
          values
        } = com;
        let typeRel = type.toLowerCase();
    
        // is absolute
        if (type != typeRel && i > 0) {
          type = typeRel;
          com.type = type;
          console.log(com);
          // check current command types
          switch (typeRel) {
            case "a":
              values[5] = +(values[5] - x);
              values[6] = +(values[6] - y);
              break;
            case "v":
              values[0] = +(values[0] - y);
              break;
            case "m":
              mx = values[0];
              my = values[1];
            default:
              // other commands
              if (values.length) {
                for (let v = 0; v < values.length; v++) {
                  // even value indices are y coordinates
                  values[v] = values[v] - (v % 2 ? y : x);
                }
              }
          }
        }
        // is already relative
        else if (type == "m" && i > 0) {
          mx = values[0] + x;
          my = values[1] + y;
        }
    
        let vLen = values.length;
        switch (type) {
          case "z":
            x = mx;
            y = my;
            break;
          case "h":
            x += values[vLen - 1];
            break;
          case "v":
            y += values[vLen - 1];
            break;
          default:
            x += values[vLen - 2];
            y += values[vLen - 1];
        }
        // round final relative values
        if (decimals > -1) {
          com.values = com.values.map(val => {
            return +val.toFixed(decimals)
          })
        }
      })
    
      return pathData;
    }
    
    
    /**
     * serialize pathData array to
     * d attribute string
     */
    function pathDataToD(pathData, decimals = 3) {
      let d = ``;
      pathData.forEach(com => {
        d += `${com.type}${com.values.map((val) => {
                    return +val.toFixed(decimals);
                }).join(" ")}`;
      })
      return d;
    }
    svg {
      width: 50vw;
      border: 1px solid #ccc;
      overflow: visible;
    }
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 700">
      <path id="example" class="st0" d="M492, 534h-96q-37 0 -53.5 -12.5t-30.5 -50.5q-20 -54 -44 -165q-17 -79 -17 -105q0 -49 38 -58q17 -3 51 -3h67q41 0 56 7.5t23 32.5h27l-24 -106q-10 -42 -27 -58
                                        q-18 -15 -50 -19t-139-4q-89 0 -128 5.5t-63 21.5q-54 35 -54 122q0 53 25 177q31 146 62 218t76 101 t124 29h258l-18 -80q-7 -34 -19 -43.5t-44 -9.5z 
    m 0 -200
    a 1 1 0 10 0 100
    1 1 0 100-100
    "
    />
    </svg>

    How it works

    Its basically quite similar to chrwahl’s script.

    1. parse d attribute to a computable array of path data items
    2. scale command coordinates according to flip-direction
    3. shift path data according to element boundaries
    4. stringify data back to d attribute string

    The parsing and scaling helpers also cover a arcto commands: these commands are an exception as their command values contain a mix of parameters and point coordinates. So we can’t apply the simple x/y scaling we can apply to other commands. Besides, they allow shorthand notations that are rather tedious to parse with too simplistic regex.

    The parsed path data format/structure follows the W3C draft for the SVGPathData Interface.

    To adjust the offset introduced by the flip-transformation we take advantage of relative coordinates. If your path contains only relative commands you can shift the entire path by just changing the first M Moveto command coordinates. This concept was also explained by Lea Verou. The included pathDataToRelative() does the conversion.

    In fact the custom flipPathData() function can easily be modified to work as a scaling helper – unless you’re working with the aforementioned A arcto commands. Worth noting you don’t even need any kind of normalization – like converting to all absolute commands or converting shorthand commands (which is required for quite a few other path data manipulations).

    Example 2: Scale pathdata

    // parse path data
    let d = example.getAttribute('d')
    let pathData = parsePathData(d);
    
    let scaleX = 0.75,
      scaleY = 0.3;
    let pathDataScaled = scalePathData(pathData, scaleX, scaleY);
    
    //apply new pathData
    let dNew = pathDataToD(pathDataScaled, 3);
    example.setAttribute('d', dNew);
    
    
    function scalePathData(pathData, scaleX = 1, scaleY = 1) {
      let pathDataScaled = [];
      pathData.forEach((com, i) => {
        let {
          type,
          values
        } = com;
        let comT = {
          type: type,
          values: []
        }
    
        switch (type.toLowerCase()) {
          // lineto shorthands
          case 'h':
            comT.values = [values[0] * scaleX]; // horizontal - x-only
            break;
          case 'v':
            comT.values = [values[0] * scaleY]; // vertical - x-only
            break;
    
            // arcto - won't work
    
            // L, C, S, Q, T
          default:
            if (values.length) {
              comT.values = values.map((val, i) => {
                return i % 2 === 0 ? val * scaleX : val * scaleY
              })
            }
        }
        pathDataScaled.push(comT)
      })
      return pathDataScaled;
    }
    
    function parsePathData(d) {
      d = d
        // remove new lines, tabs an comma with whitespace
        .replace(/[nrt|,]/g, " ")
        // pre trim left and right whitespace
        .trim()
        // add space before minus sign
        .replace(/(d)-/g, '$1 -')
        // decompose multiple adjacent decimal delimiters like 0.5.5.5 => 0.5 0.5 0.5
        .replace(/(.)(?=(d+.d+)+)(d+)/g, "$1$3 ")
    
      let pathData = [];
      let cmdRegEx = /([mlcqazvhst])([^mlcqazvhst]*)/gi;
      let commands = d.match(cmdRegEx);
    
      // valid command value lengths
      let comLengths = {
        m: 2,
        a: 7,
        c: 6,
        h: 1,
        l: 2,
        q: 4,
        s: 4,
        t: 2,
        v: 1,
        z: 0
      };
      commands.forEach(com => {
    
        let type = com.substring(0, 1);
        let typeRel = type.toLowerCase();
        let chunkSize = comLengths[typeRel];
    
        // split values to array
        let values = com.substring(1, com.length)
          .trim()
          .split(" ").filter(Boolean);
    
        /**
         * A - Arc commands
         * large arc and sweep flags
         * are boolean and can be concatenated like
         * 11 or 01
         * or be concatenated with the final on path points like
         * 1110 10 => 1 1 10 10
         */
        if (typeRel === "a" && values.length != comLengths.a) {
    
          let n = 0,
            arcValues = [];
          for (let i = 0; i < values.length; i++) {
            let value = values[i];
    
            // reset counter
            if (n >= chunkSize) {
              n = 0;
            }
            // if 3. or 4. parameter longer than 1
            if ((n === 3 || n === 4) && value.length > 1) {
              let largeArc = n === 3 ? value.substring(0, 1) : "";
              let sweep = n === 3 ? value.substring(1, 2) : value.substring(0, 1);
              let finalX = n === 3 ? value.substring(2) : value.substring(1);
              let comN = [largeArc, sweep, finalX].filter(Boolean);
              arcValues.push(comN);
              n += comN.length;
    
            } else {
              // regular
              arcValues.push(value);
              n++;
            }
          }
          values = arcValues.flat().filter(Boolean);
        }
    
        // string  to number
        values = values.map(Number)
    
        // if string contains repeated shorthand commands - split them
        let hasMultiple = values.length > chunkSize;
        let chunk = hasMultiple ? values.slice(0, chunkSize) : values;
        let comChunks = [{
          type: type,
          values: chunk
        }];
    
        // has implicit or repeated commands – split into chunks
        if (hasMultiple) {
          let typeImplicit = typeRel === "m" ? (isRel ? "l" : "L") : type;
          for (let i = chunkSize; i < values.length; i += chunkSize) {
            let chunk = values.slice(i, i + chunkSize);
            comChunks.push({
              type: typeImplicit,
              values: chunk
            });
          }
        }
        comChunks.forEach((com) => {
          pathData.push(com);
        });
      });
    
      /**
       * first M is always absolute/uppercase -
       * unless it adds relative linetos
       * (facilitates d concatenating)
       */
      pathData[0].type = "M";
      return pathData;
    }
    
    /**
     * serialize pathData array to
     * d attribute string
     */
    function pathDataToD(pathData, decimals = 3) {
      let d = ``;
      pathData.forEach(com => {
        d += `${com.type}${com.values.map((val) => {
                    return +val.toFixed(decimals);
                }).join(" ")}`;
      })
      return d;
    }
    svg {
      width: 50vw;
      border: 1px solid #ccc;
      overflow: visible;
    }
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 700">
            <path id="example" class="st0" d="M492, 534h-96q-37 0 -53.5 -12.5t-30.5 -50.5q-20 -54 -44 -165q-17 -79 -17 -105q0 -49 38 -58q17 -3 51 -3h67q41 0 56 7.5t23 32.5h27l-24 -106q-10 -42 -27 -58
                                              q-18 -15 -50 -19t-139-4q-89 0 -128 5.5t-63 21.5q-54 35 -54 122q0 53 25 177q31 146 62 218t76 101 t124 29h258l-18 -80q-7 -34 -19 -43.5t-44 -9.5z 
          " />
        </svg>

    Web based optimisation

    Single path transformations

    Single paths can easily be flipped/transformed using these open source web-apps

    I’ve been playing around with custom JS converter helpers for similar reasons (AI was constantly bloating my optimized svg code after editing). You may take inspiration from this codepen example. deploying more advanced versions of the above helpers.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search