Exportar contenido HTML desde Canvas

Funciones web: HTML en canvas

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

  1. Verifica si el navegador admite HTML-in-Canvas:

    if ('content' in HTMLCanvasElement.prototype) {
      // Use HTML in Canvas API
    } else {
      // Use fallback strategy
    }
    
  2. 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 atributo drawable a cada elemento que dibujes. Un elemento con drawable captura su subárbol, excepto los descendientes anidados que también son drawable:

    <canvas id="canvas" content="drawable">
      <div id="html-content" drawable></div>
    </canvas>
    
  3. 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);
    
  4. Renderiza el contenido HTML en el lienzo dentro de un controlador de eventos canvas.onpaint.

  5. En el contexto 2D, usa el método drawElementImage. Dibuja la última instantánea de un elemento drawable y 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 propiedades width y height se expresan en píxeles de la cuadrícula del lienzo (almacén de copia de seguridad), por lo que debes redondearlas con Math.ceil(). captureElementImage() arroja un error si el elemento aún no tiene un registro de pintura: llama a requestPaint() y vuelve a intentarlo en el 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;
}
  • 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 a texImage2D() con null en 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.
// 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.
// 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_DST y RENDER_ATTACHMENT, y agrega TEXTURE_BINDING para 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. Sin size, un elemento más grande se recorta a la textura. Con size, 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.
// 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
  };
  1. 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 establecer style.transform. Para administrar la geometría de forma manual, por ejemplo, cuando dibujas el mismo elemento más de una vez, pasa { preserveElementGeometry: true } a drawElementImage() y aplica transformaciones con canvas.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 DOMMatrix que 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.
  1. Usa métodos de exportación de lienzo normales, como toDataURL(), toBlob() o captureStream(). 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 drawable a cada elemento que dibujes, incluidos los elementos secundarios directos del lienzo.
  • OBLIGATORIO: Usa un controlador de eventos onpaint para renderizar el contenido HTML en el lienzo.
  • OBLIGATORIO: Usa el método drawElementImage en 2D o drawElementImageToTexture en 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 establece TEXTURE_MIN_FILTER en LINEAR.
  • OBLIGATORIO: En WebGL, usa el método texElementSubImage2D para renderizar el contenido HTML en una textura que asignes previamente con texImage2D(), con el tamaño de captureElementImage().
  • 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 de COPY_DST y RENDER_ATTACHMENT, y pasa el tamaño de la textura como size.
  • OBLIGATORIO: Sincroniza la geometría del elemento HTML con el contenido renderizado. En 2D, drawElementImage lo hace automáticamente. En WebGL y WebGPU, llama a canvas.updateElementGeometry(element, { canvasTransform }).
  • OBLIGATORIO: Usa ResizeObserver para 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 ResizeObserver dentro del controlador de eventos onpaint, 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.