Skip to content

Instantly share code, notes, and snippets.

@zazaulola
Last active December 16, 2024 07:27
Show Gist options
  • Save zazaulola/4f4b9f73edf249435827d2196406e2ee to your computer and use it in GitHub Desktop.
Save zazaulola/4f4b9f73edf249435827d2196406e2ee to your computer and use it in GitHub Desktop.
// Debug mode
const DEBUG = true;
const log = (...args) => {
if (DEBUG) console.log(...args);
}
// 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.abs(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 : { A: i < 6 ? v : v.abs, 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() : v?.rel ?? ({ A: () => i < 6 ? v : v.rel, H: () => v.x - v.ref.x, V: () => v.y - v.ref.y, })?.[s[0]]?.())).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) ?? v))).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){
log('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);
log('matrix',matrix);
// parse origin
let origin = getOrigin(transformOrigin);
log('origin',origin);
// 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')) {
log('skip path inside pattern, mask or clipPath');
return;
}
// read element transform matrix
const elementOTM = getOTM(path);
log('elementOTM',elementOTM);
// get all parent elements
const parents = getParents(path);
log('parents',parents);
// clone element transform matrix array
let resultCTM = [1,0,0,1,0,0];
// for each parent
parents.reverse().forEach((parent) => {
// skip if parent is <svg>
if(parent.tagName == 'svg') return;
// read parent transform matrix
let parentOTM = getOTM(parent);
log('parentOTM',parentOTM);
// calc CTM
resultCTM = consolidate([resultCTM,parentOTM]);
log('resultCTM',resultCTM);
});
resultCTM = 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;
let pathBBox = path.getBBox();
let group = groupList.length>0?groupList[groupList.length-1]:null;
if(group&&compareStyleEquals(group.style,pathStyle)){
group.list.push(path);
}
else{
group = {list:[path],style: pathStyle};
groupList.push(group);
}
}
return groupList;
}
function ungroupPathList(groupList){
let pathList = [];
for(let group of groupList){
let newPath = document.createElementNS('http://www.w3.org/2000/svg','path');
setComputedStyle(newPath,getComputedStyle(group.list[0]));
let d = group.list.map(path=>(path.parentNode.removeChild(path),path.getAttribute('d'))).join(' ');
log(d);
d = d.replace(new RegExp(rxNumberStr,'g'), num => parseFloat(num).toFixed(1));
log(d);
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;
}
function setComputedStyle(element, style){
let svg = document.querySelector('svg');
let tempPath = document.createElementNS('http://www.w3.org/2000/svg','path');
svg.appendChild(tempPath);
let defStyle = getComputedStyle(tempPath);
Object.entries(style).forEach(([k,v])=>{
if(k=='d')return;
if(v==defStyle.getPropertyValue(k))return;
element.style.setProperty(k,v);
});
svg.removeChild(tempPath);
}
function boxIntersect ([x11,y11],[x12,y12],[x21,y21],[x22,y22]){
return x11<=x22&&x12>=x21&&y11<=y22&&y12>=y21;
}
function lineIntersect([x1,y1],[x2,y2],[x3,y3],[x4,y4]){
let [x12,y12,x34,y34,x13,] = [x1-x2,y1-y2,x3-x4,y3-y4,x1-x3];
let [y13,x21,y21,x43,y43,] = [y1-y3,x2-x1,y2-y1,x4-x3,y4-y3];
let d = x12*y34-y12*x34;
let [s,t] = [(x13*y34-y13*x34)/d,(x12*y13-y12*x13)/d];
return s>=0&&s<=1&&t>=0&&t<=1 ? [x1+t*x12,y1+t*y12] : false;
}
setTimeout(()=>{
// TODO: Parse <svg width="" height="" viewBox=""> to common CTM
let svg = document.querySelector('svg');
let width = svg.getAttribute('width');
let height = svg.getAttribute('height');
let viewBox = svg.getAttribute('viewBox');
let viewBoxArray = viewBox.split(/\s+/g);
let viewBoxWidth = viewBoxArray[2];
let viewBoxHeight = viewBoxArray[3];
let viewBoxX = viewBoxArray[0];
let viewBoxY = viewBoxArray[1];
let viewBoxRatio = viewBoxWidth/viewBoxHeight;
let svgRatio = width/height;
let scale = svgRatio/viewBoxRatio;
let scaleX = width/viewBoxWidth;
let scaleY = height/viewBoxHeight;
log('svg', svg);
log('width', width);
log('height', height);
log('viewBox', viewBox);
log('================ start decompose <use> ===============');
// Replace all <use /> elements to clone elements referenced by the <use>
document.querySelectorAll('use').forEach(processUse);
log('================ start decompose shapes ===============');
// Replace all shape elements to <path />
document.querySelectorAll('line, rect, circle, ellipse, polygon, polyline').forEach(processShape);
log('================ start process <path> ===============');
// Process all <path /> elements
let pathList = [...document.querySelectorAll('path')].map(processPath).filter(Boolean);
log(pathList);
Object.entries(pathList).forEach(([i,path])=>{path.num = i});
log('================ start group <path> ===============');
// Group all <path> into groups by style values
let groupList = groupPathList(pathList);
log(groupList);
log('================ start ungroup <path> ===============');
// Convert each group into single <path>
pathList = ungroupPathList(groupList);
const precisionDigits = 1;
log('================ convert to rel <path> ===============');
// Convert all coordinates to relative form
pathList = pathList.map(path=>{
let dRel = new D(path.getAttribute('d')).rel.join(' ');
log('dRel', dRel);
dRel = dRel.replace(new RegExp(rxNumberStr,'g'), n => parseFloat(n).toFixed(precisionDigits).replace(/\.0+$/,''));
log('dRel', dRel);
path.setAttribute('d', dRel);
return dRel;
}).map(path=>path);
// Output new values
console.log(JSON.stringify(pathList,null,2));
},1000);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment