Las aplicaciones web suelen necesitar capturar y exportar contenido HTML enriquecido, como paneles personalizados, documentos con estilo o gráficos interactivos, como imágenes estáticas o grabaciones de video. Históricamente, para lograr esto, se necesitaban bibliotecas de terceros voluminosas que analizaran manualmente los nodos del DOM y las propiedades de CSS para reconstruir un facsímil visual en un lienzo. Este enfoque es costoso desde el punto de vista computacional, propenso a errores y, con frecuencia, no admite las funciones de diseño de CSS modernas. Con la API de HTML-in-Canvas, los desarrolladores pueden renderizar elementos del DOM reales directamente en el contexto del lienzo. Debido a que el motor de renderización nativo del navegador pinta el subárbol HTML con una precisión perfecta de píxeles, capturar el resultado visual exacto como una imagen o una transmisión de video es muy eficiente con los métodos de lienzo integrados, como toDataURL(), toBlob() o captureStream().
Cómo implementarlo
Verifica si el navegador admite HTML-in-Canvas:
if ('content' in HTMLCanvasElement.prototype) { // Use HTML in Canvas API } else { // Use fallback strategy }Inicializa el lienzo para admitir la renderización de elementos HTML descendientes agregando el atributo
content="drawable"al elemento HTML<canvas>. Coloca tu contenido HTML dentro del elemento<canvas>y agrega el atributodrawablea cada elemento que dibujes. Un elemento condrawablecaptura su subárbol, excepto los descendientes anidados que también sondrawable:<canvas id="canvas" content="drawable"> <div id="html-content" drawable></div> </canvas>Ajusta la cuadrícula del lienzo para que coincida con el factor de escala del dispositivo y evitar que se vea borroso:
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);Renderiza el contenido HTML en el lienzo dentro de un controlador de eventos
canvas.onpaint.En el contexto 2D, usa el método
drawElementImage. Dibuja la última instantánea de un elementodrawabley su subárbol dibujable (sin incluir otros elementos secundarios dibujables), que comienza en el cuadro de borde del elemento, antes de las transformaciones de CSS:
canvas.onpaint = () => {
ctx.reset();
// Draw the form element at x:0, y:0
ctx.drawElementImage(form_element, 0, 0);
};
- En los contextos de WebGL y WebGPU, copias el elemento en una textura. Establece el tamaño de la textura desde
canvas.captureElementImage(element). Sus propiedadeswidthyheightse expresan en píxeles de la cuadrícula del lienzo (almacén de copia de seguridad), por lo que debes redondearlas conMath.ceil().captureElementImage()arroja un error si el elemento aún no tiene un registro de pintura: llama arequestPaint()y vuelve a intentarlo en el 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;
}
- En el contexto de WebGL, primero asigna previamente el respaldo de la textura con el método
texImage2D. Los fragmentos de esta guía usan un contexto de WebGL 2 (canvas.getContext("webgl2")). Llama atexImage2D()connullen lugar de datos de píxeles para asignar almacenamiento de texturas, lo que reserva memoria de la GPU del tamaño y formato dados sin subir ningún píxel. Sigue estas reglas:- Ajusta el tamaño de la textura de
captureElementImage(), como se muestra más arriba. - Asigna el nivel 0 solo en la primera carga o cuando cambie el tamaño, ya que la reasignación borra la textura. Usa almacenamiento mutable: No se puede cambiar el tamaño del almacenamiento de
texStorage2D()inmutable cuando cambia el tamaño del elemento.
- Ajusta el tamaño de la 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 };
- Luego, en el contexto de WebGL, renderiza el contenido HTML en la textura con el método
texElementSubImage2D. Sigue estas reglas:- Pasa
{ width, height }en la configuración. Hace que la copia complete la textura asignada, independientemente de cómo se escale el lienzo. - Vuelve a asignar la textura si cambió el tamaño, como se muestra más arriba.
- Pasa
// 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);
}
};
- En el contexto de WebGPU, usa el método
drawElementImageToTexture. Sigue estas reglas:- Crea la textura con el uso de
COPY_DSTyRENDER_ATTACHMENT, y agregaTEXTURE_BINDINGpara muestrearla en sombreadores. Un uso incorrecto se informa como un error de validación de WebGPU, no como una excepción. - Pasa el tamaño de la textura como
size. Sinsize, un elemento más grande se recorta a la textura. Consize, el elemento se escala para llenarlo. - Vuelve a crear la textura cuando cambie el tamaño del elemento y vuelve a compilar los grupos de vinculaciones que hacen referencia a ella.
- Crea la textura con el 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);
}
};
Cuando uses un bucle requestAnimationFrame para renderizar la escena, llama a canvas.requestPaint() dentro del bucle para asegurarte de que el contenido HTML se renderice en el lienzo. event.changedElements enumera los elementos cuya renderización cambió. Asegúrate de volver a dibujar el elemento o volver a subir su textura solo si se actualizaron los elementos HTML descendientes:
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
};
- Sincroniza la geometría del elemento.
El navegador necesita saber dónde se dibuja el elemento HTML para que su posición en el DOM coincida con los píxeles dibujados, por ejemplo, para que los usuarios puedan hacer clic y escribir en él. Un lienzo solo realiza pruebas de posicionamiento de los elementos que se dibujan con el método 2D drawElementImage() o que se registran con updateElementGeometry().
En el caso del contexto 2D, el navegador sincroniza el elemento automáticamente.
drawElementImage()alinea los límites de la prueba de posicionamiento del DOM y los anillos de enfoque del lector de pantalla con las coordenadas dibujadas, por lo que no necesitas establecerstyle.transform. Para administrar la geometría de forma manual, por ejemplo, cuando dibujas el mismo elemento más de una vez, pasa{ preserveElementGeometry: true }adrawElementImage()y aplica transformaciones concanvas.updateElementGeometry().En el caso de 3D con WebGL o WebGPU, el lienzo no puede saber dónde se dibuja el elemento HTML, por lo que debes indicarlo. Calcula un
DOMMatrixque asigne el cuadro de borde del elemento, en píxeles de CSS, al lienzo, en píxeles de CSS. El navegador debe asignar el espacio de coordenadas 3D al espacio de coordenadas CSS con una transformación de viewport. Para facilitar este proceso, haz lo siguiente:- Normaliza el elemento HTML. Los elementos HTML se miden en píxeles (por ejemplo, 200 px de ancho). Sin embargo, WebGL suele tratar los objetos como "cuadrados unitarios", por ejemplo, con un rango de -0.5 a 0.5. Si no lo haces, el botón de 200 px se verá 200 veces más grande. En este paso, también se invierte el eje Y, ya que, en CSS, hacia abajo es positivo, pero en WebGL, hacia arriba es positivo.
- Convierte la matriz de MVP de WebGL en una matriz de DOM.
- Asigna el mapa al viewport del lienzo. Este paso es la fase de "cambio de escala": extiende esa matemática de espacio de unidades para que coincida con las dimensiones de píxeles de CSS de tu elemento
<canvas>en la pantalla y vuelve a invertir el eje Y. - Calcula la transformación final. Multiplica las matrices en orden: Viewport * MVP * Normalización. Si se combinan en una transformación final, se produce un "mapa" que le indica al navegador exactamente dónde debe ubicarse ese elemento HTML para alinearse con el dibujo en 3D. El navegador realiza la división 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); }
Luego, pasa la transformación a canvas.updateElementGeometry() como canvasTransform. Esto mueve el elemento HTML para que se ubique directamente sobre sus píxeles renderizados. Esto garantiza que, cuando un usuario hace clic en un botón o selecciona texto, realmente esté interactuando con el elemento HTML real. El navegador también usa la transformación para los límites de accesibilidad del elemento, por ejemplo, para los anillos de enfoque del lector de pantalla:
canvas.updateElementGeometry(targetHTMLElement, {
canvasTransform: computeCanvasTransform(canvas, targetHTMLElement, htmlElementMVP),
});
Usa estas opciones para controlar la prueba de posicionamiento:
- Cada llamada a
updateElementGeometry()mueve el elemento a la parte superior del orden de prueba de posicionamiento. Pasa{ preserveHitTestOrder: true }para mantener su posición. - Llama a
canvas.clearElementGeometry(element)para quitar un elemento de la prueba de posicionamiento, por ejemplo, cuando lo ocultas.
- Usa métodos de exportación de lienzo normales, como
toDataURL(),toBlob()ocaptureStream(). Los datos exportados incluirán el contenido HTML renderizado.
Ejemplo 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ácticas recomendadas
- OBLIGATORIO: Verifica la compatibilidad del navegador con la API de HTML-in-Canvas antes de usarla.
- OBLIGATORIO: Agrega siempre el atributo
content="drawable"al elemento<canvas>. - OBLIGATORIO: Agrega el atributo
drawablea cada elemento que dibujes, incluidos los elementos secundarios directos del lienzo. - OBLIGATORIO: Usa un controlador de eventos
onpaintpara renderizar el contenido HTML en el lienzo. - OBLIGATORIO: Usa el método
drawElementImageen 2D odrawElementImageToTextureen WebGPU para renderizar el contenido HTML en el lienzo. - OBLIGATORIO: En WebGL, asigna previamente el búfer de textura con
texImage2D()una vez (cuando se inicializa o cuando cambia el tamaño del elemento). Vuelve a asignar la textura solo cuando cambie el tamaño y estableceTEXTURE_MIN_FILTERenLINEAR. - OBLIGATORIO: En WebGL, usa el método
texElementSubImage2Dpara renderizar el contenido HTML en una textura que asignes previamente contexImage2D(), con el tamaño decaptureElementImage(). - OBLIGATORIO: En WebGPU, asigna el tamaño de la textura desde
captureElementImage()y vuelve a crearla solo cuando cambie el tamaño. Crea la textura con el uso deCOPY_DSTyRENDER_ATTACHMENT, y pasa el tamaño de la textura comosize. - OBLIGATORIO: Sincroniza la geometría del elemento HTML con el contenido renderizado. En 2D,
drawElementImagelo hace automáticamente. En WebGL y WebGPU, llama acanvas.updateElementGeometry(element, { canvasTransform }). - OBLIGATORIO: Usa
ResizeObserverpara observar el tamaño de la pantalla y actualizar el tamaño del lienzo para que coincida con los píxeles del dispositivo. - NO incorpores contenido de origen cruzado en un lienzo, ya que no se admite.
- NO inicialices
ResizeObserverdentro del controlador de eventosonpaint, ya que esto puede provocar fugas de memoria.
Estrategias de resguardo
Actualmente, la API de HTML-in-Canvas no es compatible con todos los navegadores modernos, por lo que, por lo general, se requiere una estrategia de resguardo. Sin embargo, dadas las ventajas de rendimiento mejorado de esta API, se debe usar HTML-in-Canvas si el navegador lo admite.
En el caso de uso en el que se deba exportar contenido HTML desde un lienzo, usa bibliotecas como html2canvas, dom-to-image o snapdom.
Para capturar interacciones HTML fotograma por fotograma, por ejemplo, para la transmisión, captura las mutaciones del DOM con bibliotecas como rrweb.
Como alternativa, implementa una advertencia que indique que la exportación de contenido multimedia HTML no es compatible con el navegador porque no admite HTML en Canvas.