HTML-in-Canvas arriva in Chrome 157: HTML vivo dentro Canvas, WebGL e WebGPU

HTML-in-Canvas arriva in Chrome 157: HTML vivo dentro Canvas, WebGL e WebGPU

Chi ha costruito una scena WebGL con dentro un modulo conosce il dilemma. O disegni l’interfaccia a mano nel canvas, e perdi la selezione del testo, l’accessibilità e l’andata a capo, oppure metti l’HTML sopra il canvas e passi il tempo a ricalcolare le posizioni perché non si stacchi dalla scena. Dal 26 ottobre c’è una terza strada: Chrome 157 include HTML-in-Canvas, l’API che disegna HTML vivo dentro un <canvas> 2D, WebGL o WebGPU.

La differenza rispetto a quello che c’era finora è che il browser non scatta una foto dell’HTML: continua a trattarlo come HTML. Il testo si può selezionare e copiare, lo screen reader lo legge, Ctrl+F lo trova, il traduttore lo traduce e la compilazione automatica riempie i suoi campi, anche quando appare deformato su una mesh 3D. La proposta è nel WICG dall’agosto 2024. Chrome l’ha avuta prima dietro un flag e, dalla versione 148, in origin trial, e lungo la strada ha cambiato nomi più volte; più sotto ci sono le corrispondenze, nel caso abbiate provato una versione precedente.

Il problema di sempre: DOM o pixel

Un testo disegnato con ctx.fillText() sono pixel. Non va a capo, non conosce il grassetto né il testo da destra a sinistra, non si può selezionare e, per uno screen reader, non esiste: bisogna mantenere a mano un DOM parallelo e invisibile che dica le stesse cose. E per un’internazionalizzazione seria tocca caricare un motore di impaginazione del testo in JavaScript.

Le vie d’uscita abituali avevano il loro prezzo:

  • Trasformare l’HTML in un’immagine con librerie come html2canvas o dom-to-image. Nel canvas arriva una foto: il pulsante non è più un pulsante. E siccome la libreria reimplementa il CSS per conto suo, quello che non supporta semplicemente non appare.
  • Far galleggiare l’HTML sopra il canvas. Funziona finché la telecamera non si muove: DOM e pixel vanno con tempi diversi e il pannello trema o resta indietro. In più l’HTML sta sempre sopra a tutto, quindi non può nascondersi dietro un oggetto 3D, vedersi attraverso un vetro né riflettersi in uno specchio. E a ogni fotogramma bisogna scrivere style.transform su ogni pannello, con il ricalcolo degli stili che ne consegue.
Confronto: istantanea statica con html2canvas contro HTML vivo con HTML-in-Canvas
Prima e adesso: la cattura di una libreria è un’immagine; con HTML-in-Canvas il browser continua a trattare il contenuto come HTML.

Come funziona: attributi, evento paint e una chiamata per disegnare

L’API si regge su pochissimi pezzi. Il primo è dichiarativo: il <canvas> riceve content="drawable", che dice al browser di impaginare i suoi figli invece di trattarli come contenuto di riserva, e ogni elemento da disegnare riceve l’attributo drawable. L’HTML vive dentro il canvas, impaginato ma invisibile finché non lo disegni.

Il secondo è l’evento paint. Il browser lo lancia sul canvas ogni volta che cambia l’aspetto di un elemento drawable: uno stile, il testo di un campo, il focus o un hover. Dentro il gestore si disegna. Basta questo per un esempio completo in 2D:

<canvas id="tela" width="600" height="400" content="drawable">
  <div id="scheda" drawable>
    <h2>Dettagli del prodotto</h2>
    <p>7,60 €</p>
    <label>Email <input type="email"></label>
    <button>Acquista</button>
  </div>
</canvas>

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

  canvas.addEventListener('paint', () => {
    ctx.reset();
    ctx.rotate(-0.1);
    ctx.drawElementImage(scheda, 40, 80);
  });
</script>

La scheda appare ruotata dentro il canvas eppure il campo email accetta la digitazione, il pulsante riceve il clic nel punto in cui si vede e il testo si può selezionare. In 2D il browser sincronizza da solo la geometria: sa dove hai disegnato l’elemento e porta lì i clic e i riquadri del focus.

La stessa scheda disegnata con fillText e con drawElementImage in un canvas 2D
La nostra prova: a sinistra, la scheda disegnata con fillText() (il testo esce dai bordi, niente grassetto né allineamento da destra a sinistra); a destra, lo stesso HTML con drawElementImage(), con andata a capo, link e una vera selezione di testo.

Alcuni dettagli da sapere fin dall’inizio:

  • drawElementImage() accetta le stesse varianti di drawImage(): posizione; posizione e dimensioni; ritaglio della sorgente; ritaglio più destinazione (3, 5, 7 e 9 argomenti).
  • I drawable si possono annidare. Un pulsante drawable dentro una scheda drawable resta fuori dal disegno della scheda e si disegna con una chiamata propria, così si può animare separatamente senza rompere la semantica dell’HTML.
  • L’evento porta changedElements, gli elementi cambiati dall’ultimo disegno, per non dover ridisegnare tutto.
  • Le modifiche al DOM fatte dentro paint si vedono nel fotogramma successivo. Se qualcosa deve apparire in quello attuale, va cambiato in requestAnimationFrame, che viene eseguito prima.
  • Per le animazioni continue, canvas.requestPaint() chiede un altro paint nel fotogramma successivo anche se non è cambiato nulla, come requestAnimationFrame().
flowchart TD
    A["Cambia l'HTML: stile, testo, focus"] --> B["Evento paint sul canvas"]
    B --> C["2D: drawElementImage"]
    B --> D["WebGL: texElementSubImage2D"]
    B --> E["WebGPU: drawElementImageToTexture"]
    D --> F["updateElementGeometry"]
    E --> F
    C --> G["Clic, focus e accessibilità dove è stato disegnato"]
    F --> G

WebGL: l’HTML come texture

In 3D l’idea è la stessa, ma l’elemento non si disegna nel canvas: si carica in una texture. Prima si riserva la memoria della texture una sola volta, passando null come dati, e poi, a ogni paint, si copia l’HTML con texElementSubImage2D():

const pannello = document.getElementById('pannello'); // l'elemento drawable
const gl = canvas.getContext('webgl2');
const tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);

// Riserva la memoria una volta (o quando cambiano le dimensioni dell'elemento)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, 640, 400, 0,
              gl.RGBA, gl.UNSIGNED_BYTE, null);

canvas.addEventListener('paint', () => {
  // 1. L'HTML nella texture
  gl.bindTexture(gl.TEXTURE_2D, tex);
  gl.texElementSubImage2D(gl.TEXTURE_2D, 0, 0, 0, pannello);

  // 2. La scena, con la texture sulla geometria che preferisci
  disegnaScena();

  // 3. Dove è finito il pannello, in pixel CSS del canvas
  const canvasTransform = new DOMMatrix().translate(x, y).scale(scala);
  canvas.updateElementGeometry(pannello, { canvasTransform });
});

Il terzo passo è quello che si dimentica più spesso. In 2D il browser sa dove è finito l’elemento; in WebGL no, perché lo shader può mettere quella texture ovunque. Con updateElementGeometry() glielo si dice, così clic, focus e strumenti di accessibilità puntano al posto giusto. La documentazione di Chrome lo mostra con una scheda 3D che continua a ruotare quando il codice smette di chiamare questo metodo: i clic continuano a cadere dove si trovava prima. Per gli elementi di sfondo c’è l’opzione preserveHitTestOrder: true, che evita che l’ultimo aggiornato passi davanti a tutti nel ricevere i clic.

Un vero modulo HTML trasformato in texture WebGL, in prospettiva e con riflesso
Un vero modulo HTML come texture WebGL, in prospettiva e riflesso sul pavimento. Il testo selezionato nel campo di ricerca viaggia con la texture.

Una volta sulla GPU, quella texture è una come le altre: si può curvare su una mesh, illuminare, riflettere o passare in uno shader. L’immagine di copertina di questo articolo è lo stesso modulo ondulato da un vertex shader e ombreggiato lungo la curva.

In WebGPU la chiamata è drawElementImageToTexture(), sulla coda del dispositivo, e la geometria si sincronizza allo stesso modo, con updateElementGeometry():

canvas.addEventListener('paint', () => {
  device.queue.drawElementImageToTexture(
    { source: pannello },
    { texture: texture }
  );
  disegnaScena();
  canvas.updateElementGeometry(pannello, { canvasTransform });
});

Cosa conserva l’HTML dentro il canvas

Secondo Chrome, il contenuto disegnato così mantiene:

  • L’impaginazione completa del testo: più righe, testo bidirezionale e CSS.
  • Selezione, copia e incolla, e menu contestuale, anche su una superficie 3D.
  • L’albero di accessibilità, per gli screen reader.
  • Trova nella pagina, traduzione e compilazione automatica, anche dentro texture WebGL e WebGPU.
  • Le estensioni: una che sostituisce testi cambia anche quello che si vede sulla mesh 3D.
  • L’ispezione e la modifica degli stili in DevTools.
  • L’indicizzazione da parte di crawler e agenti di IA, che vedono DOM invece di un’immagine.
  • L’esportazione dell’intero canvas con toDataURL(), toBlob() o, in video, captureStream().

Sull’accessibilità c’è una sfumatura. Un elemento drawable non ancora disegnato, o la cui geometria non è stata aggiornata, arriva all’albero di accessibilità senza posizione: lo screen reader può leggerlo, ma una lente d’ingrandimento sa che non è sullo schermo. Se lasciate nel canvas viste che non disegnate più, conviene toglierle dal DOM o marcarle con aria-hidden="true" o inert.

Quello che il browser non disegna

C’è una restrizione che non compare negli esempi e che può riservare sorprese. Siccome il contenuto di un canvas si può leggere pixel per pixel (con getImageData() o da WebGL), l’API non disegna nulla che permetta di spiare l’utente. Secondo la proposta del WICG restano fuori:

  • I contenuti di un’altra origine: immagini, iframe, sfondi CSS con url(), SVG esterni o canvas «contaminati». Un iframe della stessa origine si disegna, ma non quello che contiene da altre origini.
  • Colori, tema e preferenze di sistema.
  • I segni del correttore ortografico e grammaticale.
  • Se un link è già stato visitato.
  • I dati di compilazione automatica non ancora confermati.
  • L’antialiasing subpixel del testo, i sottotitoli e i controlli video e le finestre del metodo di input (IME).

In pratica: se la vostra scheda mostra un’immagine servita da una CDN su un altro dominio, nel canvas non apparirà. Le evidenziazioni di Trova nella pagina, il cursore del testo e l’aspetto dei controlli dei moduli invece si disegnano.

Pixel nitidi: il dettaglio che si dimentica sempre

Se il testo esce sfocato, quasi sempre è perché la griglia del canvas non coincide con i pixel fisici dello schermo. L’HTML si impagina in pixel CSS, il canvas ha la sua griglia (width e height) e lo schermo ha i suoi pixel reali; ogni conversione che non torna ammorbidisce il testo un’altra volta. Chrome consiglia di dimensionare il canvas con un ResizeObserver su device-pixel-content-box:

let scalaX = 1, scalaY = 1; // pixel del canvas per pixel CSS

const observer = new ResizeObserver(([entry]) => {
  const dpc = entry.devicePixelContentBoxSize;
  const { width, height } = entry.contentRect;
  canvas.width = dpc ? dpc[0].inlineSize : Math.round(width * devicePixelRatio);
  canvas.height = dpc ? dpc[0].blockSize : Math.round(height * devicePixelRatio);
  scalaX = canvas.width / width;
  scalaY = canvas.height / height;
});
observer.observe(canvas, { box: 'device-pixel-content-box' });

Attenzione alle unità: le coordinate di destinazione di drawElementImage() sono in pixel del canvas, mentre offsetWidth, getBoundingClientRect(), gli eventi del puntatore e il canvasTransform di updateElementGeometry() sono in pixel CSS. Per centrare un elemento bisogna moltiplicare le sue misure per scalaX e scalaY. E siccome si disegna il border box dell’elemento, box-sizing: border-box evita che il padding lo renda più grande del previsto.

Se l’avete provato durante l’origin trial, controllate il codice

Chrome 150 e Chrome 155 hanno cambiato l’API in base ai commenti degli sviluppatori e dei gruppi di standardizzazione. Il codice scritto per le prime versioni non funziona così com’è:

