Converting SVG To Image (PNG, Webp, etc.) in JavaScript


Photo by Justin Aikin on Unsplash
​Last Monday I built and published a new Web Component, developed with StencilJS, to generate social images dynamically in the browser.

For a given text and logo, the component renders a shadowed SVG that can be converted to an image (PNG, Webp, etc.), and does so without any third party dependencies.

This is how it works.

| Note: Code snippets are written in TypeScript.
​SVG
To create the dynamic SVG, I used a ​foreignObject​ to embed the text with an HTML paragraph (​<p/>​) and a graphical ​image​ element.
ForeignObject​
I could have used ​<text/>​ elements to draw graphics instead of HTML elements, however the feature needs to support dynamic inputs that can be too long and might need to be truncated and displayed with three ending dots ​...​.

This is something I found easier to implement with CSS rather than with JavaScript. <text/>​ are not paragraphs but lines.
<svg> {this.text && ( <foreignObject> <p>{this.text}</p> </foreignObject> )} </svg>

The -webkit-line-clamp CSS property allows shrinking the content of block containers to the specified number of lines.
p { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
Image​
Unlike the text, to embed the image, I had to use a graphical <image/>​ element.
<svg> {this.imgBase64 && this.imgMimeType && ( <image x="500" y="1000" width="64" height="64" href={`data:${this.imgMimeType};base64,${this.imgBase64}`} /> )} </svg>
Using a ​foreignObject ​with an HTML element ​<img/>​ would have been possible for rendering purposes but, I did not find a way to ultimately export it to the resulting image.

For the same reason, I was also not able to render the image directly from a URL (​href="https://..."​) and had to first transform it to a ​base64​ string.

Only this way the image is rendered and can be included in the converted image output.
export const fetchImage = async ({imgSrc}: {imgSrc: string}): Promise<string | undefined> => { const data: Response = await fetch(imgSrc); const blob: Blob = await data.blob(); const base64: string = await toBase64({blob}); return base64.split(',')?.[1]; }; const toBase64 = ({blob}: {blob: Blob}): Promise<string> => { return new Promise<string>((resolve, reject) => { try { const reader: FileReader = new FileReader(); reader.onloadend = () => { const {result} = reader; resolve(result as string); }; reader.readAsDataURL(blob); } catch (err) { reject(err); } }); };
In the above code snippet, the ​imgSrc​ is the URL to the image -- the logo -- that should be embedded. It is first fetched, then transformed to a ​blob​ and finally converted to a ​base64​ string.
Convert To Image​
Basically, the conversion process happens in two steps:
  • ​SVG to Canvas
  • Canvas to Image (Blob)
Translated to code, these steps can be chained in a function.
@Method() async toBlob(type: string = 'image/webp'): Promise<Blob> { const canvas: HTMLCanvasElement = await svgToCanvas({svg: this.svgRef}); return canvasToBlob({canvas, type}); }
​As you may notice, the above method defines a default mime type (image/webp​) for the export. According to my tests, it also works for other format such as image/png​ and ​image/jpg​.
​SVG To Canvas
In one of my previous works (a Figma plugin) I already developed a function that convert SVG to ​HTMLCanvasElement​.
export const transformCanvas = ({index}: Frame): Promise<SvgToCanvas | undefined> => { return new Promise<SvgToCanvas | undefined>((resolve) => { const svg: SVGGraphicsElement | null = document.querySelector(`div[frame="${index}"] svg`); if (!svg) { resolve(undefined); return; } const {width, height} = svgSize(svg); const blob: Blob = new Blob([svg.outerHTML], {type: 'image/svg+xml;charset=utf-8'}); const blobURL: string = URL.createObjectURL(blob); const image = new Image(); image.onload = () => { const canvas: HTMLCanvasElement = document.createElement('canvas'); canvas.width = width; canvas.height = height; const context: CanvasRenderingContext2D | null = canvas.getContext('2d'); context?.drawImage(image, 0, 0, width, height); URL.revokeObjectURL(blobURL); resolve({ canvas, index }); }; image.src = blobURL; }); };

At first, I had the feeling it would be a piece of cake to re-implement the exact same above function. Unfortunately, "feeling" was the only valid keyword in that sentence 😂.

The first issue I faced was related to the conversion of the SVG to Blob. In the previous method, it converts it using the SVG value and an object URL.
const blob: Blob = new Blob([svg.outerHTML], {type: 'image/svg+xml;charset=utf-8'}); const blobURL: string = URL.createObjectURL(blob);

​However, in my component, using that approach threw an exception at runtime.

​> Security Error: Tainted canvases may not be exported.

I had no other choice than finding another way to instantiate the Image​ object which, fortunately, was possible by using another serialization method.
const base64SVG: string = window.btoa(new XMLSerializer().serializeToString(svg)); const imgSrc: string = `data:image/svg+xml;base64,${base64SVG}`;

Unfortunately, even if the function threw no compilation nor runtime error, it was not yet ready. Indeed, no text was exported in the resulting canvas.

After some "die and retry" research, I figured out that the ​foreignObject​ content needs CSS styles to be ​inlined​ when exporting.

To solve this in a relatively dynamical way, I implemented another function that replicates all CSS styles of the original text element to a clone.
const inlineStyle = ({clone, style}: {clone: SVGGraphicsElement; style: CSSStyleDeclaration}) => { const text: HTMLParagraphElement | null = clone.querySelector('foreignObject > p'); if (!text) { return; } for (const key of Object.keys(style)) { text.style.setProperty(key, style[key]); } };
​ Finally, the transform from SVG to canvas worked out.
export const svgToCanvas = ({svg, style}: {svg: SVGGraphicsElement; style: CSSStyleDeclaration}): Promise<HTMLCanvasElement> => { return new Promise<HTMLCanvasElement>(async (resolve) => { const {width, height} = svgSize(svg); const clone: SVGGraphicsElement = svg.cloneNode(true) as SVGGraphicsElement; inlineStyle({clone, style}); const base64SVG: string = window.btoa(new XMLSerializer().serializeToString(clone)); const imgSrc: string = `data:image/svg+xml;base64,${base64SVG}`; const image = new Image(); image.crossOrigin = 'anonymous'; image.onload = () => { const canvas: HTMLCanvasElement = document.createElement('canvas'); canvas.width = width; canvas.height = height; const context: CanvasRenderingContext2D | null = canvas.getContext('2d'); context?.drawImage(image, 0, 0, width, height); resolve(canvas); }; image.src = imgSrc; }); };
As I modified its declaration, I also had to change the caller in order to find the style of the text element.
@Method() async toBlob(type: string = 'image/webp'): Promise<Blob> { const style: CSSStyleDeclaration | undefined = this.textRef ? getComputedStyle(this.textRef) : undefined; const canvas: HTMLCanvasElement = await svgToCanvas({svg: this.svgRef, style}); return canvasToBlob({canvas, type}); }
​Canvas To Image (Blob)
Converting the canvas to an image results in a blob. In my original solution, I implemented that transformation with the help of the ​fetch​ API. It’s clean and concise.
export const canvasToBlob = async ({canvas, type}: {canvas: HTMLCanvasElement; type: string}): Promise<Blob> => { const dataUrl: string = canvas.toDataURL(type); return (await fetch(dataUrl)).blob(); };
However, once again you might say 😅, I discovered an issue at runtime when I deployed my application.

That approach requires enabling ​data:​ in the ​connect-src​ rule of the content security policy (CSP) which is strongly discouraged.

Fortunately, there is another way to convert a canvas to a blob, the built-in toBlob() method that accept a ​callback​ as argument.
export const canvasToBlob = async ({canvas, type}: {canvas: HTMLCanvasElement; type: string}): Promise<Blob> => { return new Promise<Blob>((resolve) => canvas.toBlob((blob: Blob) => resolve(blob), type)); };
​Conclusion
Sometimes development takes a bit more time than excepted, it took me a day to develop, solve all issues, test and publish the component, I am glad I did it.

Not only it resolves a feature I needed to (among others) publish this blog post, but I learned quite a few new tricks along the way.

Merry Christmas 🎄
David


🔥 ​Special Notes
This blog post may very well be the first ever article that was both published and fully edited on a blockchain network.

While we were porting DeckDeckGo to DFINITY’s Internet Computer, we needed to write down notes.

As we are geeky engineers, in addition to slides (DeckDeckGo’s core feature), we though we could extend our platform to support documents too.

That's why we are developing (from scratch) a new rich text editor which saves articles offline or online on the IC and shares the rendered documents as Progressive Web Apps on the same blockchain network.

Today (Dec. 23, 2021) I used that solution to write and share this blog post.
Needless to say, it was a successful test 🥳.

​Keep In Touch
To follow our adventure, you can star and watch our GitHub repo ⭐️ and sign up to join the list of beta tester.