Exportar conteúdo HTML do Canvas

Recursos da Web: HTML em tela

Os aplicativos da Web geralmente precisam capturar e exportar conteúdo HTML avançado, como painéis personalizados, documentos estilizados ou gráficos interativos, como imagens estáticas ou gravações de vídeo. No passado, para fazer isso, era necessário usar bibliotecas de terceiros volumosas que analisavam manualmente os nós do DOM e as propriedades CSS para reconstruir um fac-símile visual em uma tela. Essa abordagem é computacionalmente cara, propensa a erros e, muitas vezes, não é compatível com os recursos modernos de layout CSS. Com a API HTML-in-Canvas, os desenvolvedores podem renderizar elementos DOM reais diretamente no contexto da tela. Como o mecanismo de renderização nativo do navegador pinta a subárvore HTML com precisão de pixel, capturar a saída visual exata como uma imagem ou stream de vídeo é altamente eficiente usando métodos de tela integrados, como toDataURL(), toBlob() ou captureStream().

Como fazer a implementação

  1. Verifique se o HTML-in-Canvas é compatível com o navegador:

    if ('content' in HTMLCanvasElement.prototype) {
      // Use HTML in Canvas API
    } else {
      // Use fallback strategy
    }
    
  2. Inicialize a tela para oferecer suporte à renderização de elementos HTML descendentes adicionando o atributo content="drawable" ao elemento HTML <canvas>. Coloque o conteúdo HTML dentro do elemento <canvas> e adicione o atributo drawable a cada elemento que você desenhar. Um elemento com drawable captura a subárvore dele, exceto os descendentes aninhados que também são drawable:

    <canvas id="canvas" content="drawable">
      <div id="html-content" drawable></div>
    </canvas>
    
  3. Dimensione a grade da tela para corresponder ao fator de escalonamento do dispositivo e evitar que fique desfocada:

    const observer = new ResizeObserver(([entry]) => {
      const dpc = entry.devicePixelContentBoxSize;
      canvas.width = dpc
        ? dpc[0].inlineSize
        : Math.round(entry.contentRect.width * window.devicePixelRatio);
      canvas.height = dpc
        ? dpc[0].blockSize
        : Math.round(entry.contentRect.height * window.devicePixelRatio);
    });
    
    const supportsDevicePixelContentBox =
      typeof ResizeObserverEntry !== "undefined" &&
      "devicePixelContentBoxSize" in ResizeObserverEntry.prototype;
    const options = supportsDevicePixelContentBox
      ? { box: "device-pixel-content-box" }
      : {};
    observer.observe(canvas, options);
    
  4. Renderize o conteúdo HTML na tela dentro de um manipulador de eventos canvas.onpaint.

  5. No contexto 2D, use o método drawElementImage. Ele desenha o último snapshot de um elemento drawable e a subárvore que pode ser desenhada (excluindo outros filhos que podem ser desenhados), começando na caixa de borda do elemento, antes das transformações de CSS:

canvas.onpaint = () => {
  ctx.reset();
  // Draw the form element at x:0, y:0
  ctx.drawElementImage(form_element, 0, 0);
};
  • Nos contextos WebGL e WebGPU, você copia o elemento em uma textura. Defina o tamanho da textura de canvas.captureElementImage(element). Os valores de width e height estão em pixels da grade da tela (armazenamento de backup). Portanto, arredonde-os com Math.ceil(). captureElementImage() vai gerar uma exceção se o elemento ainda não tiver um registro de pintura: chame requestPaint() e tente novamente no próximo evento paint.
// Returns the element's size in canvas grid pixels, or null if the element
// has no paint record yet.
function getElementImageSize(canvas, element) {
  let elementImage;
  try {
    elementImage = canvas.captureElementImage(element);
  } catch (err) {
    canvas.requestPaint(); // No paint record yet: retry on the next paint event.
    return null;
  }
  const width = Math.ceil(elementImage.width);
  const height = Math.ceil(elementImage.height);
  elementImage.close();
  return width > 0 && height > 0 ? { width, height } : null;
}
  • No contexto do WebGL, pré-aloque o suporte de textura primeiro usando o método texImage2D. Os snippets neste guia usam um contexto WebGL 2 (canvas.getContext("webgl2")). Aloque o armazenamento de textura chamando texImage2D() com null em vez de dados de pixel, o que reserva a memória da GPU do tamanho e formato fornecidos sem fazer upload de pixels. Siga estas regras:
    • Defina o tamanho da textura de captureElementImage(), conforme mostrado acima.
    • Aloque o nível 0 apenas no primeiro upload ou quando o tamanho mudar, porque a realocação limpa a textura. Use o armazenamento mutável: o armazenamento imutável de texStorage2D() não pode ser redimensionado quando o elemento muda de tamanho.
