Last active
December 16, 2024 07:27
-
-
Save zazaulola/4f4b9f73edf249435827d2196406e2ee to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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