├── .gitignore ├── LICENSE ├── index.js ├── package.json ├── rgbToHexa.js ├── testTemplate.js └── valueFormatter.js /.gitignore: -------------------------------------------------------------------------------- 1 | /pages 2 | /node_modules 3 | package-lock.json -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 hernan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const puppeteer = require('puppeteer'); 2 | var xml2js = require('xml2js'); 3 | var parseString = require('xml2js').parseString; 4 | // var parser = new xml2js.Parser({explicitArray: true, explicitChildren: true, preserveChildrenOrder: true}); 5 | // var parser = new xml2js.Parser(); 6 | var convert = require('xml-js'); 7 | var saveTemplate = require('./testTemplate'); 8 | var formatValue = require('./valueFormatter'); 9 | 10 | var http = require("http"), 11 | url = require("url"), 12 | path = require("path"), 13 | fs = require("fs") 14 | port = process.argv[2] || 8888; 15 | 16 | http.createServer(function(request, response) { 17 | 18 | var uri = url.parse(request.url).pathname 19 | , filename = path.join(process.cwd(), 'pages\\' + uri); 20 | 21 | var contentTypesByExtension = { 22 | '.html': "text/html", 23 | '.css': "text/css", 24 | '.js': "text/javascript" 25 | }; 26 | 27 | fs.exists(filename, function(exists) { 28 | console.log(filename) 29 | if(!exists) { 30 | response.writeHead(404, {"Content-Type": "text/plain"}); 31 | response.write("404 Not Found\n"); 32 | response.end(); 33 | return; 34 | } 35 | 36 | if (fs.statSync(filename).isDirectory()) filename += '/index.html'; 37 | 38 | fs.readFile(filename, "binary", function(err, file) { 39 | if(err) { 40 | response.writeHead(500, {"Content-Type": "text/plain"}); 41 | response.write(err + "\n"); 42 | response.end(); 43 | return; 44 | } 45 | 46 | var headers = {}; 47 | var contentType = contentTypesByExtension[path.extname(filename)]; 48 | if (contentType) headers["Content-Type"] = contentType; 49 | response.writeHead(200, headers); 50 | response.write(file, "binary"); 51 | response.end(); 52 | }); 53 | }); 54 | }).listen(parseInt(port, 10)); 55 | 56 | console.log("Static file server running at\n => http://localhost:" + port + "/\nCTRL + C to shutdown"); 57 | 58 | 59 | // Animation Parser 60 | const TOTAL_LOOPS = '1e10'; 61 | 62 | 63 | let parseFrame = (frameString) => { 64 | return new Promise((resolve, reject)=> { 65 | var options = {ignoreComment: true, alwaysChildren: true, compact: false, alwaysArray: true}; 66 | var result = convert.xml2js(frameString, options); 67 | resolve(result) 68 | }) 69 | } 70 | 71 | const getTotalFrames = async (page) => { 72 | const totalFrames = await page.evaluate(() => { 73 | return anim.totalFrames 74 | }) 75 | return totalFrames; 76 | } 77 | 78 | const getFrameRate = async (page) => { 79 | const frameRate = await page.evaluate(() => { 80 | return anim.frameRate 81 | }) 82 | return frameRate; 83 | } 84 | 85 | let getFrameData = async (page) => { 86 | let bodyHTML = await page.evaluate(() => { 87 | var container = document.getElementById('lottie') 88 | return container.innerHTML 89 | }) 90 | return bodyHTML 91 | } 92 | 93 | let goToFrame = async (page, frame) => { 94 | let bodyHTML = await page.evaluate((frame) => { 95 | anim.goToAndStop(frame, true) 96 | }, frame) 97 | } 98 | 99 | const emptyContent = (node) => { 100 | return { 101 | $: node.$ 102 | } 103 | } 104 | 105 | const getFrame = async (page) => { 106 | const bodyHTML = await getFrameData(page) 107 | const parsedFrame = await parseFrame(bodyHTML) 108 | return parsedFrame 109 | } 110 | 111 | const buildContainer = async (frameData) => { 112 | const container = { 113 | elements: [ 114 | { 115 | type: frameData.elements[0].type, 116 | name: frameData.elements[0].name, 117 | attributes: { 118 | xmlns: frameData.elements[0].attributes.xmlns, 119 | height: frameData.elements[0].attributes.height, 120 | width: frameData.elements[0].attributes.width, 121 | preserveAspectRatio: frameData.elements[0].attributes.preserveAspectRatio, 122 | viewBox: frameData.elements[0].attributes.viewBox, 123 | style: frameData.elements[0].attributes.style, 124 | }, 125 | elements: [{name:'style', type:'element', elements:[{type:'text', text:''}]}, ...frameData.elements[0].elements] 126 | } 127 | ] 128 | } 129 | return container 130 | } 131 | 132 | const buildElementsData = (frameData) => { 133 | const elementsData = {} 134 | const iterateElements = (elements, parentElement) => { 135 | elements.forEach(element => { 136 | if(element.attributes) { 137 | let attributeKeys = Object.keys(element.attributes) 138 | let attributes = attributeKeys.reduce((accumulator, key)=>{ 139 | accumulator[key] = { 140 | changed: false, 141 | values: [] 142 | } 143 | return accumulator 144 | }, {}) 145 | elementsData[element.attributes.__name] = { 146 | changed: false, 147 | attributes: attributes, 148 | node: element, 149 | parentNode: parentElement 150 | } 151 | if(element.elements) { 152 | iterateElements(element.elements, element) 153 | } 154 | } 155 | }) 156 | } 157 | return new Promise((resolve, reject) => { 158 | //I am iterating the third element of the container which contains all the frames. 159 | // First element is the styles tag, second is defs and third is the group with the clipping url 160 | iterateElements(frameData.elements[0].elements, frameData.elements[0]) 161 | resolve(elementsData) 162 | }) 163 | 164 | } 165 | 166 | const traverseFrames = async (page, totalFrames) => { 167 | let currentFrame = 0 168 | let frames = [] 169 | while(currentFrame < totalFrames) { 170 | await goToFrame(page, currentFrame) 171 | let parsedFrame = await getFrame(page) 172 | frames.push(parsedFrame.elements[0].elements) 173 | currentFrame += 1 174 | } 175 | return frames 176 | } 177 | 178 | const compareElements = async(elementsData, elements, index) => { 179 | const iterateElements = elements => { 180 | elements.forEach(element => { 181 | let name = element.attributes.__name 182 | if(name) { 183 | const elementData = elementsData[name] 184 | let attributes = element.attributes 185 | let keys = Object.keys(attributes) 186 | keys.forEach(key => { 187 | let exists = true 188 | if(!elementData.attributes[key]) { 189 | let attribute = { 190 | changed: false, 191 | values: [] 192 | } 193 | let i = 0 194 | while(i <= index) { 195 | attribute.values.push('') 196 | i += 1 197 | } 198 | elementData.attributes[key] = attribute 199 | exists = false 200 | } 201 | let values = elementData.attributes[key].values 202 | let previousValue = values.length ? values[values.length - 1] : null 203 | 204 | // Setting first non-empty value as default value for element 205 | if(!elementData.node.attributes[key] && attributes[key] !== '') { 206 | elementData.node.attributes[key] = attributes[key] 207 | } 208 | if(previousValue !== null 209 | && previousValue !== '' 210 | && previousValue !== attributes[key]) { 211 | elementData.attributes[key].changed = true; 212 | } 213 | elementData.attributes[key].values.push(attributes[key]) 214 | }) 215 | if(element.elements) { 216 | iterateElements(element.elements) 217 | } 218 | } 219 | }) 220 | } 221 | iterateElements(elements) 222 | } 223 | 224 | const includeFrames = async (elementsData, frames) => { 225 | 226 | frames.forEach((elements, index) => { 227 | compareElements(elementsData, elements, index) 228 | }) 229 | } 230 | 231 | const formatKey = (key) => { 232 | if(key === 'style') { 233 | return 'visibility'; 234 | } 235 | return key; 236 | } 237 | 238 | const buildKeyframes = (elementKey, changedAttributes) => { 239 | let keyframes = `@keyframes ${elementKey} {` 240 | keyframes += `\r\n` 241 | let properties = {} 242 | changedAttributes.forEach(attribute => { 243 | let values = attribute.values 244 | let attributeKey = attribute.key 245 | values.forEach((value, index) => { 246 | if(index > 0 && values[index - 1] === value && values[index + 1] === value) { 247 | // We are skipping 248 | } else { 249 | let percKey = Math.floor(index/(values.length-1) * 10000) 250 | if(!properties[percKey]) { 251 | properties[percKey] = { 252 | value: '', 253 | 254 | } 255 | } 256 | let currentProperty = properties[percKey]; 257 | currentProperty.value += `${formatKey(attributeKey)}:${formatValue(attributeKey, value)};` 258 | } 259 | }) 260 | }) 261 | let percentKeys = Object.keys(properties).sort((a,b)=>a-b) 262 | percentKeys.forEach(key => { 263 | keyframes += `${key / 100}% {` 264 | keyframes += `${properties[key].value}` 265 | keyframes += '}' 266 | keyframes += `\r\n` 267 | }) 268 | keyframes += '}' 269 | keyframes += `\r\n` 270 | return keyframes 271 | } 272 | 273 | const buildPaths = (values, elementName, animationDuration, stylesTextElement, extraAttributes) => { 274 | values = values.map((path, index)=>{ 275 | return { 276 | path: path, 277 | index: index, 278 | lastIndex: index, 279 | canDelete: false, 280 | total: values.length 281 | } 282 | }) 283 | 284 | let lastData 285 | values.forEach((data, index) => { 286 | if(lastData && data.path === lastData.path) { 287 | lastData.lastIndex = index 288 | data.canDelete = true 289 | } else { 290 | lastData = data 291 | } 292 | }) 293 | values = values.filter((data)=>!data.canDelete) 294 | const paths = values.map((value, index) => { 295 | const pathName = elementName + '__' + index 296 | const animationValues = [] 297 | let i = 0 298 | while (i < value.total) { 299 | if (i < value.index || i > value.lastIndex) { 300 | animationValues.push('hidden') 301 | } else { 302 | animationValues.push('inherit') 303 | } 304 | i += 1 305 | } 306 | 307 | const animationData = [{ 308 | key: 'visibility', 309 | values: animationValues 310 | }] 311 | stylesTextElement.text += buildKeyframes(pathName, animationData) 312 | return { 313 | name: 'path', 314 | type: 'element', 315 | attributes: { 316 | d: formatValue('d--static', value.path), 317 | style: `animation: ${pathName} ${animationDuration}s steps(1) ${TOTAL_LOOPS};`, 318 | ...extraAttributes 319 | } 320 | } 321 | }) 322 | return paths 323 | } 324 | 325 | const createAnimations = async (elementsData, animationDuration, stylesTextElement) => { 326 | const keys = Object.keys(elementsData) 327 | keys.forEach(elementKey => { 328 | const elementData = elementsData[elementKey] 329 | const node = elementData.node 330 | const attributeKeys = Object.keys(elementData.attributes) 331 | let hasAnimation = false 332 | const changedAttributes = [] 333 | const pathAttribute = [] 334 | attributeKeys.forEach(attributeKey => { 335 | const attribute = elementData.attributes[attributeKey] 336 | if(attribute.changed) { 337 | // If targetting Chrome, this first condition is not needed because it supports css path values 338 | if (attributeKey === 'd' && false) { 339 | const parentNode = elementData.parentNode 340 | let extraAttributes = {} 341 | if(parentNode && parentNode.name === 'clipPath') { 342 | if(node.attributes['clip-rule'] && node.attributes['clip-rule'] !== 'nonzero') { 343 | extraAttributes['clip-rule'] = node.attributes['clip-rule'] 344 | } 345 | parentNode.elements = buildPaths(attribute.values, elementKey, animationDuration, stylesTextElement, extraAttributes) 346 | } else { 347 | node.name = 'g' 348 | node.elements = buildPaths(attribute.values, elementKey, animationDuration, stylesTextElement, extraAttributes) 349 | } 350 | } else { 351 | changedAttributes.push({ 352 | key: attributeKey, 353 | values: attribute.values 354 | }) 355 | hasAnimation = true 356 | } 357 | } 358 | }) 359 | if(hasAnimation) { 360 | let keyframes = buildKeyframes(elementKey, changedAttributes) 361 | stylesTextElement.text += keyframes 362 | changedAttributes.forEach(attribute => { 363 | node.attributes[attribute.key] = null 364 | }) 365 | // node.attributes.style = `animation: ${elementKey} ${animationDuration}s steps(1) ${TOTAL_LOOPS};` 366 | // node.attributes.style += `animation-fill-mode: both;` 367 | // If we set animation properties as global, this is the only property that the animation needs: 368 | node.attributes.style = `animation-name: ${elementKey};` 369 | } 370 | }) 371 | stylesTextElement.text += '\r\n' 372 | stylesTextElement.text += `*{animation-duration:${animationDuration}s;animation-timing-function:steps(1);animation-iteration-count: infinite;}` 373 | } 374 | 375 | const startAnimation = async(page) => { 376 | return new Promise(async (resolve, reject) => { 377 | 378 | await page.evaluate(() => { 379 | loadAnimation() 380 | }) 381 | let intervalId = setInterval(async()=>{ 382 | let isLoaded = await page.evaluate(() => { 383 | return isLoaded 384 | }) 385 | if(isLoaded) { 386 | clearInterval(intervalId) 387 | resolve() 388 | } 389 | }, 100) 390 | }) 391 | } 392 | 393 | const cleanUnneededAttributes = (elementsData) => { 394 | let elementsKeys = Object.keys(elementsData) 395 | elementsKeys.forEach(key => { 396 | let element = elementsData[key] 397 | let node = element.node 398 | let attributesKeys = Object.keys(node.attributes) 399 | attributesKeys.forEach(attributeKey => { 400 | if (attributeKey === '__name' 401 | || attributeKey === 'style' && node.attributes[attributeKey] === 'display: block;' 402 | || attributeKey === 'stroke-linejoin' && node.attributes[attributeKey] === 'miter' 403 | || attributeKey === 'stroke-linecap' && node.attributes[attributeKey] === 'butt' 404 | || attributeKey === 'opacity' && node.attributes[attributeKey] === '1' 405 | || attributeKey === 'fill-opacity' && node.attributes[attributeKey] === '1' 406 | || attributeKey === 'stroke-opacity' && node.attributes[attributeKey] === '1' 407 | ) { 408 | node.attributes[attributeKey] = null; 409 | } else if (attributeKey === 'd' && node.attributes[attributeKey]) { 410 | node.attributes[attributeKey] = formatValue(attributeKey + '--static', node.attributes[attributeKey]) 411 | } else if (attributeKey === 'transform' && node.attributes[attributeKey]) { 412 | node.attributes[attributeKey] = formatValue(attributeKey, node.attributes[attributeKey]) 413 | } else if (attributeKey === 'style' && node.attributes[attributeKey] === 'display: none;') { 414 | node.name = null 415 | node.type = 'text' 416 | node.text = '' 417 | } else if (attributeKey === 'fill-opacity' && node.attributes[attributeKey] === '0') { 418 | node.attributes[attributeKey] = null 419 | node.attributes['fill'] = 'none' 420 | } 421 | }) 422 | }) 423 | } 424 | 425 | (async () => { 426 | try { 427 | const browser = await puppeteer.launch(); 428 | const page = await browser.newPage(); 429 | await page.goto('http://localhost:8888/index.html', {waitUntil: 'networkidle2'}); 430 | await startAnimation(page) 431 | const totalFrames = await getTotalFrames(page) 432 | const frameRate = await getFrameRate(page) 433 | const initialFrameData = await getFrame(page) 434 | const container = await buildContainer(initialFrameData) 435 | const elementsData = await buildElementsData(container) 436 | const frames = await traverseFrames(page, totalFrames) 437 | const content = await includeFrames(elementsData, frames) 438 | await createAnimations(elementsData, Math.ceil(1000 * frames.length / frameRate) / 1000, container.elements[0].elements[0].elements[0]) 439 | await cleanUnneededAttributes(elementsData) 440 | var result = convert.js2xml(container, {}); 441 | saveTemplate(result) 442 | // console.log(result) 443 | await browser.close(); 444 | } catch(err) { 445 | console.log(err) 446 | } 447 | })(); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lottie-to-svg", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "jsdom": "^13.0.0", 13 | "lottie-web": "^5.4.1", 14 | "puppeteer": "^1.10.0", 15 | "xml-js": "^1.6.8", 16 | "xml2js": "^0.4.19" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /rgbToHexa.js: -------------------------------------------------------------------------------- 1 | var rgbToHex = function (rgb) { 2 | var hex = Number(rgb).toString(16); 3 | if (hex.length < 2) { 4 | hex = "0" + hex; 5 | } 6 | return hex; 7 | }; 8 | 9 | var fullColorHex = function(r,g,b) { 10 | var red = rgbToHex(r); 11 | var green = rgbToHex(g); 12 | var blue = rgbToHex(b); 13 | return '#'+red+green+blue; 14 | }; 15 | 16 | module.exports = function(rgbString) { 17 | let colors = rgbString.split(/\(|\)/)[1].split(',') 18 | return fullColorHex(colors[0], colors[1], colors[2]) 19 | } -------------------------------------------------------------------------------- /testTemplate.js: -------------------------------------------------------------------------------- 1 | var fs = require("fs") 2 | 3 | let saveTemplate = (svgData) => { 4 | let template = ` 5 | 6 | 7 | 8 | 28 | 29 | 30 | 31 | 32 |
33 | ${svgData} 34 |
35 | 36 | ` 37 | fs.writeFile('pages/test.html', template, function(err, data){ 38 | if (err) console.log(err); 39 | console.log("Successfully Written HTML File."); 40 | }); 41 | fs.writeFile('pages/anim.svg', svgData, function(err, data){ 42 | if (err) console.log(err); 43 | console.log("Successfully Written SVG File."); 44 | }); 45 | } 46 | 47 | 48 | module.exports = saveTemplate -------------------------------------------------------------------------------- /valueFormatter.js: -------------------------------------------------------------------------------- 1 | var rgbToHexa = require('./rgbToHexa'); 2 | 3 | const formatPath = (path) => { 4 | let pathParts = path.split(' ') 5 | simplifiedPaths = pathParts.map(path => { 6 | let parts = path.split(',') 7 | return parts.map(item=>{ 8 | const parts = /([M|C])?([\-0-9.]+)(z)?/g.exec(item) 9 | if(!parts) { 10 | return item 11 | } 12 | let formattedItem = '' 13 | if(parts[1]) { 14 | formattedItem += parts[1] 15 | } 16 | if(parts[2]) { 17 | formattedItem += Math.ceil(Number(parts[2]) * 100) / 100 18 | } 19 | if(parts[3]) { 20 | formattedItem += parts[3] 21 | } 22 | return formattedItem 23 | 24 | //([M|C])?([\-0-9])+(z)? 25 | }).join(',') 26 | }) 27 | return simplifiedPaths.join(' ') 28 | } 29 | 30 | const formatDashArray = (value) => { 31 | return value.split(' ').map(item=>Math.ceil(item*100)/100).join(' ') 32 | } 33 | 34 | const formatMatrix = (value) => { 35 | if(value) { 36 | let matrixNumbers = value.split(/\(|\)/)[1].split(',') 37 | let roundedValues = matrixNumbers.map(matrixNumber=>Math.ceil(matrixNumber*100)/100) 38 | let formattedValue = 'matrix(' + roundedValues.join(',') + ')' 39 | value = formattedValue 40 | } else { 41 | value = 'scale(1)' 42 | } 43 | return value 44 | } 45 | 46 | module.exports = function(type, value) { 47 | if (type === 'transform') { 48 | value = formatMatrix(value) 49 | } else if (type === 'style') { 50 | value = value === 'display: none;' ? 'hidden' : 'inherit'; 51 | } else if (type === 'stroke-dasharray') { 52 | value = formatDashArray(value); 53 | } else if (type === 'opacity' 54 | || type === 'fill-opacity' 55 | || type === 'stroke-opacity' 56 | || type === 'stroke-width' 57 | || type === 'stroke-dashoffset') { 58 | value = Math.ceil(value*100)/100; 59 | } else if (type === 'fill' || type === 'stroke') { 60 | if(value) { 61 | value = rgbToHexa(value); 62 | } 63 | } else if (type === 'd') { 64 | value = `path("${formatPath(value)}")`; 65 | } else if (type === 'd--static') { 66 | value = formatPath(value); 67 | } 68 | return value; 69 | } --------------------------------------------------------------------------------