// Binds the texture, and allocates its storage on the first upload, and when
// the element size changes. `state` stores the allocated size.
function allocateElementTexture(gl, texture, state, width, height) {
  gl.bindTexture(gl.TEXTURE_2D, texture);
  if (state.width === width && state.height === height) return;

  gl.texImage2D(
    gl.TEXTURE_2D,
    0,                // Mipmap level
    gl.RGBA8,         // Internal format: 8 bits per RGBA channel
    width,            // Size in canvas grid pixels
    height,
    0,                // Border: must be 0
    gl.RGBA,          // Format and type that match RGBA8
    gl.UNSIGNED_BYTE,
    null              // null reserves GPU memory without uploading pixels
  );
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
  state.width = width;
  state.height = height;
}

const texture = gl.createTexture();
const textureState = { width: 0, height: 0 };
  • Em seguida, no contexto do WebGL, renderize o conteúdo HTML na textura usando o método texElementSubImage2D. Siga estas regras:
    • Transmita { width, height } na configuração. Ele faz com que a cópia preencha a textura alocada, independentemente de como a tela é dimensionada.
    • Realocar a textura se o tamanho mudar, conforme mostrado acima.
// Uploads the element into the texture. Returns false if the element has no
// paint record yet. In that case, it's uploaded on the next paint event.
function uploadElementWebGL(gl, canvas, texture, element, state) {
  const size = getElementImageSize(canvas, element);
  if (!size) return false;
  const { width, height } = size;

  // Bind the texture, and reallocate it if the size changed (see above).
  allocateElementTexture(gl, texture, state, width, height);
  gl.texElementSubImage2D(gl.TEXTURE_2D, 0, 0, 0, element, { width, height });
  return true;
}

canvas.onpaint = () => {
  try {
    uploadElementWebGL(gl, canvas, texture, uiElement, textureState);
  } catch (err) {
    console.error('texElementSubImage2D copy failed:', err);
  }
};
  • No contexto da WebGPU, use o método drawElementImageToTexture. Siga estas regras:
    • Crie a textura com o uso de COPY_DST e RENDER_ATTACHMENT e adicione TEXTURE_BINDING para fazer a amostragem em sombreadores. Um uso incorreto é relatado como um erro de validação da WebGPU, não como uma exceção.
    • Transmita o tamanho da textura como size. Sem size, um elemento maior é cortado para a textura. Com size, o elemento é dimensionado para preenchê-lo.
    • Recrie a textura quando o tamanho do elemento mudar e recrie os grupos de vinculação que fazem referência a ela.
// Create the texture once, and recreate it
// when the element size changes.
function ensureElementTexture(device, state, width, height) {
  if (state.texture?.width === width && state.texture?.height === height) {
    return state.texture;
  }
  state.texture?.destroy();
  state.texture = device.createTexture({
    size: { width, height },
    format: "rgba8unorm",
    usage:
      GPUTextureUsage.TEXTURE_BINDING |
      GPUTextureUsage.COPY_DST |
      GPUTextureUsage.RENDER_ATTACHMENT,
  });
  return state.texture; // Recreate the bind groups that used the previous texture.
}

const textureState = { texture: null };

canvas.onpaint = () => {
  const size = getElementImageSize(canvas, uiElement);
  if (!size) return;
  const previousTexture = textureState.texture;
  const texture = ensureElementTexture(device, textureState, size.width, size.height);
  if (texture !== previousTexture) {
    // New texture: create, or rebuild, the bind groups that reference it.
  }
  try {
    device.queue.drawElementImageToTexture({ source: uiElement }, { texture, size });
  } catch (err) {
    console.error('drawElementImageToTexture copy failed:', err);
  }
};

Ao usar um loop requestAnimationFrame para renderizar a cena, chame canvas.requestPaint() dentro do loop para garantir que o conteúdo HTML seja renderizado na tela. event.changedElements lista os elementos que tiveram a renderização alterada. Só redesenhe o elemento ou faça o reenviar da textura dele se houver uma atualização nos elementos HTML descendentes:

  function render() {
    // Request to update the canvas
    canvas.requestPaint();
    requestAnimationFrame(render);
  }
  requestAnimationFrame(render);

  canvas.onpaint = (event) => {
    if (event.changedElements && event.changedElements.length > 0) {
      // Redraw the element, or update its texture, as shown above
    }
    // In WebGL and WebGPU, render the scene, and sync the element geometry as shown in step 5
  };
  1. Sincronize a geometria do elemento.

