javascriptcanvascors

Drawing new Image() onto canvas and using canvas.toDataURL()


I'm playing around on a side project trying to get a button that screenshots an element. I followed the code at Rendering HTML elements to <canvas> to render the element into a canvas, then canvas.toDataURL() to create the image.

I'm getting this error: Uncaught SecurityError: Failed to execute 'toDataURL' on 'HTMLCanvasElement': Tainted canvases may not be exported.

I've read in various posts here that it's a problem when loading an image file, or when working on localhost. However I'm getting the error on Netlify as well, in all browers except Firefox (this is expected locally). One suggestion was to set the crossorigin attribute to anonymous which I've done, and it has no effect.

I've read in a couple of places, including this post that I need to set headers on the image. As it's coming from new Image() in JS can I set those headers for the whole site safely? Will it fix the problem even if I can?

The relevant part of my code:

    function renderHtmlToCanvas(canvas, html) {
        const ctx = canvas.getContext('2d');

        const svg = `
            <svg xmlns="http://www.w3.org/2000/svg" width="${canvas.width}" height="${canvas.height}">
            <foreignObject width="100%" height="100%">
                    <div xmlns="http://www.w3.org/1999/xhtml">${html}</div>
            </foreignObject>
            </svg>`;

        const svgBlob = new Blob([svg], { type: 'image/svg+xml;charset=utf-8' });
        const svgObjectUrl = URL.createObjectURL(svgBlob);

        const tempImage = new Image();
        tempImage.crossOrigin = 'anonymous';
        tempImage.addEventListener('load', () => {
            ctx.drawImage(tempImage, 0, 0);
            canvasToImage(canvas);
            URL.revokeObjectURL(svgObjectUrl);
        });

        tempImage.src = svgObjectUrl;
    }

    // Convert the canvas to an image
    function canvasToImage(canvas) {
        const image = new Image();
        const screenshotData = canvas.toDataURL('image/webp');
    }

    renderHtmlToCanvas(canvas, html);

Solution

  • I changed your approach a little and got this working (almost there).

    // This function takes any DOMDocumentElement as a first parameter.
    // And return a Data URL of its generated image.
    async function RenderElement ( Element )
    {
      // Take the measures of the given Element.
      const Measures = Element.getBoundingClientRect();
      
      // The canvas does not need to exist in your document body.
      const CanvasElement = document.getElementById('DisplayCanvas');//document.createElement('canvas');
      {
        // The canvas is adjusted here.
        CanvasElement.width = Measures.width;
        CanvasElement.height = Measures.height;
      }
      
      const Context = CanvasElement.getContext('2d');
      
      // Slightly changed SVG...
      const ImageSVG =
      `
        <svg xmlns="http://www.w3.org/2000/svg" width="${CanvasElement.width}" height="${CanvasElement.height}">
          <foreignObject width="100%" height="100%">
            <div xmlns="http://www.w3.org/1999/xhtml">
              ${Element.outerHTML}
            </div>
          </foreignObject>
        </svg>
      `;
      
      // The image also does not need to exist in your document body.
      const ImageElement = document.createElement('img');
      const NewImage = document.createElement('img');
      
      // The svg image source is already converted to Data URL (Base64).
      ImageElement.src = `data:image/svg+xml;base64,${btoa(ImageSVG)}`;
      
      ImageElement.addEventListener
      (
        'load', () =>
        {
          Context.drawImage(ImageElement, 0, 0);
          
          NewImage.src    = CanvasElement.toDataURL('image/webp');
          
          NewImage.addEventListener
          (
            'load', () =>
            {
              NewImage.width  = NewImage.naturalWidth;
              NewImage.height = NewImage.naturalHeight;
              
              document.getElementById('GeneratedSVG').innerText = ImageSVG;
              document.getElementById('GeneratedDataURL').innerText = NewImage.src;
              document.getElementById('GeneratedImage').replaceChildren(NewImage);
            }
          );
        }
      );
      
      return NewImage;
    }
    <div id="MyContent">
      <button style='color: red' onclick="RenderElement(MyContent)">Click to generate Image of this container</button>
      <div style='color: blue'>Hello World!</div>
    </div>
    <hr />
    <div>
      Generated image and data below:
    </div>
    CANVAS: <br/>
    <canvas id='DisplayCanvas'></canvas>
    <hr />
    GENERATED IMAGE: <br/>
    <div id="GeneratedImage"></div>
    <hr />
    GENERATED SVG: <br/>
    <code id="GeneratedSVG"></code>
    <hr />
    GENERATED DATA URL: <br/>
    <code id="GeneratedDataURL"></code>

    Run the snippet to see it in action. Click the button to generate an image of the container. If it does not work on the first click, try clicking again. I really don't know why it does not work on the first try usually. I tried to change the logic to use events and I think it is working fine (at least in Chrome).

    IMPORTANT: This function does not import any CSS into the SVG which will actually re-render your element. Thus you must secure that the CSS rules are loaded into your SVG content. Inline styles work fine though...