├── .gitignore
├── package.json
├── rgbToHexa.js
├── LICENSE
├── testTemplate.js
├── valueFormatter.js
└── index.js
/.gitignore:
--------------------------------------------------------------------------------
1 | /pages
2 | /node_modules
3 | package-lock.json
--------------------------------------------------------------------------------
/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 | }
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | }
--------------------------------------------------------------------------------
/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 | })();
--------------------------------------------------------------------------------