Esporta contenuti HTML da Canvas

Funzionalità web: HTML in canvas

Le applicazioni web spesso devono acquisire ed esportare contenuti HTML avanzati, come dashboard personalizzate, documenti con stile o grafici interattivi, sotto forma di immagini statiche o registrazioni video. In passato, per ottenere questo risultato erano necessarie librerie di terze parti ingombranti che analizzavano manualmente i nodi DOM e le proprietà CSS per ricostruire un facsimile visivo su un canvas. Questo approccio è oneroso dal punto di vista computazionale, soggetto a errori e spesso non supporta le moderne funzionalità di layout CSS. Con l'API HTML-in-Canvas, gli sviluppatori possono eseguire il rendering di elementi DOM reali direttamente nel contesto del canvas. Poiché il motore di rendering nativo del browser disegna il sottoalbero HTML con una precisione perfetta, l'acquisizione dell'output visivo esatto come immagine o stream video è molto efficiente utilizzando metodi canvas integrati come toDataURL(), toBlob() o captureStream().

Come implementare

  1. Verifica se HTML-in-Canvas è supportato nel browser:

    if ('content' in HTMLCanvasElement.prototype) {
      // Use HTML in Canvas API
    } else {
      // Use fallback strategy
    }
    
  2. Inizializza il canvas per supportare il rendering degli elementi HTML discendenti aggiungendo l'attributo content="drawable" all'elemento HTML <canvas>. Inserisci i contenuti HTML all'interno dell'elemento <canvas> e aggiungi l'attributo drawable a ogni elemento che disegni. Un elemento con drawable acquisisce il relativo sottoalbero, ad eccezione dei discendenti nidificati che sono anche drawable:

    <canvas id="canvas" content="drawable">
      <div id="html-content" drawable></div>
    </canvas>
    
  3. Scala la griglia del canvas in modo che corrisponda al fattore di scalabilità del dispositivo per evitare sfocature:

    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. Esegui il rendering dei contenuti HTML nel canvas all'interno di un gestore di eventi canvas.onpaint.

  5. Nel contesto 2D, utilizza il metodo drawElementImage. Disegna l'ultimo snapshot di un elemento drawable e il relativo sottoalbero disegnabile (esclusi altri elementi secondari disegnabili), a partire dalla casella del bordo dell'elemento, prima delle trasformazioni CSS:

canvas.onpaint = () => {
  ctx.reset();
  // Draw the form element at x:0, y:0
  ctx.drawElementImage(form_element, 0, 0);
};
  • Nei contesti WebGL e WebGPU, l'elemento viene copiato in una texture. Dimensiona la texture da canvas.captureElementImage(element). I valori width e height sono espressi in pixel della griglia del canvas (backing store), quindi arrotondali per eccesso con Math.ceil(). captureElementImage() genera un errore se l'elemento non ha ancora un record di visualizzazione: chiama requestPaint() e riprova al successivo 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;
}
  • Nel contesto WebGL, prealloca prima il supporto della texture utilizzando il metodo texImage2D. Gli snippet di questa guida utilizzano un contesto WebGL 2 (canvas.getContext("webgl2")). Alloca lo spazio di archiviazione delle texture chiamando texImage2D() con null anziché con i dati dei pixel, in modo da riservare la memoria GPU delle dimensioni e del formato specificati senza caricare alcun pixel. Segui queste regole:
    • Ridimensiona la texture da captureElementImage(), come mostrato sopra.
    • Alloca il livello 0 solo al primo caricamento o quando le dimensioni cambiano, perché la riallocazione cancella la texture. Utilizza lo spazio di archiviazione modificabile: lo spazio di archiviazione texStorage2D() immutabile non può essere ridimensionato quando l'elemento cambia dimensione.
// 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 };
  • Poi, nel contesto WebGL, esegui il rendering dei contenuti HTML nella texture utilizzando il metodo texElementSubImage2D. Segui queste regole:
    • Passa { width, height } nella configurazione. In questo modo, la copia riempie la texture allocata indipendentemente dalla scalabilità dell'area di lavoro.
    • Riassegna la texture se le dimensioni sono cambiate, come mostrato sopra.
