svg2vectordrawable
Version:
JavaScript module and command-line tools for convert SVG to Android vector drawable.
891 lines (852 loc) • 41.2 kB
JavaScript
const { parseSvg } = require('svgo/lib/parser');
const JSAPI = require('svgo/lib/svgo/jsAPI');
// https://www.npmjs.com/package/svg-path-bounds
const pathBounds = require('svg-path-bounds');
// https://github.com/fontello/svgpath
const svgpath = require('svgpath');
// const { stringifySvg } = require('svgo/lib/stringifier.js');
let JS2XML = function() {
this.width = 24;
this.height = 24;
this.viewportWidth = 24;
this.viewportHeight = 24;
this.indent = 4;
this.indentLevel = 0;
// https://developer.android.com/reference/android/graphics/drawable/VectorDrawable
this.vectordrawableTags = [
// Android vs SVG
'vector', // svg
'group', // g
'path', // path, rect, circle, polygon, ellipse, polyline, line
'clip-path', // mask
'aapt:attr',
'gradient', // linearGradient, radialGradient
'item' // stop
];
this.vectordrawableAttrs = [
// Android vs SVG
'android:name', // id
// <Vector>
'xmlns:aapt',
'android:width', // width
'android:height', // height
'android:viewportWidth', 'android:viewportHeight', // viewBox
'android:tint',
'android:tintMode',
'android:autoMirrored',
'android:alpha',
// <group>
'android:rotation', // transform="rotate(<a> [<x> <y>])"
'android:pivotX',
'android:pivotY',
'android:scaleX', // transform="scale(<x> [<y>])"
'android:scaleY',
'android:translateX', // transform="translate(<x> [<y>])"
'android:translateY',
// <path>
'android:pathData', // d
'android:fillColor', // fill
'android:fillAlpha', // fill-opacity 0..1
'android:strokeColor', // stroke
'android:strokeWidth', // stroke-width
'android:strokeAlpha', // stroke-opacity
'android:trimPathStart',
'android:trimPathEnd',
'android:trimPathOffset',
'android:strokeLineCap', // stroke-linecap butt, round, square. Default is butt.
'android:strokeLineJoin', // stroke-linejoin miter, round, bevel. Default is miter.
'android:strokeMiterLimit', // stroke-miterlimit Default is 4.
'android:fillType', // fill-rule For SDK 24+, evenOdd, nonZero. Default is nonZero.
// aapt
'name',
'xmlns:aapt',
// gradient
'android:type', // linear, radial, sweep
'android:startX', // x1
'android:startY', // y1
'android:endX', // x2
'android:endY', // y2
'android:centerX', // cx
'android:centerY', // cy
'android:gradientRadius', // r
'android:startColor', // not support
'android:centerColor', // not support
'android:endColor', // not support
'android:tileMode', // not support, disabled, clamp, repeat, mirror
// gradient stop
'android:color', // stop-color
'android:offset' // offset
];
};
JS2XML.prototype.refactorData = function(data, floatPrecision, fillBlack, tint) {
// Tag use to original
let elemUses = data.querySelectorAll('use');
if (elemUses) {
elemUses.forEach(elem => {
if (elem.hasAttr('xlink:href') || elem.hasAttr('href')) {
let attr = '';
if (elem.hasAttr('xlink:href')) {
attr = 'xlink:href';
}
if (elem.hasAttr('href')) {
attr = 'href';
}
let originalElem = data.querySelector(elem.attr(attr).value);
let newElem = new JSAPI({
type: originalElem.type,
name: originalElem.name
});
originalElem.eachAttr(attr => {
if (attr.name !== attr && attr.name !== 'id') {
newElem.addAttr(attr);
}
});
elem.eachAttr(attr => {
if (attr.name !== attr && attr.name !== 'id' && attr.name !== 'class') {
newElem.addAttr(attr);
}
});
if ((newElem.attr('x') || newElem.attr('y')) && newElem.attr('d')) {
let x = newElem.attr('x') ? parseFloat(newElem.attr('x').value) : 0;
let y = newElem.attr('y') ? parseFloat(newElem.attr('y').value) : 0;
let pathData = newElem.attr('d').value;
if (x !== 0 || y !== 0) {
pathData = svgpath(pathData).translate(x, y).rel().round(floatPrecision).toString();
}
newElem.removeAttr('d');
newElem.addAttr({ name: 'd', value: pathData, prefix: '', local: 'd' });
}
elem.parentNode.spliceContent(elem.parentNode.children.indexOf(elem), 0, newElem);
elem.parentNode.spliceContent(elem.parentNode.children.indexOf(elem), 1, []);
}
});
}
// Remove id attribute in some elements.
// SVGO do not move transform from group to child elements which have id attribute.
let elemHaveIds = data.querySelectorAll('path, g, circle, ellipse, line, polygon, polyline, rect');
if (elemHaveIds) {
elemHaveIds.forEach(elem => {
elem.removeAttr('id');
});
}
// Rounded rect to path, SVGO does not convert round rect to paths.
let elemRects = data.querySelectorAll('rect');
if (elemRects) {
elemRects.forEach(elem => {
elem.renameElem('path');
let x = elem.hasAttr('x') ? parseFloat(elem.attr('x').value) : 0;
let y = elem.hasAttr('y') ? parseFloat(elem.attr('y').value) : 0;
let width = elem.hasAttr('width') ? parseFloat(elem.attr('width').value) : 0;
let height = elem.hasAttr('height') ? parseFloat(elem.attr('height').value) : 0;
let rx = 0;
let ry = 0;
// see spec here: https://www.w3.org/TR/SVG11/shapes.html#RectElement
if (elem.hasAttr('rx') && elem.hasAttr('ry')) {
rx = parseFloat(elem.attr('rx').value);
ry = parseFloat(elem.attr('ry').value);
}
else if (elem.hasAttr('rx')) {
rx = parseFloat(elem.attr('rx').value);
ry = rx;
}
else if (elem.hasAttr('ry')) {
ry = parseFloat(elem.attr('ry').value);
rx = ry;
}
x = this.round(x, floatPrecision);
y = this.round(y, floatPrecision);
width = this.round(width, floatPrecision);
height = this.round(height, floatPrecision);
rx = this.round(rx, floatPrecision);
ry = this.round(ry, floatPrecision);
let pathData = this.rectToPathData(x, y, width, height, rx, ry);
// Android 8- have a bug when drawing rounded rectangle with arc code in path data.
if (rx !== 0 || ry !== 0) {
pathData = svgpath(pathData).unarc().rel().round(floatPrecision).toString();
}
// Apply transform to rect.
if (elem.hasAttr('transform')) {
let svgTransform = elem.attr('transform').value;
let number = '((?:-)?\\d+(?:\\.\\d+)?)';
let separator = '(?:(?:\\s+)|(?:\\s*,\\s*))';
let translateRegExp = new RegExp(`translate\\(${number}${separator}?${number}?\\)`);
let scaleRegExp = new RegExp(`scale\\(${number}${separator}?${number}?\\)`);
let rotateRegExp = new RegExp(`rotate\\(${number}${separator}?${number}?${separator}?${number}?\\)`);
let translateMatch = translateRegExp.exec(svgTransform);
let scaleMatch = scaleRegExp.exec(svgTransform);
let rotateMatch = rotateRegExp.exec(svgTransform);
if (rotateMatch) {
let angle = parseFloat(rotateMatch[1]);
let rx = parseFloat(rotateMatch[2]) || 0;
let ry = parseFloat(rotateMatch[3]) || 0;
pathData = svgpath(pathData).rotate(angle, rx, ry).rel().round(floatPrecision).toString();
}
if (scaleMatch) {
let sx = parseFloat(scaleMatch[1]);
let sy = parseFloat(scaleMatch[2]) || sx;
pathData = svgpath(pathData).scale(sx, sy).rel().round(floatPrecision).toString();
}
if (translateMatch) {
let x = parseFloat(translateMatch[1]);
let y = parseFloat(translateMatch[2]) || 0;
pathData = svgpath(pathData).translate(x, y).rel().round(floatPrecision).toString();
}
}
elem.addAttr({ name: 'd', value: pathData, prefix: '', local: 'd' });
elem.removeAttr('x')
elem.removeAttr('y')
elem.removeAttr('width')
elem.removeAttr('height')
elem.removeAttr('rx')
elem.removeAttr('ry')
});
}
// Tag g to group
let elemGroups = data.querySelectorAll('g');
if (elemGroups) {
elemGroups.forEach(elem => {
let childPaths = elem.querySelectorAll('path');
if (childPaths) {
childPaths.forEach(item => {
// Move fill to child path
if (elem.hasAttr('fill') && !elem.hasAttr('fill', 'none') && !item.hasAttr('fill') && !item.hasAttr('fill', 'none')) {
item.addAttr({ name: 'fill', value: elem.attr('fill').value, prefix: '', local: 'fill' });
}
// Move fill-opacity to child path
if (elem.hasAttr('fill-opacity') && !item.hasAttr('fill-opacity')) {
item.addAttr({ name: 'fill-opacity', value: elem.attr('fill-opacity').value, prefix: '', local: 'fill-opacity' });
}
// Move stroke to child path
if (elem.hasAttr('stroke') && !item.hasAttr('stroke')) {
item.addAttr({ name: 'stroke', value: elem.attr('stroke').value, prefix: '', local: 'stroke' });
}
// Move stroke-width to child path
if (elem.hasAttr('stroke-width') && !item.hasAttr('stroke-width')) {
item.addAttr({ name: 'stroke-width', value: elem.attr('stroke-width').value, prefix: '', local: 'stroke-width' });
}
// Move stroke-opacity to child path
if (elem.hasAttr('stroke-opacity') && !item.hasAttr('stroke-opacity')) {
item.addAttr({ name: 'stroke-opacity', value: elem.attr('stroke-opacity').value, prefix: '', local: 'stroke-opacity' });
}
// Move stroke-linecap to child path
if (elem.hasAttr('stroke-linecap') && !item.hasAttr('stroke-linecap')) {
item.addAttr({ name: 'stroke-linecap', value: elem.attr('stroke-linecap').value, prefix: '', local: 'stroke-linecap' });
}
// Move stroke-linejoin to child path
if (elem.hasAttr('stroke-linejoin') && !item.hasAttr('stroke-linejoin')) {
item.addAttr({ name: 'stroke-linejoin', value: elem.attr('stroke-linejoin').value, prefix: '', local: 'stroke-linejoin' });
}
// Move opacity to child node
if (elem.hasAttr('opacity')) {
let opacity = elem.attr('opacity').value;
if (item.hasAttr('opacity')) {
opacity = this.round(elem.attr('opacity').value * item.attr('opacity').value, floatPrecision);
}
item.addAttr({ name: 'opacity', value: opacity, prefix: '', local: 'opacity' });
}
// Move fill-rule to child node which has fill attribute
if (elem.hasAttr('fill-rule', 'evenodd')) {
if (!item.hasAttr('fill-rule', 'nonzero') && item.hasAttr('fill')) {
item.addAttr({ name: 'android:fillType', value: 'evenOdd', prefix: 'android', local: 'fillType' });
}
}
});
elem.removeAttr('fill');
elem.removeAttr('fill-opacity');
elem.removeAttr('stroke');
elem.removeAttr('stroke-width');
elem.removeAttr('stroke-opacity');
elem.removeAttr('stroke-linecap');
elem.removeAttr('stroke-linejoin');
elem.removeAttr('opacity');
elem.removeAttr('fill-rule');
}
// Group transform
if (elem.hasAttr('transform')) {
let svgTransform = elem.attr('transform').value;
let number = '((?:-)?\\d+(?:\\.\\d+)?)';
let separator = '(?:(?:\\s+)|(?:\\s*,\\s*))';
let attrs = { rotation: '', pivotX: '', pivotY: '', scaleX: '', scaleY: '', translateX: '', translateY: ''};
let translateRegExp = new RegExp(`translate\\(${number}${separator}?${number}?\\)`, 'g');
let translateX = 0;
let translateY = 0;
let translateMatch;
while (translateMatch = translateRegExp.exec(svgTransform)) {
translateX += Number(translateMatch[1]);
translateY += Number(translateMatch[2]);
}
if (translateX !== 0 && !isNaN(translateX)) {
attrs.translateX = translateX;
}
if (translateY !== 0 && !isNaN(translateY)) {
attrs.translateY = translateY;
}
let scaleRegExp = new RegExp(`scale\\(${number}${separator}?${number}?\\)`, 'g');
let scaleX = 1;
let scaleY = 1;
let scaleMatch;
while (scaleMatch = scaleRegExp.exec(svgTransform)) {
scaleX *= Number(scaleMatch[1]);
scaleY *= Number(scaleMatch[2]) || Number(scaleMatch[1]);
}
if (scaleX !== 1 && !isNaN(scaleX)) {
attrs.scaleX = scaleX;
}
if (scaleY !== 1 && !isNaN(scaleY)) {
attrs.scaleY = scaleY;
}
let rotateRegExp = new RegExp(`rotate\\(${number}${separator}?${number}?${separator}?${number}?\\)`);
let rotateMatch = rotateRegExp.exec(svgTransform);
if (rotateMatch) {
attrs.rotation = rotateMatch[1];
attrs.pivotX = rotateMatch[2] || '';
attrs.pivotY = rotateMatch[3] || '';
}
let skewRegExp = new RegExp(`skew\([XY]\)\\(${number}\\)`);
let matrixRegExp = new RegExp('matrix\\(\(.*\)\\)');
let skewMatch = skewRegExp.exec(svgTransform);
let matrixMatch = matrixRegExp.exec(svgTransform);
if (skewMatch || matrixMatch) {
let paths = elem.querySelectorAll('path');
if (paths) {
paths.forEach(path => {
let pathData = path.attr('d').value;
if (skewMatch) {
let skewX = skewMatch[1] === 'X' ? parseFloat(skewMatch[2]) : 0;
let skewY = skewMatch[1] === 'Y' ? parseFloat(skewMatch[2]) : 0;
pathData = svgpath(pathData).skewX(skewX).skewY(skewY).rel().round(floatPrecision).toString();
}
if (matrixMatch) {
let matrix = matrixMatch[1].split(' ').map(item => parseFloat(item));
pathData = svgpath(pathData).matrix(matrix).rel().round(floatPrecision).toString();
}
path.removeAttr('d');
path.addAttr({ name: 'd', value: pathData, prefix: '', local: 'd' });
});
}
}
Object.keys(attrs).forEach(key => {
if (attrs[key] !== '' && (attrs[key] !== '0' && (key !== 'scaleX' || key !== 'scaleY'))) {
elem.addAttr({ name: `android:${key}`, value: this.round(attrs[key], floatPrecision), prefix: 'android', local: key });
}
});
elem.removeAttr('transform');
}
});
}
// Rename g to group, and remove useless g tag.
elemGroups = data.querySelectorAll('g');
if (elemGroups) {
elemGroups.forEach(elem => {
if (Object.keys(elem.attrs).length === 0) {
elem.parentNode.spliceContent(elem.parentNode.children.indexOf(elem), 0, elem.children);
elem.parentNode.spliceContent(elem.parentNode.children.indexOf(elem), 1, []);
} else {
elem.renameElem('group');
}
});
}
// Tag svg to vector
let elemSVG = data.querySelector('svg');
if (elemSVG) {
elemSVG.renameElem('vector');
if (elemSVG.hasAttr('width') && elemSVG.hasAttr('height')) {
this.width = parseInt(elemSVG.attr('width').value);
this.height = parseInt(elemSVG.attr('height').value);
}
if (elemSVG.hasAttr('viewBox')) {
let [x, y, w, h] = elemSVG.attr('viewBox').value.split(/\s+/);
this.viewportWidth = w;
this.viewportHeight = h;
if (!elemSVG.hasAttr('width') && !elemSVG.hasAttr('height')) {
this.width = w;
this.height = h;
}
}
elemSVG.attrs = {};
// SVG is not support sweep (angular) gradient
if (data.querySelector("linearGradient, radialGradient, sweepGradient, aapt\\:attr")) {
elemSVG.addAttr({ name: 'xmlns:aapt', value: 'http://schemas.android.com/aapt', prefix: 'xmlns', local: 'aapt' });
}
elemSVG.addAttr({ name: 'android:width', value: this.width + 'dp', prefix: 'android', local: 'width' });
elemSVG.addAttr({ name: 'android:height', value: this.height + 'dp', prefix: 'android', local: 'height' });
elemSVG.addAttr({ name: 'android:viewportWidth', value: this.viewportWidth, prefix: 'android', local: 'viewportWidth' });
elemSVG.addAttr({ name: 'android:viewportHeight', value: this.viewportHeight, prefix: 'android', local: 'viewportHeight' });
// Tint color
if (tint) {
if (/^ - -9 {1,8}$/i.test(tint)) {
tint = tint.toUpperCase();
}
elemSVG.addAttr({ name: 'android:tint', value: tint, prefix: 'android', local: 'tint' });
}
}
// Tag gradient
let elemGradients = data.querySelectorAll('linearGradient, radialGradient, sweepGradient');
if (elemGradients) {
elemGradients.forEach(gradient => {
if (gradient.hasAttr('id')) {
let gradientId = gradient.attr('id').value;
let gradientPaths = data.querySelectorAll(`path[fill="url(#${gradientId})"], path[stroke="url(#${gradientId})"]`);
if (gradientPaths) {
gradientPaths.forEach(path => {
this.addGradientToElement(gradient, path, floatPrecision);
});
}
}
// Remove original gradient element
gradient.parentNode.spliceContent(gradient.parentNode.children.indexOf(gradient), 1, []);
});
}
// Tag mask to clip-path
let elemMasks = data.querySelectorAll('mask');
if (elemMasks) {
elemMasks.forEach(elem => {
if (elem.hasAttr('id')) {
let maskId = elem.attr('id').value;
let clipMaskElem = elem.children[0];
let pathData = svgpath(clipMaskElem.attr('d').value).round(floatPrecision).toString();
// Create a group for mask
let maskGroup = new JSAPI({
type: 'element',
name: 'group',
attrs: {},
children: []
});
clipMaskElem.renameElem('clip-path');
clipMaskElem.attrs = {};
clipMaskElem.addAttr({ name: 'android:pathData', value: pathData, prefix: 'android', local: 'pathData' });
maskGroup.children.push(clipMaskElem);
// Move masked layer to mask group
let maskedElems = data.querySelectorAll(`*[mask="url(#${maskId})"]`);
if (maskedElems) {
maskedElems.forEach(item => {
item.removeAttr('mask');
maskGroup.children.push(item);
});
elem.parentNode.spliceContent(elem.parentNode.children.indexOf(maskedElems[0]), maskedElems.length, maskGroup);
} else {
elem.parentNode.spliceContent(elem.parentNode.children.indexOf(elem), 0, maskGroup);
}
}
// Remove original mask element
elem.parentNode.spliceContent(elem.parentNode.children.indexOf(elem), 1, []);
});
}
// Tag path
let elemPaths = data.querySelectorAll('path');
if (elemPaths) {
elemPaths.forEach(elem => {
// Fill
if (elem.hasAttr('fill')) {
if (elem.attr('fill').value === 'none') {
elem.removeAttr('fill');
}
else if (!/^url\(#.*\)$/.test(elem.attr('fill').value)) {
let color = this.svgHexToAndroid(elem.attr('fill').value);
let fillAttr = { name: 'android:fillColor', value: color, prefix: 'android', local: 'fillColor' };
if (elem.hasAttr('fill-opacity')) {
fillAttr.value = this.mergeColorAndOpacity(fillAttr.value, elem.attr('fill-opacity').value);
elem.removeAttr('fill-opacity');
}
elem.addAttr(fillAttr);
elem.removeAttr('fill');
}
}
// Fill black?
else if (fillBlack) {
let fillAttr = { name: 'android:fillColor', value: "#FF000000", prefix: 'android', local: 'fillColor' };
elem.addAttr(fillAttr);
}
// Opacity, Android not support path/group alpha
if (elem.hasAttr('opacity')) {
elem.addAttr({ name: 'android:fillAlpha', value: elem.attr('opacity').value, prefix: 'android', local: 'fillAlpha' });
}
// Tag stroke
if (elem.hasAttr('stroke')) {
if (!/^url\(#.*\)$/.test(elem.attr('stroke').value) && elem.attr('stroke').value !== 'none') {
let color = this.svgHexToAndroid(elem.attr('stroke').value);
let strokeAttr = { name: 'android:strokeColor', value: color, prefix: 'android', local: 'strokeColor' };
if (elem.hasAttr('stroke-opacity')) {
strokeAttr.value = this.mergeColorAndOpacity(strokeAttr.value, elem.attr('stroke-opacity').value);
elem.removeAttr('stroke-opacity');
}
elem.addAttr(strokeAttr);
elem.removeAttr('stroke');
}
if (elem.hasAttr('opacity')) {
elem.addAttr({ name: 'android:strokeAlpha', value: elem.attr('opacity').value, prefix: 'android', local: 'strokeAlpha' });
}
// SVG stroke-width default is 1, Android android:strokeWidth default is 0
let strokeWidthAttr = { name: 'android:strokeWidth', value: 0, prefix: 'android', local: 'strokeWidth' };
if (!elem.hasAttr('stroke-width')) {
strokeWidthAttr.value = 1;
}
else {
strokeWidthAttr.value = elem.attr('stroke-width').value;
elem.removeAttr('stroke-width');
}
elem.addAttr(strokeWidthAttr);
let strokeExtraAttrs = ['stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit'];
let strokeExtraAttrsAndroid = ['strokeLineCap', 'strokeLineJoin', 'strokeMiterLimit'];
strokeExtraAttrs.forEach((attr, index) => {
if (elem.hasAttr(attr)) {
let localName = strokeExtraAttrsAndroid[index];
elem.addAttr({ name: 'android:' + localName, value: elem.attr(attr).value, prefix: 'android', local: localName });
elem.removeAttr('attr');
}
});
}
// Opacity, Android not support path/group alpha
if (elem.hasAttr('opacity')) {
elem.removeAttr('opacity');
}
// Fill-rule
elem.removeAttr('fill-rule', 'nonzero');
if (elem.hasAttr('fill-rule', 'evenodd')) {
elem.addAttr({ name: 'android:fillType', value: 'evenOdd', prefix: 'android', local: 'fillType' });
elem.removeAttr('fill-rule', 'evenodd');
}
// Path data
if (elem.hasAttr('d')) {
// Fix SVO remove leading zero bug
let pathData = svgpath(elem.attr('d').value).round(floatPrecision).toString();
elem.addAttr({ name: 'android:pathData', value: pathData, prefix: 'android', local: 'pathData' });
elem.removeAttr('d');
}
});
}
};
JS2XML.prototype.addGradientToElement = function(gradient, elem, floatPrecision) {
let vectorDrawableGradient = new JSAPI({
type: 'element',
name: 'gradient',
children: []
});
let vectorDrawableAapt = new JSAPI({
type: 'element',
name: 'aapt:attr',
children: [ vectorDrawableGradient ]
});
let gradientId = gradient.attr('id').value;
if (elem.hasAttr('fill', `url(#${gradientId})`)) {
vectorDrawableAapt.addAttr({ name: 'name', value: 'android:fillColor', prefix: '', local: 'name'})
elem.removeAttr('fill')
}
if (elem.hasAttr('stroke', `url(#${gradientId})`)) {
vectorDrawableAapt.addAttr({ name: 'name', value: 'android:strokeColor', prefix: '', local: 'name'})
elem.removeAttr('stroke')
}
this.adjustGradientCoordinate(gradient, elem, floatPrecision);
if (gradient.name === 'linearGradient') {
vectorDrawableGradient.addAttr({ name: 'android:type', value: 'linear', prefix: 'android', local: 'type'});
let startX = gradient.hasAttr('x1') ? gradient.attr('x1').value : '0';
let startY = gradient.hasAttr('y1') ? gradient.attr('y1').value : '0';
let endX = gradient.hasAttr('x2') ? gradient.attr('x2').value : this.viewportWidth;
let endY = gradient.hasAttr('y2') ? gradient.attr('y2').value : '0';
vectorDrawableGradient.addAttr({ name: 'android:startX', value: startX, prefix: 'android', local: 'startX'});
vectorDrawableGradient.addAttr({ name: 'android:startY', value: startY, prefix: 'android', local: 'startY'});
vectorDrawableGradient.addAttr({ name: 'android:endX', value: endX, prefix: 'android', local: 'endX'});
vectorDrawableGradient.addAttr({ name: 'android:endY', value: endY, prefix: 'android', local: 'endY'});
}
if (gradient.name === 'radialGradient') {
vectorDrawableGradient.addAttr({ name: 'android:type', value: 'radial', prefix: 'android', local: 'type'});
let centerX = gradient.hasAttr('cx') ? gradient.attr('cx').value : this.viewportWidth / 2;
let centerY = gradient.hasAttr('cy') ? gradient.attr('cy').value : this.viewportHeight / 2;
if (gradient.hasAttr('rx')) centerX = gradient.attr('rx').value;
if (gradient.hasAttr('ry')) centerY = gradient.attr('ry').value;
let gradientRadius = gradient.hasAttr('r') ? gradient.attr('r').value : Math.max(this.viewportWidth, this.viewportHeight) / 2;
vectorDrawableGradient.addAttr({ name: 'android:centerX', value: centerX, prefix: 'android', local: 'centerX'});
vectorDrawableGradient.addAttr({ name: 'android:centerY', value: centerY, prefix: 'android', local: 'centerY'});
vectorDrawableGradient.addAttr({ name: 'android:gradientRadius', value: gradientRadius, prefix: 'android', local: 'gradientRadius'});
}
// SVG is not support sweepGradient
if (gradient.name === 'sweepGradient') {
vectorDrawableGradient.addAttr({ name: 'android:type', value: 'sweep', prefix: 'android', local: 'type'});
let centerX = gradient.hasAttr('cx') ? gradient.attr('cx').value : this.viewportWidth / 2;
let centerY = gradient.hasAttr('cy') ? gradient.attr('cy').value : this.viewportHeight / 2;
vectorDrawableGradient.addAttr({ name: 'android:centerX', value: centerX, prefix: 'android', local: 'centerX'});
vectorDrawableGradient.addAttr({ name: 'android:centerY', value: centerY, prefix: 'android', local: 'centerY'});
}
// Color stops
gradient.children.forEach(item => {
let colorStop = new JSAPI({
type: 'element',
name: 'item'
});
const stopColorAttr = item.attr('stop-color');
let color = this.svgHexToAndroid(stopColorAttr == null ? '#000000FF' : stopColorAttr.value);
const offsetAttr = item.attr('offset');
let offset = offsetAttr == null ? 0 : offsetAttr.value;
if (this.isPercent(offset)) {
offset = Math.round(parseFloat(offset)) / 100;
}
if (item.hasAttr('stop-opacity')) {
color = this.mergeColorAndOpacity(color, item.attr('stop-opacity').value);
}
colorStop.addAttr({ name: 'android:color', value: color, prefix: 'android', local: 'color'});
colorStop.addAttr({ name: 'android:offset', value: offset, prefix: 'android', local: 'offset'});
vectorDrawableGradient.children.push(colorStop);
});
if (!elem.children) elem.children = [];
elem.children.push(vectorDrawableAapt);
};
JS2XML.prototype.adjustGradientCoordinate = function(gradient, elem, floatPrecision) {
// Default value
if (gradient.elem === 'linearGradient') {
if (!gradient.hasAttr('x1')) {
gradient.addAttr({ name: 'x1', value: '0', prefix: '', local: 'x1'});
}
if (!gradient.hasAttr('y1')) {
gradient.addAttr({ name: 'y1', value: '0', prefix: '', local: 'y1'});
}
if (!gradient.hasAttr('x2')) {
gradient.addAttr({ name: 'x2', value: '100%', prefix: '', local: 'x2'});
}
if (!gradient.hasAttr('y2')) {
gradient.addAttr({ name: 'y2', value: '100%', prefix: '', local: 'y2'});
}
}
if (gradient.elem === 'radialGradient') {
if (!gradient.hasAttr('cx')) {
gradient.addAttr({ name: 'cx', value: '50%', prefix: '', local: 'cx'});
}
if (!gradient.hasAttr('cy')) {
gradient.addAttr({ name: 'cy', value: '50%', prefix: '', local: 'cy'});
}
if (!gradient.hasAttr('r')) {
gradient.addAttr({ name: 'r', value: '50%', prefix: '', local: 'r'});
}
}
if (gradient.elem === 'sweepGradient') {
if (!gradient.hasAttr('cx')) {
gradient.addAttr({ name: 'cx', value: '50%', prefix: '', local: 'cx'});
}
if (!gradient.hasAttr('cy')) {
gradient.addAttr({ name: 'cy', value: '50%', prefix: '', local: 'cy'});
}
}
gradient.eachAttr(attr => {
let positionAttrs = [
// SVG linearGradient
'x1', 'y1', 'x2', 'y2',
// SVG radialGradient, Android VectorDrawable not support 'fx' and 'fy'.
'cx', 'cy', 'r', 'fx', 'fy'
];
if (positionAttrs.indexOf(attr.name) >= 0) {
// Android gradient use gradientUnits="userSpaceOnUse", SVG default is objectBoundingBox.
if (!gradient.hasAttr('gradientUnits', 'userSpaceOnUse')) {
let [x1, y1, x2, y2] = pathBounds(elem.attr('d').value);
// Percent to float.
if (this.isPercent(attr.value)) {
let valueFloat = parseFloat(attr.value) / 100;
if (attr.name === 'x1' || attr.name === 'x2' || attr.name === 'cx' || attr.name === 'fx') {
attr.value = x1 + (x2 - x1) * valueFloat;
}
if (attr.name === 'y1' || attr.name === 'y2' || attr.name === 'cy' || attr.name === 'fy') {
attr.value = y1 + (y2 - y1) * valueFloat;
}
if (attr.name === 'r') {
attr.value = Math.max(x2 - x1, y2 - y1) * valueFloat;
}
}
else {
if (attr.name === 'x1' || attr.name === 'x2' || attr.name === 'cx' || attr.name === 'fx') {
attr.value = x1 + attr.value;
}
if (attr.name === 'y1' || attr.name === 'y2' || attr.name === 'cy' || attr.name === 'fy') {
attr.value = y1 + attr.value;
}
}
}
else {
if (this.isPercent(attr.value)) {
let valueFloat = parseFloat(attr.value) / 100;
if (attr.name === 'x1' || attr.name === 'x2' || attr.name === 'cx' || attr.name === 'fx') {
attr.value = this.viewportWidth * valueFloat;
}
if (attr.name === 'y1' || attr.name === 'y2' || attr.name === 'cy' || attr.name === 'fy') {
attr.value = this.viewportHeight * valueFloat;
}
if (attr.name === 'r') {
attr.value = Math.max(this.viewportWidth, this.viewportHeight) * valueFloat;
}
}
}
attr.value = this.round(attr.value, floatPrecision);
}
}, this);
};
JS2XML.prototype.mergeColorAndOpacity = function(androidColorHex, opacity) {
let opacityFromAndroidColor = parseInt(androidColorHex.substr(1, 2), 16) / 255;
let opacityHex = Number(Math.round(opacity * opacityFromAndroidColor * 255)).toString(16).toUpperCase();
if (opacityHex.length === 1) {
opacityHex = '0' + opacityHex;
}
return '#' + opacityHex + androidColorHex.substr(-6);
};
JS2XML.prototype.isPercent = function(value) {
return /(-)?\d+(\.d+)?%$/.test(String(value));
};
JS2XML.prototype.round = function(value, floatPrecision) {
return Math.round(value * Math.pow(10, floatPrecision)) / Math.pow(10, floatPrecision);
};
JS2XML.prototype.convert = function(data, floatPrecision, strict, fillBlack, tint) {
this.refactorData(data, floatPrecision, fillBlack, tint);
return this.travelConvert(data, strict);
};
JS2XML.prototype.travelConvert = function(data, strict) {
let xml = '';
this.indentLevel ++;
if (data.children) {
data.children.forEach(item => {
if (this.vectordrawableTags.indexOf(item.name) >= 0) {
xml += this.createElement(item, strict);
}
else if (strict) {
throw new Error('Unsupported element ' + item.name);
}
}, this);
}
this.indentLevel --;
return xml;
};
JS2XML.prototype.createElement = function(data, strict) {
if (data.isEmpty()) {
return this.createIndent() + '<' + data.name + this.createAttrs(data, strict) + '/>\n';
}
else {
let processedData = '';
processedData += this.travelConvert(data, strict);
return this.createIndent() + '<' + data.name + this.createAttrs(data, strict) + '>\n' +
processedData +
this.createIndent() + '</' + data.name + '>\n';
}
};
JS2XML.prototype.createAttrs = function(elem, strict) {
let attrs = '';
if (elem.name === 'vector') {
attrs += ' xmlns:android="http://schemas.android.com/apk/res/android"';
}
elem.eachAttr(function(attr) {
if (attr.value !== undefined) {
if (this.vectordrawableAttrs.indexOf(attr.name) >= 0) {
if (['fillColor', 'strokeColor', 'color'].indexOf(attr.local) >= 0) {
attr.value = this.simplifyAndroidHexCode(attr.value);
}
if (elem.name === 'aapt:attr' && attr.name === 'name') {
attrs += ' ' + attr.name + '="' + attr.value + '"';
}
else {
attrs += '\n' + this.createIndent() + ' '.repeat(this.indent) + attr.name + '="' + attr.value + '"';
}
} else if (strict) {
throw new Error('Unsupported attribute ' + attr.name);
}
}
}, this);
return attrs;
};
JS2XML.prototype.svgHexToAndroid = function(hexColor) {
// RRGGBBAA to AARRGGBB
// 8 digit hex code
if (/^#[0-9a-f]{8}$/i.test(hexColor)) {
hexColor = '#' + hexColor.substr(7, 2) + hexColor.substr(1, 6);
}
// RGBA to AARRGGBB
// 4 digit hex code
else if (/^#[0-9a-f]{4}$/i.test(hexColor)) {
hexColor = '#' + hexColor[4].repeat(2) + hexColor[1].repeat(2) + hexColor[2].repeat(2) + hexColor[3].repeat(2);
}
else if (/^#[0-9a-f]{6}$/i.test(hexColor)) {
hexColor = '#FF' + hexColor.substr(1, 6);
}
else if (/^#[0-9a-f]{3}$/i.test(hexColor)) {
hexColor = '#FF' + hexColor[1].repeat(2) + hexColor[2].repeat(2) + hexColor[3].repeat(2);
}
else {
hexColor = '#FF000000';
}
return hexColor.toUpperCase();
};
JS2XML.prototype.simplifyAndroidHexCode = function(androidColorHex) {
// Remove alpha is FF
if (/#FF[A-F0-9]{6}/.test(androidColorHex)) {
androidColorHex = '#' + androidColorHex.substr(-6);
}
let partOdd = androidColorHex.substr(1).split('').filter((item, index) => {
return index % 2 === 0;
}).join('');
let partEven = androidColorHex.substr(1).split('').filter((item, index) => {
return index % 2 === 1;
}).join('');
if (partOdd === partEven) {
androidColorHex = '#' + partOdd;
}
return androidColorHex;
};
JS2XML.prototype.createIndent = function() {
let indent = ' '.repeat(this.indent);
indent = indent.repeat(this.indentLevel - 1);
return indent;
};
JS2XML.prototype.rectToPathData = function(x, y, width, height, rx, ry) {
let d = '';
if(rx === 0 && ry === 0) {
d = 'M' + x + ',' + y + 'h' + width + 'v' + height + 'H' + x + 'z';
} else {
d = 'M' + x + ',' + (y + ry) +
'a' + rx + ' -' + ry + ' 0 0 1 ' + rx + ' -' + ry + 'h' + (width - rx * 2) +
'a' + rx + ' ' + ry + ' 0 0 1 ' + rx + ' ' + ry + 'v' + (height - ry * 2) +
'a-' + rx + ' ' + ry + ' 0 0 1 -' + rx + ' ' + ry + 'h-' + (width - rx * 2) +
'a-' + rx + ' -' + ry + ' 0 0 1 -' + rx + ' -' + ry +
'z';
}
return d;
};
/**
* @param {String} svgCode SVG code
* @param {Object} options*
* @param {Number} floatPrecision Integer number
* @param {Boolean} strict Set strict mode
* @param {Boolean} fillBlack Add black fill to paths with no fill
* @param {Boolean} xmlTag
* @param {String} tint color
* @returns {Promise<string>}
*/
module.exports = function(svgCode, options) {
let floatPrecision = 2;
let strict = false;
let fillBlack = false;
let xmlTag = false;
let tint;
if (options) {
if (options.floatPrecision) {
floatPrecision = options.floatPrecision;
}
if (options.strict) {
strict = options.strict;
}
if (options.fillBlack) {
fillBlack = options.fillBlack;
}
if (options.xmlTag) {
xmlTag = options.xmlTag;
}
if (options.tint) {
tint = options.tint;
}
}
return new Promise((resolve, reject) => {
let data = parseSvg(svgCode);
if (data.error) {
reject(data.error);
}
else {
let xml = new JS2XML().convert(data, floatPrecision, strict, fillBlack, tint);
if (xmlTag) {
xml = '<?xml version="1.0" encoding="utf-8"?>\n' + xml;
}
resolve(xml);
}
});
};