O navegador precisa saber onde o elemento HTML é desenhado para que a posição do DOM corresponda aos pixels desenhados, por exemplo, para que os usuários possam clicar e digitar nele. Uma tela só faz testes de hit nos elementos desenhados com o método 2D drawElementImage() ou registrados com updateElementGeometry().

  • No caso do contexto 2D, o navegador sincroniza o elemento automaticamente. drawElementImage() alinha os limites do teste de hit do DOM e os anéis de foco do leitor de tela com as coordenadas desenhadas, então não é necessário definir style.transform. Para gerenciar a geometria manualmente, por exemplo, ao desenhar o mesmo elemento mais de uma vez, transmita { preserveElementGeometry: true } para drawElementImage() e aplique transformações com canvas.updateElementGeometry().

  • No caso 3D com WebGL ou WebGPU, o canvas não consegue identificar onde o elemento HTML é desenhado, então você precisa informar isso. Calcule um DOMMatrix que mapeie a caixa de borda do elemento, em pixels CSS, para a tela, em pixels CSS. O navegador precisa mapear o espaço de coordenadas 3D para o espaço de coordenadas CSS usando uma transformação de janela de visualização. Para facilitar, faça o seguinte:

    • Normalizar o elemento HTML. Os elementos HTML são dimensionados em pixels (por exemplo, 200 px de largura). No entanto, o WebGL geralmente trata os objetos como "quadrados unitários", por exemplo, de -0,5 a 0,5. Se você não normalizar, o botão de 200 px vai parecer 200 vezes maior. Essa etapa também inverte o eixo Y, porque no CSS, para baixo é positivo, mas no WebGL, para cima é positivo.
    • Converta a matriz MVP do WebGL em uma matriz DOM.
    • Mapear para a janela de visualização da tela. Essa etapa é a fase de "redimensionamento": ela estende o cálculo do espaço unitário para corresponder às dimensões de pixel CSS do elemento <canvas> na tela e inverte o eixo Y.
    • Calcule a transformação final. Multiplique as matrizes na ordem: Janela de visualização * MVP * Normalização. Combiná-las em uma transformação final produz um "mapa" que informa ao navegador exatamente onde esse elemento HTML deve ficar para se alinhar ao desenho 3D. O navegador realiza a divisão de perspectiva.
    // Maps the element's border box (CSS pixels) to the canvas (CSS pixels), for a
    // quad that spans -0.5 to 0.5 in model space and is drawn with the `mvp` matrix.
    function computeCanvasTransform(canvas, element, mvp) {
      const width = element.offsetWidth;
      const height = element.offsetHeight;
    
      // 1. Normalize the HTML element (CSS pixels -> WebGL Model Space)
      const toGLModel = new DOMMatrix()
        // Scale pixels to 1 unit, flip Y (as in CSS it points down, and in WebGL it points up)
        .scale(1 / width, -1 / height, 1 / height)
        // Center the origin: (0,0) becomes (-width/2, -height/2) before scaling
        .translate(-width / 2, -height / 2);
    
      // 2. Convert WebGL MVP Matrix to DOM Matrix
      const mvpDOM = new DOMMatrix(Array.from(mvp));
    
      // 3. Map to the canvas viewport, in CSS pixels
      const clipToCanvasViewport = new DOMMatrix()
        // Move center (0,0) to center of canvas
        .translate(canvas.clientWidth / 2, canvas.clientHeight / 2)
        // Scale normalized clip (-1..1) to viewport size, and flip Y back
        .scale(canvas.clientWidth / 2, -canvas.clientHeight / 2, canvas.clientHeight / 2);
    
      // 4. Multiply: (Clip -> Pixels) * (MVP) * (pixels -> unit square)
      return clipToCanvasViewport.multiply(mvpDOM).multiply(toGLModel);
    }
    

Em seguida, transmita a transformação para canvas.updateElementGeometry() como canvasTransform. Isso move o elemento HTML para ficar diretamente sobre os pixels renderizados. Isso garante que, quando um usuário clicar em um botão ou selecionar um texto, ele esteja realmente interagindo com o elemento HTML real. O navegador também usa a transformação para os limites de acessibilidade do elemento, por exemplo, para anéis de foco do leitor de tela:

  canvas.updateElementGeometry(targetHTMLElement, {
    canvasTransform: computeCanvasTransform(canvas, targetHTMLElement, htmlElementMVP),
  });

Use estas opções para controlar o teste de hit:

  • Cada chamada updateElementGeometry() move o elemento para o topo da ordem de teste de hit. Passe { preserveHitTestOrder: true } para manter a posição.
  • Chame canvas.clearElementGeometry(element) para remover um elemento do teste de hit, por exemplo, ao ocultá-lo.
  1. Use métodos regulares de exportação de tela, como toDataURL(), toBlob() ou captureStream(). Os dados exportados vão incluir o conteúdo HTML renderizado.