// 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);
  }
};
  • Nel contesto WebGPU, utilizza il metodo drawElementImageToTexture. Segui queste regole:
    • Crea la texture con l'utilizzo di COPY_DST e RENDER_ATTACHMENT e aggiungi TEXTURE_BINDING per campionarla negli shader. Un utilizzo errato viene segnalato come errore di convalida WebGPU, non come eccezione.
    • Passa le dimensioni della texture come size. Senza size, un elemento più grande viene ritagliato in base alla texture. Con size, l'elemento viene ridimensionato per riempirlo.
    • Ricrea la texture quando le dimensioni dell'elemento cambiano e ricrea i gruppi di associazione che vi fanno riferimento.
// 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);
  }
};

Quando utilizzi un loop requestAnimationFrame per eseguire il rendering della scena, chiama canvas.requestPaint() all'interno del loop per assicurarti che il rendering dei contenuti HTML venga eseguito nel canvas. event.changedElements elenca gli elementi il cui rendering è cambiato. Assicurati di ridisegnare l'elemento o di ricaricare la sua texture solo se è stato eseguito un aggiornamento degli elementi HTML discendenti:

  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. Sincronizza la geometria dell'elemento.

Il browser deve sapere dove viene disegnato l'elemento HTML, in modo che la sua posizione DOM corrisponda ai pixel disegnati, ad esempio per consentire agli utenti di fare clic e digitare al suo interno. Un canvas esegue l'hit test solo degli elementi disegnati con il metodo 2D drawElementImage() o registrati con updateElementGeometry().

  • Per il caso del contesto 2D, il browser sincronizza automaticamente l'elemento. drawElementImage() allinea i limiti dell'hit test del DOM e gli anelli di messa a fuoco dello screen reader con le coordinate disegnate, quindi non è necessario impostare style.transform. Per gestire la geometria manualmente, ad esempio quando disegni lo stesso elemento più di una volta, passa { preserveElementGeometry: true } a drawElementImage() e applica le trasformazioni con canvas.updateElementGeometry().

  • Nel caso 3D con WebGL o WebGPU, il canvas non può sapere dove viene disegnato l'elemento HTML, quindi devi indicarglielo. Calcola un DOMMatrix che mappa il bordo dell'elemento, in pixel CSS, al canvas, in pixel CSS. Il browser deve mappare lo spazio delle coordinate 3D nello spazio delle coordinate CSS utilizzando una trasformazione dell'area visibile. Per facilitare questa operazione, segui questi passaggi:

    • Normalizza l'elemento HTML. Le dimensioni degli elementi HTML sono espresse in pixel (ad esempio, 200 px di larghezza). WebGL, tuttavia, di solito tratta gli oggetti come "quadrati unitari", ad esempio con un intervallo compreso tra -0,5 e 0,5. Se non normalizzi, il pulsante da 200 px apparirà 200 volte più grande. Questo passaggio inverte anche l'asse Y, perché in CSS il valore positivo è verso il basso, mentre in WebGL è verso l'alto.
    • Converti la matrice MVP WebGL in una matrice DOM.
    • Mappa all'area visibile del canvas. Questo passaggio è la fase di "ridimensionamento": estende nuovamente la matematica dello spazio unitario per adattarla alle dimensioni in pixel CSS dell'elemento <canvas> sullo schermo e capovolge l'asse Y.
    • Calcola la trasformazione finale. Moltiplica le matrici nell'ordine: Viewport * MVP * Normalization. La loro combinazione in un'unica trasformazione finale produce una "mappa" che indica al browser esattamente dove deve trovarsi l'elemento HTML per allinearsi al disegno 3D. Il browser esegue la divisione prospettica.
    // 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);
    }
    