PrimaAdesso (Chrome 155+)
<canvas layoutsubtree><canvas content="drawable"> e drawable su ogni elemento
Si disegnavano solo i figli diretti del canvasQualsiasi discendente con drawable, annidabili
drawElementImage() restituiva una matrice da copiare in style.transformNon restituisce nulla: in 2D si sincronizza da solo (salvo con preserveElementGeometry: true)
WebGL: texElementImage2D()texElementSubImage2D(), con offset e memoria riservata prima con texImage2D(…, null)
WebGPU: copyElementImageToTexture()drawElementImageToTexture(), con sourceX, sourceWidth… e size

Il blog di Chrome ha pubblicato frammenti per convivere con più versioni insieme, per esempio controllando 'texElementSubImage2D' in WebGL2RenderingContext.prototype prima di scegliere la chiamata.

Rilevare il supporto e avere un piano B

Per ora è una cosa di Chrome. Mozilla ha aperta la richiesta di posizione sulla proposta dal settembre 2024 e WebKit dal marzo 2026, entrambe senza un verdetto. Qualsiasi uso in produzione richiede il rilevamento delle funzionalità. Quello proposto da Chrome:

export function supportsHtmlInCanvas() {
  if (typeof document === 'undefined') return false;
  const canvas = document.createElement('canvas');
  canvas.setAttribute('content', 'drawable');
  return canvas.content === 'drawable';
}

Se restituisce false, le alternative sono quelle di sempre: un’interfaccia diversa, HTML che galleggia sopra il canvas, la libreria three-html-render, html2canvas o dom-to-image. Nessuna di queste librerie è di Google: sono progetti della comunità.

Three.js, Babylon.js, PixiJS e PlayCanvas

Scrivere a mano le matrici di ogni pannello stanca presto, e i motori si sono già mossi:

  • Three.js: supporto sperimentale con THREE.HTMLTexture.
  • Babylon.js: la classe BABYLON.HtmlTexture.
  • PixiJS: HTMLSource dalla versione 8.19.0.
  • PlayCanvas: texture alimentate dall’HTML con setSource().
  • CanvasUI: una libreria open source di componenti pensata per HTML-in-Canvas.

Con Three.js, mettere un elemento HTML sulle facce di un cubo è così:

const material = new THREE.MeshBasicMaterial();
material.map = new THREE.HTMLTexture(pannelloHtml);
const cubo = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), material);
scene.add(cubo);

Il grado di maturità varia da un motore all’altro; prima di puntare su uno, conviene guardare la sua documentazione.

A cosa serve (e a cosa no)

Dove si noterà di più è nelle applicazioni che vivono già dentro un canvas: editor grafici, lavagne, fogli di calcolo o strumenti di design che oggi disegnano da soli i propri menu e campi di testo. Lì possono usare HTML vero, guadagnare accessibilità e liberarsi del codice che imitava quello che il browser sa già fare. Anche nei giochi e in WebXR, con terminali dentro il gioco o libri da leggere nella scena; nei grafici accelerati dalla GPU con legende ed etichette su più righe; nei configuratori di prodotto con prezzo e opzioni attaccati al modello 3D; e negli strumenti creativi che mescolano tipografia HTML e shader ed esportano il risultato in immagine o video.

Per un sito normale non aggiunge nulla. Un blog, un negozio o un pannello di amministrazione stanno meglio nel DOM di sempre, che è più semplice, più veloce da mantenere e funziona in tutti i browser. HTML-in-Canvas ha senso quando c’è già un canvas e finora l’HTML doveva restarne fuori.

Come abbiamo fatto le immagini

Le immagini di questo articolo non sono fotomontaggi: sono catture di due piccole demo che abbiamo scritto per provare l’API. Le abbiamo fatte con Chrome 156, che ha già l’implementazione dietro il flag chrome://flags/#canvas-draw-element (o avviando il browser con --enable-blink-features=CanvasDrawElement); in Chrome 157 non serve niente. La demo WebGL non usa nessun motore, solo WebGL2: una mesh a griglia di 64×64 con il modulo come texture, il riflesso disegnato specchiando la geometria e uno shader a onda per la copertina. Quello che ci ha sorpreso di più è stato vedere la selezione del testo del campo di ricerca viaggiare con la texture, piegarsi con l’onda e comparire nel riflesso sul pavimento senza scrivere una sola riga per farlo.

Fonti