79081429

Date: 2024-10-12 16:36:28
Score: 2
Natty:
Report link

I DID IT!

So, the working code is below. This answer was made possible by the source code of the NetSurf Browser SVG renderer.

Also, herrstrietzel helped me a lot. The tip to wrap <use> inside <g> was just right.

In my case, text rendering is not needed. But if someone needs to render text in <path>, then this issue is solved with the help of the opentype library. The library is not very well known, but it works magic. In order to apply text transformations, the test must be rendered in <path> before all <path> are processed.

What's next? Either the task needs to be more complicated, or I don't even know. If anyone has a more elegant solution or finds a bug in my code, let me know. You'll probably get a bounty.

    // regular expression to parse numbers
const rxNumberStr = '[+-]?(?:(?:[0-9]+\\.[0-9]+)|(?:\\.[0-9]+)|(?:[0-9]+))(?:[eE][+-]?[0-9]+)?';
// regular expression to  parse next lemma from d attribute of <path> 
const rxDLemmaStr = `[MmLlHhVvCcSsQqTtAaZz]|${rxNumberStr}`;

// D-attribute string parser
// https://www.w3.org/TR/SVG/paths.html#PathData
// https://www.w3.org/TR/SVG/paths.html#PathDataBNF
class D {
  // Result of parsing a D-attribute string
  // path: [[cmd, new D.P({x,y}), ...], ...]
  path = [];
  constructor(d) {
    let rx = new RegExp(rxDLemmaStr, 'g');
    let ps = d.match(rx).map(v => ('MmLlHhVvCcSsQqTtAaZz'.indexOf(v) > -1 ? v : parseFloat(v)));
    let cmd, move, ref = D.P.abs(0, 0);
    for (let i = 0, len = ps.length; i < len; ) {
      if (isNaN(ps[i])) cmd = ps[i++];
      this.path.push(({
        m: () => (ref = D.P.rel(ps[i++], ps[i++], ref), ps[i - 3] == cmd ? ['M', move = ref] : ['L', ref]),
        M: () => (ref = D.P.rel(ps[i++], ps[i++], ref), ps[i - 3] == cmd ? ['M', move = ref] : ['L', ref]),
        l: () => ['L', (ref = D.P.rel(ps[i++], ps[i++], ref))],
        L: () => ['L', (ref = D.P.abs(ps[i++], ps[i++], ref))],
        h: () => ['H', (ref = D.P.rel(ps[i++], 0, ref))],
        H: () => ['H', (ref = D.P.abs(ps[i++], ref.y, ref))],
        v: () => ['V', (ref = D.P.rel(0, ps[i++], ref))],
        V: () => ['V', (ref = D.P.abs(ref.x, ps[i++], ref))],
        c: () => ['C', D.P.rel(ps[i++], ps[i++], ref), D.P.rel(ps[i++], ps[i++], ref), (ref = D.P.rel(ps[i++], ps[i++], ref))],
        C: () => ['C', D.P.abs(ps[i++], ps[i++], ref), D.P.abs(ps[i++], ps[i++], ref), (ref = D.P.abs(ps[i++], ps[i++], ref))],
        s: () => ['S', D.P.rel(ps[i++], ps[i++], ref), (ref = D.P.rel(ps[i++], ps[i++], ref))],
        S: () => ['S', D.P.abs(ps[i++], ps[i++], ref), (ref = D.P.abs(ps[i++], ps[i++], ref))],
        q: () => ['Q', D.P.rel(ps[i++], ps[i++], ref), (ref = D.P.rel(ps[i++], ps[i++], ref))],
        Q: () => ['Q', D.P.abs(ps[i++], ps[i++], ref), (ref = D.P.abs(ps[i++], ps[i++], ref))],
        t: () => ['T', (ref = D.P.rel(ps[i++], ps[i++], ref))],
        T: () => ['T', (ref = D.P.abs(ps[i++], ps[i++], ref))],
        a: () => ['A', ps[i++], ps[i++], ps[i++], ps[i++], ps[i++], (ref = D.P.rel(ps[i++], ps[i++], ref))],
        A: () => ['A', ps[i++], ps[i++], ps[i++], ps[i++], ps[i++], (ref = D.P.abs(ps[i++], ps[i++], ref))],
        z: () => (ref = move, ['Z']),
        Z: () => (ref = move, ['Z']),
      })[cmd]());
    }
  }
  // Compile array with absolute coordinates for new <path d="..." />
  get abs() {
    return this.path.map(s => s.map((v, i) => (!i ? v : { H: v.x, V: v.y }[s[0]] || v.abs))).flat(3);
  }
  // Compile array with relative coordinates for new <path d="..." />
  get rel() {
    return this.path.map(s => s.map((v, i) => !i ? v.toLowerCase() : { H: v.x - v.ref.x, V: v.y - v.ref.y }[s[0]] || v.rel)).flat(3);
  }
  // Compile array with transformed absolute coordinates for new <path d="..." />
  transform(matrix) {
    return this.path.map(s => s.map((v, i) => (!i ? v.replace(/[HV]/i,'L') : v.transform(matrix)))).flat(3);
  }
  // Path Point class 
  static get P() {
    return class P {
      x;
      y;
      // Reference point for relative coordinates
      ref;
      constructor(props) {
        Object.assign(this, props);
      }
      // Absolute coordinates
      get abs() {
        return [this.x, this.y];
      }
      // Relative coordinates
      get rel() {
        return [this.x - this.ref.x, this.y - this.ref.y];
      }
      // Transform point with matrix
      transform([a, b, c, d, e, f]) {
        return [this.x * a + this.y * c + e, this.x * b + this.y * d + f];
      }
      // Create point from absolute coordinates
      static abs = (x, y, ref) => new D.P({ x, y, ref });
      // Create point from relative coordinates
      static rel = (x, y, ref) => new D.P({ x: ref.x + x, y: ref.y + y, ref });
    };
  }
}

