My ultimate goal is that I'm trying to convert an animated SMIL SVG into an APNG file. I have found no easy way to do this, and so I'm doing something a roundabout: I've written a node.js + express.js app that hosts a simple backend to get svg images on my local filesystem, and I've written a vue.js app that will go and pull those images and render them on a google chrome browser. I then play the SVG and try to capture rendered "frames", and save those frames as static png files (about 30 static PNG files for each second of SVG animation). I then plan to take those static png files & convert them over to a single animated png / apng file using another program. The part that I'm stuck on: actually trying to capture a rasterized "frame" of the svg.
Here's a snippet of code from my vue.js app which requests an SVG file, and renders it to a div, and then it tries to call a function takeSnap()
.
const file = await RequestsService.getFile(i);
const div = document.getElementById("svgContainer");
div.innerHTML = file.svg;
const { width, height } = div.children[0].getBBox();
console.debug(`width: ${width}, height: ${height}`);
const svg = div.children[0];
await svg.pauseAnimations();
let time = 0.0;
const interval = 1.0 / numFrames; // interval in seconds.
let count = 0;
while (time < file.duration) {
console.log(`time=${time}`);
await svg.setCurrentTime(time);
await this.takeSnap(svg, width, height);
time += interval;
console.debug(`file: ${file.fileName}_${count}`);
}
await svg.setCurrentTime(file.duration);
await this.takeSnap(svg, width, height);
I haven't been able to make a proper implementation of takeSnap()
. I know that there are a slew of tools such as Canvg or HTML2png that go and directly render a webpage from the DOM. I've tried many different libraries, but none of them seem to be able to correctly render the frame of the SVG that chrome is correctly rendering. I don't blame the libraries: going from animated SVG XML file to actually rasterized pixels is a very difficult problem I think. But Chrome can do it, and what I'm wondering is... can I capture the browser engine output of chrome somehow?
Is there a way that I can get the rasterized pixel data produced by the blink browser engine in chrome & then save that rasterized pixel data into a png file? I know that I'll lose the transparency data of the SVG, but that's okay, I'll work around that later.
OK, this got a bit complicated. The script can now take SMIL animations with both <animate>
and <animateTransform>
. Essentially I take a snap shot of the SVG using Window.getComputedStyle() (for <animate>
elements) and the matrix value using SVGAnimatedString.animVal (for <animateTransform>
elements). A copy of the SVG is turned into a data URL and inserted into a <canvas>
. From here it is exported as a PNG image.
In this example I use a data URL in the fetch function, but this can be replaced by a URL. The script has been tested with the SVG that OP provided.
var svgcontainer, svg, canvas, ctx, output, interval;
var num = 101;
const nsResolver = prefix => {
var ns = {
'svg': 'http://www.w3.org/2000/svg',
'xlink': 'http://www.w3.org/1999/xlink'
};
return ns[prefix] || null;
};
const takeSnap = function() {
// get all animateTransform elements
let animateXPath = document.evaluate('//svg:*[svg:animateTransform]', svg, nsResolver, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
// store all animateTransform animVal.matrix in a dataset attribute
Object.keys([...Array(animateXPath.snapshotLength)]).forEach(i => {
let node = animateXPath.snapshotItem(i);
let mStr = [...node.transform.animVal].map(animVal => {
let m = animVal.matrix;
return `matrix(${m.a} ${m.b} ${m.c} ${m.d} ${m.e} ${m.f})`;
}).join(' ');
node.dataset.transform = mStr;
});
// get all animate elements
animateXPath = document.evaluate('//svg:animate', svg, nsResolver, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
// store all animate properties in a dataset attribute on the target for the animation
Object.keys([...Array(animateXPath.snapshotLength)]).forEach(i => {
let node = animateXPath.snapshotItem(i);
let propName = node.getAttribute('attributeName');
let target = node.targetElement;
let computedVal = getComputedStyle(target)[propName];
target.dataset[propName] = computedVal;
});
// create a copy of the SVG DOM
let parser = new DOMParser();
let svgcopy = parser.parseFromString(svg.outerHTML, "application/xml");
// find all elements with a dataset attribute
animateXPath = svgcopy.evaluate('//svg:*[@*[starts-with(name(), "data")]]', svgcopy, nsResolver, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
// copy the animated property to a style or attribute on the same element
Object.keys([...Array(animateXPath.snapshotLength)]).forEach(i => {
let node = animateXPath.snapshotItem(i);
// for each data-
for (key in node.dataset) {
if (key == 'transform') {
node.setAttribute(key, node.dataset[key]);
} else {
node.style[key] = node.dataset[key];
}
}
});
// find all animate and animateTransform elements from the copy document
animateXPath = svgcopy.evaluate('//svg:*[starts-with(name(), "animate")]', svgcopy, nsResolver, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
// remove all animate and animateTransform elements from the copy document
Object.keys([...Array(animateXPath.snapshotLength)]).forEach(i => {
let node = animateXPath.snapshotItem(i);
node.remove();
});
// create a File object
let file = new File([svgcopy.rootElement.outerHTML], 'svg.svg', {
type: "image/svg+xml"
});
// and a reader
let reader = new FileReader();
reader.addEventListener('load', e => {
/* create a new image assign the result of the filereader
to the image src */
let img = new Image();
// wait got load
img.addEventListener('load', e => {
// update canvas with new image
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = 'white';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(e.target, 0, 0);
// create PNG image based on canvas
let img = new Image();
img.src = canvas.toDataURL("image/png");
output.append(img);
//let a = document.createElement('A');
//a.textContent = `Image-${num}`;
//a.href = canvas.toDataURL("image/png");
//a.download = `Image-${num}`;
//num++;
//output.append(a);
});
img.src = e.target.result;
});
// read the file as a data URL
reader.readAsDataURL(file);
};
document.addEventListener('DOMContentLoaded', e => {
svgcontainer = document.getElementById('svgcontainer');
canvas = document.getElementById('canvas');
output = document.getElementById('output');
ctx = canvas.getContext('2d');
fetch('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMCAxMCI+CiAgPHJlY3QgeD0iMCIgeT0iMCIgd2lkdGg9IjQiIGhlaWdodD0iNCIgZmlsbD0ibmF2eSI+CiAgICA8YW5pbWF0ZSBhdHRyaWJ1dGVOYW1lPSJmaWxsIiBkdXI9IjNzIiBrZXlUaW1lcz0iMDsuMTsuMjsxIiB2YWx1ZXM9Im5hdnk7bGlnaHRibHVlO2JsdWU7bmF2eSIvPgogICAgPGFuaW1hdGVUcmFuc2Zvcm0gYWRkaXRpdmU9InN1bSIgYXR0cmlidXRlTmFtZT0idHJhbnNmb3JtIiBkdXI9IjNzIiBrZXlUaW1lcz0iMDsuNTsxIiB0eXBlPSJ0cmFuc2xhdGUiIHZhbHVlcz0iMCwwOzIsMjs0LDYiLz4KICAgIDxhbmltYXRlVHJhbnNmb3JtIGFkZGl0aXZlPSJzdW0iIGF0dHJpYnV0ZU5hbWU9InRyYW5zZm9ybSIgZHVyPSIzcyIga2V5VGltZXM9IjA7LjU7MSIgdHlwZT0icm90YXRlIiB2YWx1ZXM9IjAsMiwyOzMwLDIsMjstMTAsMiwyOyIvPgogIDwvcmVjdD4KPC9zdmc+CgoK').then(res => res.text()).then(text => {
let parser = new DOMParser();
let svgdoc = parser.parseFromString(text, "application/xml");
canvas.width = svgdoc.rootElement.getAttribute('width');
canvas.height = svgdoc.rootElement.getAttribute('height');
svgcontainer.innerHTML = svgdoc.rootElement.outerHTML;
svg = svgcontainer.querySelector('svg');
// set interval
interval = setInterval(takeSnap, 50);
// get all
let animateXPath = document.evaluate('//svg:*[starts-with(name(), "animate")]', svg, nsResolver, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
let animationArr = Object.keys([...Array(animateXPath.snapshotLength)]).map(i => {
let node = animateXPath.snapshotItem(i);
return new Promise((resolve, reject) => {
node.addEventListener('endEvent', e => {
resolve();
});
});
});
Promise.all(animationArr).then(value => {
clearInterval(interval);
});
});
});
<div style="display:flex">
<div id="svgcontainer"></div>
<canvas id="canvas" width="200" height="200"></canvas>
</div>
<p>Exported PNGs:</p>
<div id="output"></div>