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>