Exemplo de código

<body>
    <canvas id="canvas" style="width: 400px; height: 200px;" content="drawable">
        <input id="element" drawable>
    </canvas>
    
    <button id="download">Download Image</button>

    <script>
        const canvas = document.getElementById('canvas');
        const ctx = canvas.getContext('2d');
        const element = document.getElementById('element');
        const download = document.getElementById('download');

        canvas.onpaint = (event) => {
            ctx.reset();
            // Draw the element into the canvas. The browser synchronizes its
            // DOM position for hit testing (typing).
            ctx.drawElementImage(element, 10, 10);
        };

        download.onclick = () => {
            // Export the canvas content as an image
            const dataURL = canvas.toDataURL('image/png');
            const link = document.createElement('a');
            link.download = 'exported-canvas.png';
            link.href = dataURL;
            link.click();
        };

        // Re-initialize canvas size on screen resize
        const observer = new ResizeObserver(([entry]) => {
            const dpc = entry.devicePixelContentBoxSize;
            canvas.width = dpc ? dpc[0].inlineSize : Math.round(entry.contentRect.width * window.devicePixelRatio);
            canvas.height = dpc ? dpc[0].blockSize : Math.round(entry.contentRect.height * window.devicePixelRatio);
            canvas.requestPaint();
        });
        const supportsDevicePixelContentBox = 
            typeof ResizeObserverEntry !== 'undefined' && 
            'devicePixelContentBoxSize' in ResizeObserverEntry.prototype;
        const options = supportsDevicePixelContentBox ? { box: 'device-pixel-content-box' } : {};
        observer.observe(canvas, options);
    </script>
</body>

Práticas recomendadas

  • OBRIGATÓRIO: verifique se o navegador é compatível com a API HTML-in-Canvas antes de usá-la.
  • OBRIGATÓRIO: sempre adicione o atributo content="drawable" ao elemento <canvas>.
  • OBRIGATÓRIO: adicione o atributo drawable a cada elemento que você desenhar, incluindo os filhos diretos da tela.
  • OBRIGATÓRIO: use um manipulador de eventos onpaint para renderizar o conteúdo HTML na tela.
  • OBRIGATÓRIO: use o método drawElementImage em 2D ou drawElementImageToTexture na WebGPU para renderizar o conteúdo HTML na tela.
  • OBRIGATÓRIO: no WebGL, pré-aloque o buffer de textura com texImage2D() uma vez (quando ele for inicializado ou quando o tamanho do elemento mudar). Realoque a textura apenas quando o tamanho mudar e defina TEXTURE_MIN_FILTER como LINEAR.
  • OBRIGATÓRIO: no WebGL, use o método texElementSubImage2D para renderizar o conteúdo HTML em uma textura pré-alocada com texImage2D(), dimensionada de captureElementImage().
  • OBRIGATÓRIO: na WebGPU, dimensione a textura de captureElementImage() e recrie-a apenas quando o tamanho mudar. Crie a textura com o uso de COPY_DST e RENDER_ATTACHMENT e transmita o tamanho da textura como size.
  • OBRIGATÓRIO: sincronize a geometria do elemento HTML com o conteúdo renderizado. Em 2D, o drawElementImage faz isso automaticamente. No WebGL e na WebGPU, chame canvas.updateElementGeometry(element, { canvasTransform }).
  • OBRIGATÓRIO: use ResizeObserver para observar o tamanho da tela e atualizar o tamanho da tela de pintura para corresponder aos pixels do dispositivo.
  • NÃO incorpore conteúdo de origem cruzada em uma tela, porque isso não é permitido.
  • NÃO inicialize ResizeObserver no manipulador de eventos onpaint, porque isso pode causar vazamentos de memória.

Estratégias de fallback

A API HTML-in-Canvas ainda não é compatível com todos os navegadores mais recentes. Por isso, geralmente é necessária uma estratégia de fallback. No entanto, considerando os benefícios de desempenho dessa API, o HTML-in-Canvas deve ser usado se o navegador for compatível.

Para o caso de uso em que o conteúdo HTML precisa ser exportado de uma tela, use bibliotecas como html2canvas, dom-to-image ou snapdom.

Para capturar interações HTML frame a frame, por exemplo, para streaming, capture mutações do DOM usando bibliotecas como rrweb.

Como alternativa, implemente um aviso de que a exportação de mídia HTML não é compatível com o navegador porque ele não é compatível com HTML-in-Canvas.