// Matrix multiplication
// https://en.wikipedia.org/wiki/Matrix_multiplication
const multiply = ([a1, b1, c1, d1, e1, f1], [a2, b2, c2, d2, e2, f2]) => [
    a1 * a2 + c1 * b2,
    b1 * a2 + d1 * b2,
    a1 * c2 + c1 * d2,
    b1 * c2 + d1 * d2,
    a1 * e2 + c1 * f2 + e1,
    b1 * e2 + d1 * f2 + f1,
  ];

// Consolidate matrix chain
const consolidate = matrices => matrices.reduce((acc, m) => multiply(acc,m), [1, 0, 0, 1, 0, 0]);

// Read SVG element attributes
// https://developer.mozilla.org/en-US/docs/Web/API/SVGElement/attributes
// returns object with attributes
const readAttrs = (element) => [...element.attributes].reduce((acc, { name, value }) => ((acc[name] = value), acc), {});
// Write SVG element attributes
const writeAttrs = (element, attrs) => Object.entries(attrs).forEach(([attr, value]) => element.setAttribute(attr, value));

// Process SVG shape element
// Shape elements are replaced with new <path d="..." /> element
// Shape element attributes are copied to new <path d="..." /> element
// Shape element must not have child elements
// List of shape elements <circle>, <ellipse>, <line>, <path>, <polygon>, <polyline>, <rect>
function processShape(shape) {
  const shape2path = {
    circle: ({ cx, cy, r, ...attrs }) => [
      `M ${[cx - r, cy]} A ${r} ${r} 0 1 1 ${[cx + r, cy]} A ${r} ${r} 0 1 1 ${[cx - r, cy]} Z`,
      attrs,
    ],
    line: ({ x1, y1, x2, y2, ...attrs }) => [`M ${[x1, y1]} L ${[x2, y2]}`, attrs],
    rect: ({ x, y, width, height, rx = 0, ry = 0, ...attrs }) =>
      rx == 0 && ry == 0
        ? [`M ${[x, y]} h ${width} v ${height} h -${width} Z`, attrs]
        : [
            `M ${[x + rx, y]} h  ${width - 2 * rx}  a ${rx} ${ry} 0 0 1  ${rx}  ${ry}
                              v  ${height - 2 * ry} a ${rx} ${ry} 0 0 1 -${rx}  ${ry}
                              h -${width - 2 * rx}  a ${rx} ${ry} 0 0 1 -${rx} -${ry}
                              v -${height - 2 * ry} a ${rx} ${ry} 0 0 1  ${rx} -${ry} Z`,
            attrs,
          ],
    ellipse: ({ cx, cy, rx, ry, ...attrs }) => [
      `M ${[cx - rx, cy]} A ${rx} ${ry} 0 1 1 ${[cx + 2 * rx, cy]}
                          A ${rx} ${ry} 0 1 1 ${[cx + rx, cy - ry]}
                          A ${rx} ${ry} 0 1 1 ${[cx + rx, cy + ry]}
                          A ${rx} ${ry} 0 1 1 ${[cx - rx, cy]} Z`,
      attrs,
    ],
    polygon: ({ points, ...attrs }) => [`M ${points} Z`, attrs],
    polyline: ({ points, ...attrs }) => [`M ${points}`, attrs],
  };

  // return if shape inside <pattern>, <mask> or <clipPath>
  if (shape.closest('pattern') || shape.closest('mask') || shape.closest('clipPath')) {
    return;
  }

  // create d value for new <path>
  let [d, attrs] = shape2path[shape.tagName](readAttrs(shape));
  // create new <path>
  let path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
  // write attributes from object to <path>
  writeAttrs(path, attrs);
  // set attribute "d"
  path.setAttribute('d', d);
  // replace shape with <path>
  shape.replaceWith(path);
  return path;
}

