/**
* decompose shorthands to "longhand" commands:
* H, V, S, T => L, L, C, Q
* reversed method: pathDataToShorthands()
*/
function pathDataToLonghands(pathData) {
pathData = pathDataToAbsolute(pathData);
let pathDataLonghand = [];
let comPrev = {
type: "M",
values: pathData[0].values
};
pathDataLonghand.push(comPrev);
for (let i = 1; i < pathData.length; i++) {
let com = pathData[i];
let {
type,
values
} = com;
let valuesL = values.length;
let valuesPrev = comPrev.values;
let valuesPrevL = valuesPrev.length;
let [x, y] = [values[valuesL - 2], values[valuesL - 1]];
let cp1X, cp1Y, cp2X, cp2Y;
let [prevX, prevY] = [
valuesPrev[valuesPrevL - 2],
valuesPrev[valuesPrevL - 1]
];
switch (type) {
case "H":
comPrev = {
type: "L",
values: [values[0], prevY]
};
break;
case "V":
comPrev = {
type: "L",
values: [prevX, values[0]]
};
break;
case "T":
[cp1X, cp1Y] = [valuesPrev[0], valuesPrev[1]];
[prevX, prevY] = [
valuesPrev[valuesPrevL - 2],
valuesPrev[valuesPrevL - 1]
];
// new control point
cpN1X = prevX + (prevX - cp1X);
cpN1Y = prevY + (prevY - cp1Y);
comPrev = {
type: "Q",
values: [cpN1X, cpN1Y, x, y]
};
break;
case "S":
[cp1X, cp1Y] = [valuesPrev[0], valuesPrev[1]];
[cp2X, cp2Y] =
valuesPrevL > 2 ? [valuesPrev[2], valuesPrev[3]] : [valuesPrev[0], valuesPrev[1]];
[prevX, prevY] = [
valuesPrev[valuesPrevL - 2],
valuesPrev[valuesPrevL - 1]
];
// new control points
cpN1X = 2 * prevX - cp2X;
cpN1Y = 2 * prevY - cp2Y;
cpN2X = values[0];
cpN2Y = values[1];
comPrev = {
type: "C",
values: [cpN1X, cpN1Y, cpN2X, cpN2Y, x, y]
};
break;
default:
comPrev = {
type: type,
values: values
};
}
pathDataLonghand.push(comPrev);
}
return pathDataLonghand;
}
/**
* apply shorthand commands if possible
* L, L, C, Q => H, V, S, T
*/
function pathDataToShorthands(pathData) {
pathData = pathDataToAbsolute(pathData);
let comShort = {
type: "M",
values: pathData[0].values
};
let pathDataShorts = [comShort];
for (let i = 1; i < pathData.length; i++) {
let com = pathData[i];
let comPrev = pathData[i - 1];
let {
type,
values
} = com;
let valuesL = values.length;
let valuesPrev = comPrev.values;
let valuesPrevL = valuesPrev.length;
let [x, y] = [values[valuesL - 2], values[valuesL - 1]];
let cp1X, cp1Y, cp2X, cp2Y;
let [prevX, prevY] = [
valuesPrev[valuesPrevL - 2],
valuesPrev[valuesPrevL - 1]
];
let val0R, cpN1XR, val1R, cpN1YR, cpN1X, cpN1Y, cpN2X, cpN2Y, prevXR, prevYR;
switch (type) {
case "L":
[val0R, prevXR, val1R, prevYR] = [
values[0],
prevX,
values[1],
prevY
].map((val) => {
return +(val * 2).toFixed(1);
});
if (prevYR == val1R && prevXR !== val0R) {
comShort = {
type: "H",
values: [values[0]]
};
} else if (prevXR == val0R && prevYR !== val1R) {
comShort = {
type: "V",
values: [values[1]]
};
} else {
comShort = com;
}
break;
case "Q":
[cp1X, cp1Y] = [valuesPrev[0], valuesPrev[1]];
[prevX, prevY] = [
valuesPrev[valuesPrevL - 2],
valuesPrev[valuesPrevL - 1]
];
// Q control point
cpN1X = prevX + (prevX - cp1X);
cpN1Y = prevY + (prevY - cp1Y);
/**
* control points can be reflected
*/
[val0R, cpN1XR, val1R, cpN1YR] = [
values[0],
cpN1X,
values[1],
cpN1Y
].map((val) => {
return +val.toFixed(1);
});
if (val0R == cpN1XR && val1R == cpN1YR) {
comShort = {
type: "T",
values: [x, y]
};
} else {
comShort = com;
}
break;
case "C":
[cp1X, cp1Y] = [valuesPrev[0], valuesPrev[1]];
[cp2X, cp2Y] =
valuesPrevL > 2 ? [valuesPrev[2], valuesPrev[3]] : [valuesPrev[0], valuesPrev[1]];
[prevX, prevY] = [
valuesPrev[valuesPrevL - 2],
valuesPrev[valuesPrevL - 1]
];
// C control points
cpN1X = 2 * prevX - cp2X;
cpN1Y = 2 * prevY - cp2Y;
cpN2X = values[2];
cpN2Y = values[3];
/**
* control points can be reflected
*/
[val0R, cpN1XR, val1R, cpN1YR] = [
values[0],
cpN1X,
values[1],
cpN1Y
].map((val) => {
return +val.toFixed(1);
});
if (val0R == cpN1XR && val1R == cpN1YR) {
comShort = {
type: "S",
values: [cpN2X, cpN2Y, x, y]
};
} else {
comShort = com;
}
break;
default:
comShort = {
type: type,
values: values
};
}
pathDataShorts.push(comShort);
}
return pathDataShorts;
}
/**
* dependancy: Jarek Foks's pathdata polyfill
* github: https://github.com/jarek-foksa/path-data-polyfill
*/
function pathDataToRelative(pathData, decimals = -1) {
let M = pathData[0].values;
let x = M[0],
y = M[1],
mx = x,
my = y;
for (let i = 1; i < pathData.length; i++) {
let cmd = pathData[i];
let type = cmd.type;
let typeRel = type.toLowerCase();
let values = cmd.values;
// is absolute
if (type != typeRel) {
type = typeRel;
cmd.type = type;
// 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 (cmd.type == "m") {
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 coordinates
if (decimals >= 0) {
cmd.values = values.map((val) => {
return +val.toFixed(decimals);
});
}
}
// round M (starting point)
if (decimals >= 0) {
[M[0], M[1]] = [+M[0].toFixed(decimals), +M[1].toFixed(decimals)];
}
return pathData;
}
function pathDataToAbsolute(pathData, decimals = -1) {
let M = pathData[0].values;
let x = M[0],
y = M[1],
mx = x,
my = y;
for (let i = 1; i < pathData.length; i++) {
let cmd = pathData[i];
let type = cmd.type;
let typeAbs = type.toUpperCase();
let values = cmd.values;
if (type != typeAbs) {
type = typeAbs;
cmd.type = type;
switch (typeAbs) {
case "A":
values[5] = +(values[5] + x);
values[6] = +(values[6] + y);
break;
case "V":
values[0] = +(values[0] + y);
break;
case "H":
values[0] = +(values[0] + x);
break;
case "M":
mx = +values[0] + x;
my = +values[1] + y;
default:
// other commands
if (values.length) {
for (let v = 0; v < values.length; v++) {
// even value = y coordinates
values[v] = values[v] + (v % 2 ? y : x);
}
}
}
}
// is already absolute
let vLen = values.length;
switch (type) {
case "Z":
x = +mx;
y = +my;
break;
case "H":
x = values[0];
break;
case "V":
y = values[0];
break;
case "M":
mx = values[vLen - 2];
my = values[vLen - 1];
default:
x = values[vLen - 2];
y = values[vLen - 1];
}
// round coordinates
if (decimals >= 0) {
cmd.values = values.map((val) => {
return +val.toFixed(decimals);
});
}
}
// round M (starting point)
if (decimals >= 0) {
[M[0], M[1]] = [+M[0].toFixed(decimals), +M[1].toFixed(decimals)];
}
return pathData;
}
function setPathDataOpt(path, pathData, decimals) {
let d = "";
pathData.forEach((com, c) => {
let type = com["type"];
let values = com["values"];
if (decimals >= 0) {
values.forEach(function(val, v) {
pathData[c]["values"][v] = +val.toFixed(decimals);
});
}
d += `${type}${values.join(" ")}`;
});
d = d.replace(/\s\s+/g, " ", "").replaceAll(",", " ").replaceAll(" -", "-");
path.setAttribute("d", d);
}
function roundPathData(pathData, decimals = -1) {
pathData.forEach((com, c) => {
if (decimals >= 0) {
com.values.forEach((val, v) => {
pathData[c].values[v] = +val.toFixed(decimals);
});
}
});
return pathData;
}
function pathDataToQuadratic(pathData, width, height, precision = 0.1) {
let newPathData = [pathData[0]];
for (let i = 1; i < pathData.length; i++) {
let comPrev = pathData[i - 1];
let com = pathData[i];
let [type, values] = [com.type, com.values];
let [typePrev, valuesPrev] = [comPrev.type, comPrev.values];
let valuesPrevL = valuesPrev.length;
let [xPrev, yPrev] = [
valuesPrev[valuesPrevL - 2],
valuesPrev[valuesPrevL - 1]
];
// convert C to Q
if (type == "C") {
let q = cubicToQuad(
xPrev,
yPrev,
values[0],
values[1],
values[2],
values[3],
values[4],
values[5],
precision
);
let p0 = {
x: xPrev,
y: yPrev
},
cp1 = {
x: values[0],
y: values[1]
},
cp2 = {
x: values[2],
y: values[3]
},
p = {
x: values[4],
y: values[5]
};
/**
* convert to quadratic
* if curve is rather flat
* or shorter than 1/20 width/height of path
*/
let angle = getAngleABC(cp1, cp2, p);
let segLength = getDistance(p0, p);
if (segLength < (width + height) / 20 || angle < 20) {
for (let j = 2; j < q.length; j += 4) {
newPathData.push({
type: "Q",
values: [q[j], q[j + 1], q[j + 2], q[j + 3]].map((val) => {
return +val.toFixed(9);
})
});
}
} else {
newPathData.push(com);
}
} else {
newPathData.push(com);
}
}
return newPathData;
}
// get distance
function getDistance(p1, p2) {
if (Array.isArray(p1)) {
p1.x = p1[0];
p1.y = p1[1];
}
if (Array.isArray(p2)) {
p2.x = p2[0];
p2.y = p2[1];
}
let [x1, y1, x2, y2] = [p1.x, p1.y, p2.x, p2.y];
let y = x2 - x1;
let x = y2 - y1;
return Math.sqrt(x * x + y * y);
}
// get angle helper
function getAngle(p1, p2) {
let angle = (Math.atan2(p2.y - p1.y, p2.x - p1.x) * 180) / Math.PI;
//console.log(angle);
return angle;
}
// get angle between 3 points helper
function getAngleABC(A, B, C) {
let BA = Math.sqrt(Math.pow(A.x - B.x, 2) + Math.pow(A.y - B.y, 2));
let AC = Math.sqrt(Math.pow(A.x - C.x, 2) + Math.pow(A.y - C.y, 2));
let BC = Math.sqrt(Math.pow(C.x - B.x, 2) + Math.pow(C.y - B.y, 2));
let angle =
(Math.acos((AC * AC + BA * BA - BC * BC) / (2 * AC * BA)) * 180) / Math.PI;
return angle;
}
* {
box-sizing: border-box;
}
body {
font-family: sans-serif;
}
svg {
height: 20em;
width: auto;
max-width: 100%;
margin: 0.3em;
overflow: visible;
border: 1px solid #ccc;
}
textarea {
width: 100%;
min-height: 30em;
font-family: monospace;
white-space: pre-wrap;
}
.flex {
display: flex;
gap: 1em;
}
.col {
flex: 1 1 auto;
width: 100%;
}
<label>Round to n decimals<input id="round" class="inputs" type="number" min="-1" max="8" value="1"></label>
<label><input name="absoluteRelative" class="inputs absoluteRelative" type="radio" min="-1" max="8" value="absolute">
Absolute </label>
<label><input name="absoluteRelative" class="inputs absoluteRelative" type="radio" min="-1" max="8" value="relative" checked> Relative </label>
<label> <input id="shorthands" class="inputs" type="checkbox" value="1" checked> Apply shorthands</label>
<label> <input id="toQuadratic" class="inputs" type="checkbox" value="1">small or flat curves to quadratic</label>
<label> <input id="crop" class="inputs" type="checkbox" value="1" checked> Crop and center</label>
<div class="flex">
<div class="col">
<h3>Vector drawable input</h3>
<textarea id="svgIn" class="inputs filesize">
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94c0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.36 4.8,11.69 4.8,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6s3.6,1.62 3.6,3.6S13.98,15.6 12,15.6z"/>
</vector>
</textarea>
<svg id="svgPreview">
<path id="pathPrev" />
</svg>
</div>
<div class="col">
<h3>Vector drawable: relative, shorthands, rounded</h3>
<textarea id="svgOut" class="filesize"></textarea>
<p id="pathSize"></p>
<svg id="svgNew">
<path id="pathNew" />
</svg>
</div>
<div id="vectorDrawable"></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/path-data-polyfill@latest/path-data-polyfill.min.js">
</script>
<script src="https://cdn.jsdelivr.net/gh/herrstrietzel/svgHelpers@main/js/pathData.cubic2quad.js">
</script>
<script>
let decimals = +round.value;
let inputs = document.querySelectorAll('.inputs');
inputs.forEach(input => {
input.addEventListener('input', e => {
updateSVG()
})
})
window.addEventListener('DOMContentLoaded', e => {
updateSVG();
})
function updateSVG() {
let useRelative = document.querySelector('.absoluteRelative:checked').value;
decimals = +round.value;
let svgCode = cleanStr(svgIn.value);
//drawable to svg
let ns = 'http://schemas.android.com/apk/res/android';
let xml = new DOMParser();
let doc = xml.parseFromString(svgCode, "application/xml");
let vector = doc.querySelector('vector');
vectorDrawable.appendChild(vector);
let vectorPath = vector.querySelector('path');
let vectorD = vectorPath.getAttribute('android:pathData');
// apply path data to preview svg
pathPrev.setAttribute('d', vectorD);
let pathData = pathPrev.getPathData();
let viewportWidth = vector.getAttribute('android:viewportWidth');
let viewportHeight = vector.getAttribute('android:viewportHeight');
svgPreview.setAttribute('viewBox', [0, 0, viewportWidth, viewportHeight].join(' '));
let {
x,
y,
width,
height
} = pathPrev.getBBox();
// crop and center icon
let cropAndCenter = crop.checked ? true : false;
if (cropAndCenter) {
// apply rounded viewport height divisable by 4
viewportHeight = Math.ceil(height / 4) * 4;
viewportWidth = Math.ceil(width / 4) * 4;
// add necessary attributes
vector.setAttributeNS(ns, 'android:viewportWidth', viewportWidth);
vector.setAttributeNS(ns, 'android:viewportHeight', viewportHeight);
vector.setAttributeNS(ns, 'android:width', viewportWidth + 'dp');
vector.setAttributeNS(ns, 'android:height', viewportHeight + 'dp');
pathData = pathDataToRelative(pathData);
let xN = pathData[0].values[0] - x;
let yN = pathData[0].values[1] - y;
let offsetX = (viewportWidth - width) / 2;
let offsetY = (viewportHeight - height) / 2;
pathData[0].values = [xN + offsetX, yN + offsetY]
}
// pre rounding for low precision
if (decimals < 2) {
pathData = roundPathData(pathData, decimals)
}
/**
* cubic curves to quadratic
*/
let convertToQuad = toQuadratic.checked ? true : false;
if (convertToQuad) {
pathData = pathDataToQuadratic(pathDataToLonghands(pathData), width, height);
}
let useShorthands = shorthands.checked ? true : false;
if (useShorthands) {
pathData = pathDataToShorthands(pathData);
} else {
pathData = pathDataToLonghands(pathData);
}
if (useRelative === 'relative') {
pathData = pathDataToRelative(pathData);
} else {
pathData = pathDataToAbsolute(pathData);
}
setPathDataOpt(pathNew, pathData, decimals)
let dMin = pathNew.getAttribute('d');
// update drawable
vectorPath.setAttributeNS(ns, 'android:pathData', dMin);
let serializer = new XMLSerializer();
let xmlStr = serializer.serializeToString(vector).replace(/>\s+/g, '>').replaceAll('><', '>\n<');
pathSize.textContent = filesize(dMin) + ' Bytes (pathdata)';
svgOut.value = xmlStr;
svgNew.setAttribute('viewBox', [0, 0, viewportWidth, viewportHeight].join(' '));
showFileSize();
}
function showFileSize() {
let textareas = document.querySelectorAll('.filesize');
textareas.forEach(text => {
let pSize = text.nextElementSibling;
if (!pSize.classList.contains('pFileSize')) {
pSize = document.createElement('p');
pSize.classList.add('pFileSize');
text.parentNode.insertBefore(pSize, text.nextElementSibling);
}
let size = new Blob([text.value]).size;
size = +size.toFixed(3);
pSize.textContent = size + ' Bytes'
})
}
function filesize(str) {
let size = new Blob([str]).size;
return +size.toFixed(3);
}
function cleanStr(str) {
str = str.
replace(/[(\t\r\n]+/g, '').
replace(/\s{2,}/g, ' ').
trim();
return str;
}
function adjustViewBox(svg) {
let bb = svg.getBBox();
let bbVals = [bb.x, bb.y, bb.width, bb.height].map((val) => {
return +val.toFixed(2);
});
let maxBB = Math.max(...bbVals);
let [x, y, width, height] = bbVals;
svg.setAttribute("viewBox", [x, y, width, height].join(" "));
}
</script>
d
attribute? This post might be helpful “Very Long Vector Path” issues… and where to find them. – Upanchor