Poi, passa la trasformazione a canvas.updateElementGeometry() come canvasTransform. In questo modo, l'elemento HTML viene posizionato direttamente sopra i pixel visualizzati. In questo modo, quando un utente fa clic su un pulsante o seleziona un testo, interagisce effettivamente con l'elemento HTML reale. Il browser utilizza la trasformazione anche per i limiti di accessibilità dell'elemento, ad esempio per gli anelli di messa a fuoco dello screen reader:

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

Utilizza queste opzioni per controllare l'hit test:

  • Ogni chiamata updateElementGeometry() sposta l'elemento in cima all'ordine di hit test. Passa { preserveHitTestOrder: true } per mantenere la sua posizione.
  • Chiama canvas.clearElementGeometry(element) per rimuovere un elemento dall'hit test, ad esempio quando lo nascondi.
  1. Utilizza i normali metodi di esportazione del canvas, come toDataURL(), toBlob() o captureStream(). I dati esportati includeranno i contenuti HTML visualizzati.

Esempio di codice

<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>

Best practice

  • OBBLIGATORIO: verifica il supporto del browser per l'API HTML-in-Canvas prima di utilizzarla.
  • OBBLIGATORIO: aggiungi sempre l'attributo content="drawable" all'elemento <canvas>.
  • OBBLIGATORIO: aggiungi l'attributo drawable a ogni elemento che disegni, inclusi gli elementi secondari diretti del canvas.
  • OBBLIGATORIO: utilizza un gestore di eventi onpaint per eseguire il rendering dei contenuti HTML nel canvas.
  • OBBLIGATORIO: utilizza il metodo drawElementImage in 2D o drawElementImageToTexture in WebGPU per eseguire il rendering dei contenuti HTML nel canvas.
  • OBBLIGATORIO: in WebGL, prealloca il buffer delle texture con texImage2D() una volta (quando viene inizializzato o quando cambiano le dimensioni dell'elemento). Riassegna la texture solo quando le dimensioni cambiano e imposta TEXTURE_MIN_FILTER su LINEAR.
  • OBBLIGATORIO: in WebGL, utilizza il metodo texElementSubImage2D per eseguire il rendering dei contenuti HTML in una texture preallocata con texImage2D(), con dimensioni da captureElementImage().
  • OBBLIGATORIO: in WebGPU, ridimensiona la texture da captureElementImage() e ricreala solo quando le dimensioni cambiano. Crea la texture con l'utilizzo di COPY_DST e RENDER_ATTACHMENT e passa la dimensione della texture come size.
  • OBBLIGATORIO: sincronizza la geometria dell'elemento HTML con i contenuti visualizzati. In 2D, drawElementImage lo fa automaticamente. In WebGL e WebGPU, chiama canvas.updateElementGeometry(element, { canvasTransform }).
  • OBBLIGATORIO: utilizza ResizeObserver per osservare le dimensioni dello schermo e aggiornare le dimensioni del canvas in modo che corrispondano ai pixel del dispositivo.
  • NON incorporare contenuti multiorigine in un canvas, poiché non sono supportati.
  • NON inizializzare ResizeObserver all'interno del gestore di eventi onpaint, in quanto ciò potrebbe causare perdite di memoria.

Strategie di riserva

L'API HTML-in-Canvas non è attualmente supportata in tutti i browser moderni, pertanto in genere è necessaria una strategia di riserva. Tuttavia, dati i vantaggi in termini di prestazioni di questa API, è consigliabile utilizzare HTML-in-Canvas se il browser lo supporta.

Per il caso d'uso in cui i contenuti HTML devono essere esportati da un canvas, utilizza librerie come html2canvas, dom-to-image o snapdom.

Per acquisire le interazioni HTML frame per frame, ad esempio per lo streaming, acquisisci le mutazioni DOM utilizzando librerie come rrweb.

In alternativa, implementa un avviso che indichi che l'esportazione di contenuti multimediali HTML non è supportata nel browser perché non supporta HTML-in-Canvas.