// process <path /> element
function processPath(path){
  // returns all parent Elements
  const getParents = (element)=>{
    let parents = [];
    while(element !== document.documentElement){
      parents.push(element = element.parentNode);
    }
    return parents;
  }
  // parse transform matrix
  const getMatrix = (transform) => {
    let [a, b, c, d, e, f] = transform.match(/^matrix\(([^)]+)\)$/)[1].split(',').map(Number);
    return [a, b, c, d, e, f];
  }
  // parse transform origin
  const getOrigin = (transformOrigin) => {
    // TODO Parse origin coordinates with units
    // from https://source.netsurf-browser.org/libsvgtiny.git/tree/src/svgtiny.c#n1816
    let [x, y] = transformOrigin.split(' ').map(value=>value.trim().replace(/[a-z]+$/i,'')).map(Number);
    return [x, y];
  }
  // calc origed matrix from transform and origin
  const getOTM = (element) => {
    // very useful function for read computed style
    let style = getComputedStyle(element);
    // no matter how exactly the transform is specified
    // (in attributes or in styles or in individual classes),
    // the function returns transform always in a matrix form
    let transform = style.getPropertyValue('transform');
    let transformOrigin = style.getPropertyValue('transform-origin');
    // if no transform is acquired
    if(!transform || transform == 'none') return [1,0,0,1,0,0];
    // parse transform matrix
    let matrix = getMatrix(transform);
    // parse origin
    let origin = getOrigin(transformOrigin);
    // return consolidate matrix
    return consolidate([
      [1,0,0,1,...origin],            // + origin
      matrix,
      [1,0,0,1,...origin.map(v=>-v)]  // - origin
    ]);
  }
  
  // return if <path> inside <pattern>, <mask> or <clipPath>
  if (path.closest('pattern') || path.closest('mask') || path.closest('clipPath')) {
    return;
  }

  // read element transform matrix
  const elementOTM = getOTM(path);
  // get all parent elements
  const parents = getParents(path);
  // clone element transform matrix array
  let resultCTM = [1,0,0,1,0,0];
  // for each parent
  parents.reverse().forEach((parent) => {
    // read parent transform matrix
    let parentOTM = getOTM(parent);
    // calc CTM
    resultCTM = consolidate([resultCTM,parentOTM]);
  });

  consolidate([resultCTM,elementOTM])
  
  // read d attribute, parse it, transform each point and join to single string
  let d = new D(path.getAttribute('d')).transform(resultCTM).join(' ');
  // this variant returns absolute coordinates without transform
  //let d = new D(path.getAttribute('d')).abs.join(' ');
  // remove transform attributes
  path.removeAttribute('transform');
  path.removeAttribute('transform-origin');
  // force init transform matrix
  //path.style.setProperty('transform','matrix(1,0,0,1,0,0)');
  path.style.setProperty('transform-origin', '0 0');
  // set d attribute
  path.setAttribute('d', d);
  // find root element
  let svg = path.closest('svg');
  if(svg){
    // remove element from parent
    path.parentNode.removeChild(path);
    // add element to root
    svg.appendChild(path);
  }
  return path;
}

