I’m using html2canvas to render HTML as an image.
I’m running into issues when the element I need to convert contains an img whose source is an external domain.
I think the issue could be that the library internally sets crossOrigin = 'anonymous' on cloned img elements.
Below is a summary of how I’ve managed to avoid CORS-related issues when rendering the img elements by converting them to base64 server-side, what I think could be the current roadblock, and questions regarding next steps.
This is part of a React/Next project.
Avoiding CORS issues by loading the images server-side:
To avoid CORS issues, I’m using a proxy server to handle fetching the image and converting it to a base64 value that can be used as the img source.
An example of this can be found in the associated library html2canvas-proxy-nodejs. The code for this library is fairly simple, and included below to give you an idea of what html2canvas expects the proxy to do:
const express = require('express');
const url = require('url');
const cors = require('cors');
const request = require('request');
function validUrl(req, res, next) {
if (!req.query.url) {
next(new Error('No url specified'));
} else if (typeof req.query.url !== 'string' || url.parse(req.query.url).host === null) {
next(new Error(`Invalid url specified: ${req.query.url}`));
} else {
next();
}
}
module.exports = () => {
const app = express.Router();
app.get('/', cors(), validUrl, (req, res, next) => {
switch (req.query.responseType) {
case 'blob':
req.pipe(request(req.query.url).on('error', next)).pipe(res);
break;
case 'text':
default:
request({url: req.query.url, encoding: 'binary'}, (error, response, body) => {
if (error) {
return next(error);
}
res.send(
`data:${response.headers['content-type']};base64,${Buffer.from(
body,
'binary'
).toString('base64')}`
);
});
}
});
return app;
};
I originally tried using html2canvas-proxy-nodejs, but it constantly resulted in CORS issues even though it’s supposed to resolve them. Instead, I wrote my own server-side implementation that accepts a URL, converts it to a base64 string, and returns the value as follows:
res.status(200).json(`data:image/png;base64,${response}`)
I’ve tested my server-side proxy and confirmed that the response is able to be used in an img element without any issues:
const response = await fetch(proxyRoute)
const src = await response.json()
return <img src = {src} />
Using the server-side proxy with the library:
I can’t get the proxy to work correctly with html2canvas.
Below is an example of how I’m attempting to do this. The ref represents the element I’m attempting to convert to an image, and it contains at least one img element within it:
// Other options being set are width, height, and backgroundColor. Excluded here.
const options = { proxy: proxyRoute, ... }
const canvas = await html2canvas(ref?.current, options)
const src = canvas.toDataURL('image/png', 1.0)
When I attempt the above, the element is rendered without any issue except for the fact that space occupied by the img element within the overall element is left blank. The following error appears in the console:
550ms Error loading image https://link.toExternalDomain.jpeg
There’s no other information.
If I look in Chrome’s Network tab after attempting to render the HTML as an image, I can see various assets that html2canvas as the Initiator attempted to load. The content of the img element is there with a 200 status, and I can view the image in the Network Preview tab. Just to note, the Network tab lists the Type of that image as webp, but it doesn’t make a difference whether or not I change image/png above to image/webp.
crossOrigin = ‘anonymous’, the potential problem:
I looked through the library’s source code to try to find how and when it’s using the proxy, and found that it’s used here:
private async loadImage(key: string) {
const isSameOrigin = CacheStorage.isSameOrigin(key);
const useCORS =
!isInlineImage(key) && this._options.useCORS === true && FEATURES.SUPPORT_CORS_IMAGES && !isSameOrigin;
const useProxy =
!isInlineImage(key) &&
!isSameOrigin &&
!isBlobImage(key) &&
typeof this._options.proxy === 'string' &&
FEATURES.SUPPORT_CORS_XHR &&
!useCORS;
if (
!isSameOrigin &&
this._options.allowTaint === false &&
!isInlineImage(key) &&
!isBlobImage(key) &&
!useProxy &&
!useCORS
) {
return;
}
// Here's where my proxy is being called. I'm not passing the useCORS prop.
let src = key;
if (useProxy) {
src = await this.proxy(src);
}
this.context.logger.debug(`Added image ${key.substring(0, 256)}`);
return await new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
//ios safari 10.3 taints canvas with data urls unless crossOrigin is set to anonymous
if (isInlineBase64Image(src) || useCORS) {
img.crossOrigin = 'anonymous';
}
img.src = src;
if (img.complete === true) {
// Inline XML images may fail to parse, throwing an Error later on
setTimeout(() => resolve(img), 500);
}
if (this._options.imageTimeout > 0) {
setTimeout(
() => reject(`Timed out (${this._options.imageTimeout}ms) loading image`),
this._options.imageTimeout
);
}
});
}
The only thing I can imagine might be causing the issue is this portion:
//ios safari 10.3 taints canvas with data urls unless crossOrigin is set to anonymous
if (isInlineBase64Image(src) || useCORS) {
img.crossOrigin = 'anonymous';
}
I’m not passing useCORS, but isInlineBase64Image(src) will be true:
const INLINE_BASE64 = /^data:image/.*;base64,/i;
const isInlineBase64Image = (src: string): boolean => INLINE_BASE64.test(src);
In an attempt to confirm this, I manually added crossOrigin = 'anonymous' to the img elements in my project, and they wouldn’t load correctly.
The error message in the console was the exact same error message I saw when passing the useCORS prop to html2canvas:
Access to fetch at ‘link.toExternalDomain.jpeg’ from origin ‘localhost:8080’
has been blocked by CORS policy: No ‘Access-Control-Allow-Origin’
header is present on the requested resource. If an opaque response
serves your needs, set the request’s mode to ‘no-cors’ to fetch the
resource with CORS disabled.
The FetchEvent for “link.toExternalDomain.jpeg” resulted in a network error
response: the promise was rejected.
For reference, here’s the description of the useCORS prop:
useCORS: Whether to attempt to load images from a server using CORS
Conclusion:
Am I correct that the reason for the error message is that the library is setting crossOrigin = 'anonymous'?
Here’s the fairly unhelpful error message again, for reference:
550ms Error loading image https://link.toExternalDomain.jpeg
If I’m correct, how can I get around this? If that line really is the issue, one “solution” is to fork the library and comment that out, but assuming the author is correct that Safari taints the canvas otherwise, it seems like it would just create another issue further downstream.
If I’m not correct, what actually is the problem?
I attempted using other libraries such as html-to-image and modern-screenshot. Neither of these libraries worked as well as html2canvas in general. Most importantly, they both had similar CORS issues and provided no methods similar to the proxy prop of html2canvas to resolve the issues.