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
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 }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 atributodrawablea cada elemento que você desenhar. Um elemento comdrawablecaptura a subárvore dele, exceto os descendentes aninhados que também sãodrawable:<canvas id="canvas" content="drawable"> <div id="html-content" drawable></div> </canvas>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);Renderize o conteúdo HTML na tela dentro de um manipulador de eventos
canvas.onpaint.No contexto 2D, use o método
drawElementImage. Ele desenha o último snapshot de um elementodrawablee 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 dewidtheheightestão em pixels da grade da tela (armazenamento de backup). Portanto, arredonde-os comMath.ceil().captureElementImage()vai gerar uma exceção se o elemento ainda não tiver um registro de pintura: chamerequestPaint()e tente novamente no próximo eventopaint.
// 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 chamandotexImage2D()comnullem 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.
- Defina o tamanho da textura de
// 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.
- Transmita
// 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_DSTeRENDER_ATTACHMENTe adicioneTEXTURE_BINDINGpara 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. Semsize, um elemento maior é cortado para a textura. Comsize, 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.
- Crie a textura com o uso de
// 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
};
- 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 definirstyle.transform. Para gerenciar a geometria manualmente, por exemplo, ao desenhar o mesmo elemento mais de uma vez, transmita{ preserveElementGeometry: true }paradrawElementImage()e aplique transformações comcanvas.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
DOMMatrixque 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.
- Use métodos regulares de exportação de tela, como
toDataURL(),toBlob()oucaptureStream(). 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
drawablea cada elemento que você desenhar, incluindo os filhos diretos da tela. - OBRIGATÓRIO: use um manipulador de eventos
onpaintpara renderizar o conteúdo HTML na tela. - OBRIGATÓRIO: use o método
drawElementImageem 2D oudrawElementImageToTexturena 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 definaTEXTURE_MIN_FILTERcomoLINEAR. - OBRIGATÓRIO: no WebGL, use o método
texElementSubImage2Dpara renderizar o conteúdo HTML em uma textura pré-alocada comtexImage2D(), dimensionada decaptureElementImage(). - OBRIGATÓRIO: na WebGPU, dimensione a textura de
captureElementImage()e recrie-a apenas quando o tamanho mudar. Crie a textura com o uso deCOPY_DSTeRENDER_ATTACHMENTe transmita o tamanho da textura comosize. - OBRIGATÓRIO: sincronize a geometria do elemento HTML com o conteúdo renderizado. Em 2D, o
drawElementImagefaz isso automaticamente. No WebGL e na WebGPU, chamecanvas.updateElementGeometry(element, { canvasTransform }). - OBRIGATÓRIO: use
ResizeObserverpara 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
ResizeObserverno manipulador de eventosonpaint, 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.