// Process <use /> element
function  processUse(element) {
  // read attributes to object
  let attrs = readAttrs(element);
  // get href attribute value
  let sel = attrs.href;
  // find referenced element by href="#id"
  let ref = document.querySelector(sel);
  // clone referenced element
  let clone = ref.cloneNode(true);
  // remove id attribute from clone
  clone.removeAttribute('id');
  // remove href from attribute object
  delete attrs.href;
  // Create new <g>
  let group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
  // write attributes from object
  writeAttrs(group, attrs);
  // add clone to <g>
  group.appendChild(clone);
  // replace <use> element to <g>
  element.replaceWith(group);
  return clone;
}


// Create matrix from transform attribute
// This fuction does not used.
// Use of getComputedStyle() completely replaces this implementation
function parseTransform (transform) {
  const rxTransformNameStr = '(?:(?:translate|scale|skew)[XY]?)|matrix|rotate';

  const rad = a => a / 57.29577951308232;

  const { sin, cos, tan } = Math;

  const matricies = {
    identity: () => [1, 0, 0, 1, 0, 0],
    translate: (tx, ty) => [1, 0, 0, 1, tx, ty],
    translateX: tx => [1, 0, 0, 1, tx, 0],
    translateY: ty => [1, 0, 0, 1, 0, ty],
    scale: (sx, sy = sx, cx = 0, cy = 0) => [sx, 0, 0, sy, cx * (1 - sx), cy * (1 - sy)],
    scaleX: (sx, cx = 0) => [sx, 0, 0, 1, cx * (1 - sx), 0],
    scaleY: (sy, cy = 0) => [1, 0, 0, sy, 0, cy * (1 - sy)],
    rotate: (a, cx = 0, cy = 0) => {
      let [s, c] = [sin(rad(a)), cos(rad(a))];
      return [c, s, -s, c, cx * (1 - c) + cy * s, cy * (1 - c) - cx * s];
    },
    skew: (ax, ay = ax) => [1, tan(rad(ay)), tan(rad(ax)), 1, 0, 0],
    skewX: a => [1, 0, tan(rad(a)), 1, 0, 0],
    skewY: a => [1, tan(rad(a)), 0, 1, 0, 0],
    matrix: (a, b, c, d, e, f) => [a, b, c, d, e, f],
  };

  let chain = [...transform.matchAll(new RegExp(`(${rxTransformNameStr})\\(([0-9eE,\\.\\s+-]+)\\)`, 'g'))].map(
    ([, fn, args]) => [fn, [...args.matchAll(new RegExp(rxNumberStr, 'g'))].map(([num]) => parseFloat(num))]
  );

  // For example,
  //    chain = [
  //      [ 'matrix', [ 1, 0, 0, 1, 0, 0 ] ],
  //      [ 'translate', [ 100, 100 ] ],
  //      [ 'rotate', [ 45 ] ],
  //      [ 'scale', [ 2, 2 ] ],
  //    ]

  chain = chain.map(([fn, args]) => matrices[fn](...args));

  // chain = [
  //   [ 1, 0, 0, 1, 0, 0 ],
  //   [ 1, 0, 0, 1, 100, 100 ],
  //   [ 0.7071067811865476, 0.7071067811865475, -0.7071067811865475, 0.7071067811865476, 0, 0 ],
  //   [ 2, 0, 0, 2, 0, 0 ]
  // ]

  // returns single matrix
  return consolidate(chain);
}

function groupPathList(pathList){
  let groupList = [];

  const compareStyleEquals = (s1,s2) => Object.entries(s1).every(([k,v])=>'d'==k?true:v==s2[k]);

  for(let path of pathList){
    let pathStyle = getComputedStyle(path);
    let groupFound = false;
    for(let group of groupList){
      if(compareStyleEquals(group.style,pathStyle)){
        group.list.push(path);
        groupFound = true;
        break;
      }
    }
    if(!groupFound){
      groupList.push({
        style: pathStyle,
        list: [path],
      });
    }
  }
  return groupList;
}

