Modify the data of SVG path element's d-attribute to make the resulting path flipped

157 Views Asked by At

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

There are 2 best solutions below

0
chrwahl On BEST ANSWER

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])([\s\d\-\.]*)/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])([\s\d\-\.]*)/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>

0
herrstrietzel On

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(/[\n\r\t|,]/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(/[\n\r\t|,]/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.