function ungroupPathList(groupList){
  let pathList = [];
  for(let group of groupList){
    let newPath = document.createElementNS('http://www.w3.org/2000/svg','path');
    Object.entries(getComputedStyle(group.list[0])).forEach(([k,v])=>{if(k!=='d')newPath.style.setProperty(k,v)});
    let d = group.list.map(path=>(path.parentNode.removeChild(path),path.getAttribute('d'))).join(' ');
    d = d.replace(/[0-9]+\.[0-9]+/g, num => parseFloat(num).toFixed(1));
    newPath.setAttribute('d', d);
    // this varian to run outside <svg>
    document.querySelector('svg').appendChild(newPath);
    // this variant to run inside <svg>
    //document.documentElement.appendChild(newPath);
    pathList.push(newPath);
  }
  return pathList;
}

setTimeout(()=>{

  // TODO: Parse <svg width="" height="" viewBox=""> to common CTM

  // Replace all <use /> elements to clone elements referenced by the <use>
  document.querySelectorAll('use').forEach(processUse);

  // Replace all shape elements to <path />
  document.querySelectorAll('line, rect, circle, ellipse, polygon, polyline').forEach(processShape);

  // Process all <path /> elements
  let pathList = [...document.querySelectorAll('path')].map(processPath).filter(Boolean);

  // Group all <path> into groups by style values
  let groupList = groupPathList(pathList);

  // Convert each group into single <path>
  pathList = ungroupPathList(groupList);

  // Output new values
  console.log(JSON.stringify(pathList.map(path=>new D(path.getAttribute('d')).rel.join(' ')),null,2));

},1000);
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000">

  <g transform="translate(-100,-100)">

    <g id="hexagon" transform="translate(250,220) scale(1,.5)">
      <line id="line" x1="200" x2="500" y1="100" y2="100" stroke="#000" stroke-width="5" />
      <g transform="rotate(-120)" transform-origin="500 100">
        <use href="#line" />
        <g transform="rotate(-120)" transform-origin="200 100">
          <use href="#line" />
          <g transform="rotate(-120)" transform-origin="500 100">
            <use href="#line" />
            <g transform="rotate(-120)" transform-origin="200 100">
              <use href="#line" />
              <g transform="rotate(-120)" transform-origin="500 100">
                <use href="#line" />
              </g>
            </g>
          </g>
        </g>
      </g>
    </g>


    <g id="hexagon2" transform="translate(250,720) scale(1,.5)">
      <line id="line2" x1="200" x2="500" y1="100" y2="100" stroke="#000" stroke-width="5" />
      <g transform="rotate(60) translate(-300,0)" transform-origin="200 100">
        <use href="#line2" />
        <g transform="rotate(60) translate(-300,0)" transform-origin="200 100">
          <use href="#line2" />
          <g transform="rotate(60) translate(-300,0)" transform-origin="200 100">
            <use href="#line2" />
            <g transform="rotate(60) translate(-300,0)" transform-origin="200 100">
              <use href="#line2" />
              <g transform="rotate(60) translate(-300,0)" transform-origin="200 100">
                <use href="#line2" />
              </g>
            </g>
          </g>
        </g>
      </g>
    </g>

    <use href="#hexagon" transform="scale(1,-1) translate(0,-1200)" />

    <rect x="500" y="500" width="200" height="400" fill="#0008" stroke="#000" stroke-width="5" />
    <g transform="translate(-100,-100) skewY(-45)" transform-origin="500 500">
      <rect x="500" y="500" width="100" height="400" fill="#0004" stroke="#000" stroke-width="5" />
    </g>
    <g transform="translate(-100,-100) skewY(45)" transform-origin="500 500">
      <rect x="500" y="500" width="100" height="400" fill="#0008" stroke="#000" stroke-width="5" />
    </g>
    <g transform="translate(200,-200) skewY(45)" transform-origin="500 500">
      <rect x="500" y="500" width="100" height="400" fill="#0004" stroke="#000" stroke-width="5" />
    </g>
    <g transform="translate(200,0) skewY(-45)" transform-origin="500 500">
      <rect x="500" y="500" width="100" height="400" fill="#0008" stroke="#000" stroke-width="5" />
    </g>
    <rect x="500" y="300" width="200" height="400" fill="#0004" stroke="#000" stroke-width="5" />
  </g>

</svg>

Reasons:
  • Blacklisted phrase (1): helped me a lot
  • Contains signature (1):
  • Long answer (-1):
  • Has code block (-0.5):
  • Contains question mark (0.5):
  • Self-answer (0.5):
  • Low reputation (0.5):
Posted by: DiD