HTML in Canvas: concetti tecnici

L'API HTML-in-Canvas consente di eseguire il rendering di sottoalberi DOM interattivi e accessibili direttamente in contesti canvas 2D, WebGL o WebGPU.

Questa guida illustra i concetti tecnici di HTML-in-Canvas comuni a tutti i contesti di Canvas:

Prepara il canvas per il rendering dell'HTML

Per eseguire il rendering degli elementi HTML all'interno di un <canvas>, configura i seguenti due attributi:

  • Imposta content="drawable" su <canvas>: in questo modo, l'elemento canvas viene attivato per il layout dei nodi DOM discendenti anziché trattarli esclusivamente come contenuti nascosti.
  • Imposta drawable sugli elementi target:contrassegna un elemento come radice di disegno indipendente idonea per il rendering del canvas.
<canvas id="mainCanvas" width="600" height="400" content="drawable">
  <div id="parentElement" class="card" drawable>
    <h2>Container Title</h2>
    <p>Descriptive text inside the parent element.</p>
    <!-- Nested drawable: excluded from parentElement and drawn independently -->
    <button id="childElement" class="btn" drawable>Action Button</button>
  </div>
</canvas>

Comprendi il sottoalbero disegnabile

Quando prepari l'HTML per il rendering in un canvas, devi decidere quali elementi devono essere disegnati in modo indipendente. Ogni elemento drawable rappresenta una radice di disegno indipendente e il relativo sottoalbero disegnabile.

Un sottoalbero disegnabile è costituito da un elemento con l'attributo drawable e tutti i suoi discendenti, esclusi eventuali discendenti nidificati che contengono anche drawable, ad esempio:

<canvas content="drawable">
  <!-- Subtree A (Renders parent container and text) -->
  <div id="parentElement" drawable>
    <h2>Parent Card Heading</h2>
    <p>Card body text content.</p>
    <!-- Subtree B (Excluded from Subtree A and drawn separately) -->
    <button id="nestedChildElement" drawable>
      Clickable Button
    </button>
  </div>
</canvas>
Elemento Colora confine Comportamento
#parentElement (albero secondario A) Riquadro di sfondo, intestazione e paragrafo Visualizzato quando viene chiamato drawElementImage(parentElement, ...)
#nestedChildElement (Subtree B) Superficie e testo del pulsante Escluso dal sottoalbero #parentElement; visualizzato in una chiamata drawElementImage(nestedChildElement, ...) separata
Un pulsante drawable è nidificato all'interno di una scheda drawable e ciascuno viene disegnato dalla propria chiamata drawElementImage().

La nidificazione degli elementi disegnabili ti consente di mantenere le relazioni HTML semantiche, offrendoti al contempo la libertà di stratificare, posizionare o animare gli elementi secondari in modo indipendente sulla scena del canvas.

Informazioni sull'evento paint

L'API HTML-in-Canvas introduce l'evento paint. Il browser invia automaticamente un evento paint su <canvas> ogni volta che il layout, il testo o gli stili CSS cambiano su uno dei suoi discendenti drawable.

canvas.addEventListener("paint", (event) => {
  // Clear the viewport
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  // Redraw elements individually
  ctx.drawElementImage(parentElement, 20, 20);
  ctx.drawElementImage(childElement, 40, 90);
});

L'evento paint invia un'istanza di CanvasPaintEvent, che estende il DOM standard Event e ha una proprietà changedElements che contiene solo i discendenti drawable che sono cambiati visivamente (ad esempio, modifiche allo stile, variazioni del layout, modifiche al testo o modifiche allo stato attivo) dall'ultimo paint.

canvas.addEventListener("paint", (event) => {
  // Array of changed elements
  for (const el of event.changedElements) {
    console.log(`Element #${el.id} visually changed and needs repainting`);
  }
});
Il browser attiva la pittura sul canvas ogni volta che cambia il rendering di un discendente disegnabile.

Ciclo di vita dei frame

I comandi di disegno del canvas eseguiti all'interno dell'evento paint vengono visualizzati nell'aggiornamento del rendering corrente, ma le modifiche al DOM apportate all'interno dell'evento paint non vengono visualizzate fino al frame successivo:

canvas.addEventListener("paint", () => {
  // Renders in the CURRENT rendering update:
  ctx.drawElementImage(parentElement, 20, 20);

  // Modifying DOM styles inside paint schedules a new layout cycle;
  // changes will appear in the NEXT frame:
  parentElement.style.backgroundColor = "#e0f2fe";
});

Canvas nidificati

Se la pagina contiene elementi <canvas content="drawable"> nidificati, il browser invia gli eventi paint in ordine inverso rispetto all'albero DOM (i discendenti vengono attivati prima degli antenati). In questo modo, gli elementi canvas secondari vengono visualizzati prima che gli elementi canvas principali li compongano.

Supponiamo che nell'esempio seguente l'elemento nestedElement interno debba cambiare visivamente (ad esempio, il colore di sfondo CSS cambia quando l'utente tiene il puntatore sopra l'elemento). Innanzitutto, l'evento paint viene attivato sul canvas nestedCanvas. Il listener cancella il buffer secondario e chiama ctx.drawElementImage(nestedElement, ...) per eseguire il rendering dello stato aggiornato. Poi, l'evento paint si attiva su parentCanvas. Il suo listener disegna wrapperElement (che contiene il canvas nestedCanvas già aggiornato) nella scena.

<!-- Outer parent canvas -->
<canvas id="parentCanvas" width="800" height="600" content="drawable">
  <!-- Wrapper container for nested content -->
  <div id="wrapperElement" drawable>

    <!-- Nested child canvas -->
    <canvas id="nestedCanvas" width="300" height="200" content="drawable">
      <div id="nestedElement" class="badge" drawable>
        Inner Child Content
      </div>
    </canvas>

  </div>
</canvas>

<script>
const parentCanvas = document.getElementById("parentCanvas");
const parentCtx = parentCanvas.getContext("2d");

const nestedCanvas = document.getElementById("nestedCanvas");
const nestedCtx = nestedCanvas.getContext("2d");

const nestedElement = document.getElementById("nestedElement");
const wrapperElement = document.getElementById("wrapperElement");

// ============================================================================
// 1. Child Canvas Listener (Fires FIRST)
// ============================================================================
nestedCanvas.addEventListener("paint", (event) => {
  // Clear and redraw the child canvas with the updated inner element
  nestedCtx.clearRect(0, 0, nestedCanvas.width, nestedCanvas.height);
  nestedCtx.drawElementImage(nestedElement, 10, 10);

  console.log("1. Child nested canvas rendered fresh pixels.");
});

// ============================================================================
// 2. Parent Canvas Listener (Fires SECOND)
// ============================================================================
parentCanvas.addEventListener("paint", (event) => {
  // Clear and redraw the parent canvas scene
  parentCtx.clearRect(0, 0, parentCanvas.width, parentCanvas.height);

  // Draws the wrapper container (including the freshly painted nestedCanvas)
  parentCtx.drawElementImage(wrapperElement, 50, 50);

  console.log("2. Parent canvas composited the complete scene.");
});
</script>
Quando gli elementi <canvas content="drawable"> sono nidificati, gli eventi di disegno vengono attivati in ordine inverso rispetto all'albero: il canvas nidificato viene disegnato per primo, in modo che il canvas principale possa disegnare i suoi pixel aggiornati.

Attiva un evento paint

Chiama requestPaint() per pianificare un singolo evento paint nel frame successivo, anche se non sono stati modificati testo o stili DOM. Questo può essere utilizzato per il rendering di scene interattive con elementi in movimento, ad esempio:

let animatedX = 0;

canvas.addEventListener("paint", () => {
  animatedX += 1.5; // Move the element horizontally
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.drawElementImage(staticElement, 20, 20);
  ctx.drawElementImage(movingElement, animatedX, 200);
  // Request canvas paint on each rendering update for smooth animation
  canvas.requestPaint();
});

canvas.requestPaint();

Aggiorna la geometria di un elemento

Quando disegni un elemento HTML in un canvas, il codice di disegno decide dove appare l'elemento e quanto è grande. A differenza dei normali contenuti HTML, la posizione e le dimensioni dell'elemento sullo schermo, ovvero la sua geometria, non possono essere determinate solo dal suo stile calcolato. Il browser ha bisogno della geometria di un elemento per:

  • Invia eventi di input come clic, tocchi e movimenti del puntatore all'elemento corretto. Questo processo è chiamato hit test.
  • Restituisci risultati accurati dalle query di layout, ad esempio getBoundingClientRect().
  • Segnala i limiti dell'elemento alle tecnologie per la disabilità, come le lenti d'ingrandimento.

In 2D, drawElementImage() aggiorna automaticamente la geometria per impostazione predefinita. Gli argomenti che passi, insieme allo stile calcolato dell'elemento e alla trasformazione corrente del contesto, sono sufficienti per determinare dove viene visualizzato l'elemento.

Nei contesti WebGL e WebGPU, il browser non può determinare la geometria. Un elemento HTML viene copiato in una texture e il programma shader può quindi mappare la texture sulla scena in qualsiasi modo arbitrario. Per indicare al browser dove viene visualizzato l'elemento, passa una trasformazione a updateElementGeometry().

Quando il codice smette di chiamare updateElementGeometry(), il browser non sa più dove si trovano gli elementi HTML sulla scheda 3D. La scheda continua a girare, ma i clic continuano ad atterrare dove si trovava.

Per aggiornare la geometria, passa una trasformazione spaziale al metodo. Scopri come creare una trasformazione per contesti diversi:

Gestisci l'ordine di sovrapposizione degli hit test

Per impostazione predefinita, ogni volta che la geometria di un elemento viene aggiornata, il browser la porta in cima allo stack di interazione (ultimo disegno = target di clic più in alto). Puoi controllare questo comportamento utilizzando preserveHitTestOrder:

// Updates the backdrop without stealing clicks from foreground elements
canvas.updateElementGeometry(backgroundElement, {
  canvasTransform: customTransform,
  preserveHitTestOrder: true
});
Opzione Comportamento Caso d'uso
preserveHitTestOrder: false (valore predefinito) Sposta l'elemento in cima allo stack di hit test. Elementi in primo piano che devono ricevere i clic per primi.
preserveHitTestOrder: true Lascia l'elemento alla priorità di impilamento corrente. Elementi in background che devono essere aggiornati dopo gli elementi in primo piano.

Reimposta geometria

Per annullare il disegno di un elemento senza rimuoverlo dal DOM, chiama canvas.clearElementGeometry(). In questo modo, la trasformazione del canvas dell'elemento viene reimpostata e l'elemento viene contrassegnato come fuori schermo nell'albero di accessibilità:

// Remove from canvas hit-testing and mark as offscreen
canvas.clearElementGeometry(childElement);

Dimensioni e unità

Quando disegni HTML in un canvas, il browser opera contemporaneamente in tre sistemi di coordinate: pixel CSS, pixel del dispositivo e pixel della griglia del canvas.

I pixel CSS vengono utilizzati per misurare e posizionare i contenuti durante il layout. Il browser li scala quindi di devicePixelRatio per rasterizzare la pagina in pixel del dispositivo fisico. Poiché i pixel CSS sono un'unità logica, le posizioni e le dimensioni spesso finiscono per essere frazioni di un pixel del dispositivo (ad esempio, 10,5 px CSS × un DPR di 1,5 = 15,75 px del dispositivo). Uno schermo può colorare solo pixel interi, quindi il browser deve risolvere tutto ciò che si trova tra di loro. In due modi:

  • Aggancio: i limiti della casella, i bordi e gli offset dei livelli vengono arrotondati al pixel intero più vicino del dispositivo per mantenerli nitidi.
  • Anti-aliasing: per glifi di testo, curve e bordi trasformati, la copertura parziale viene fusa con i pixel adiacenti in modo che appaiano comunque uniformi e nella posizione corretta.

Un elemento <canvas> ha una propria griglia di pixel, impostata dagli attributi width e height, che è separata sia dai pixel CSS sia dai pixel del dispositivo. Per visualizzarla, il browser ricampiona la bitmap del canvas sulla griglia di pixel fisici, combinando i pixel del canvas adiacenti per riempire ogni pixel del dispositivo. Per scoprire come il browser mappa i pixel CSS, canvas e del dispositivo, consulta Rendering perfetto al pixel con devicePixelContentBox.

Tutti gli elementi HTML drawable sono disposti in pixel CSS. Poiché i contenuti disegnati in un canvas possono essere sottoposti a due conversioni, dai pixel CSS alla griglia del canvas e poi dalla griglia del canvas ai pixel fisici, ogni passaggio che non si allinea esattamente aggiunge un altro ciclo di fusione e il risultato può apparire sfocato. Per evitare questo problema, fai in modo che la griglia del canvas corrisponda alla griglia dei pixel fisici. Questo serve a due scopi:

  • Rendering coerente: se il canvas viene mappato 1:1 sui pixel fisici, la conversione dei pixel CSS in pixel del canvas produce lo stesso risultato della conversione dei pixel CSS in pixel fisici da parte del browser per l'HTML normale.
  • Nessuna fusione aggiuntiva: se il canvas viene mappato 1:1 sui pixel fisici, la conversione in pixel del dispositivo non aggiunge sfocatura.

L'immagine seguente mostra una conversione dei pixel che genera contenuti sfocati. Le tre griglie non sono allineate: l'elemento canvas è di 360 × 180 px CSS e le dimensioni del canvas sono di 300 × 150 pixel della griglia del canvas. Con un rapporto pixel del dispositivo di 1,5, l'area di disegno copre 540 x 270 pixel del dispositivo. Un pixel CSS si estende su 1,5 pixel del dispositivo e un pixel del canvas si estende su 1,2 pixel CSS o 1,8 pixel del dispositivo (riquadro 1). Quando il cerchio viene disegnato, la sua posizione e il suo raggio sono in pixel canvas, non in pixel CSS, quindi il browser lo disegna nella bitmap canvas 300 × 150 e applica l'anti-aliasing al bordo (riquadro 2). Per visualizzare il canvas, il browser estende questa bitmap su 540 × 270 pixel del dispositivo, quindi deve eseguire l'interpolazione: ogni pixel del dispositivo prende un mix ponderato dei pixel del canvas più vicini, che contengono già il bordo con anti-aliasing. Il bordo viene sfumato una seconda volta e si estende su diversi pixel del dispositivo, quindi il cerchio appare sfocato (riquadro 3). Questo problema potrebbe essere evitato se la griglia del canvas corrispondesse ai pixel del dispositivo in un rapporto 1:1. Il browser applica quindi l'anti-aliasing al bordo una sola volta, direttamente sulla griglia del dispositivo, e visualizza la bitmap senza ricampionamento, in modo che il bordo rimanga nitido (riquadro 4):

Diagramma a quattro pannelli che confronta le griglie di pixel per un bordo curvo: 1. Griglie CSS (360 x 180), canvas (300 x 150) e dispositivo (540 x 270) non allineate. 2. La curva con anti-aliasing sulla griglia di un canvas di 300 x 150. 3. La bitmap viene interpolata sulla griglia del dispositivo 540 x 270, applicando l'anti-aliasing una seconda volta e sfocando il bordo. 4. Una tela di 540 x 270 che corrisponde alla griglia del dispositivo 1:1, con anti-aliasing una volta per un bordo nitido.
In che modo le griglie di pixel CSS, canvas e del dispositivo non allineate causano un doppio anti-aliasing (pannelli 1-3), rispetto a una griglia canvas-dispositivo 1:1 (pannello 4).

Per un rendering nitido, la griglia del canvas deve corrispondere al numero di pixel del dispositivo coperti dal canvas: 540 × 270 nell'esempio precedente.

Confronto fianco a fianco di una scheda HTML con la scritta &quot;Hello from the DOM!&quot; Questa scheda è un codice HTML disegnato in un canvas.' A sinistra: disegnato sulla griglia canvas predefinita di 300 x 150, con un aspetto sfocato. A destra: disegnato su una griglia canvas di 800 x 400 ridimensionata con ResizeObserver, con un aspetto nitido.
Lo stesso canvas, 400 x 200 pixel CSS, su uno schermo con un DPR di 1,5. A sinistra: la griglia del canvas 400 x 200. A destra: adattata al DPR, una griglia 600 x 300.

Dimensiona la griglia del canvas con un osservatore di ridimensionamento

La dimensione corretta della griglia dipende dal dispositivo e può cambiare quando cambia il layout, quando l'utente aumenta lo zoom della pagina o quando sposta la finestra su un altro display. Quindi, invece di impostare come hardcoded le dimensioni della griglia, lascia che sia il browser a dirti quali dovrebbero essere.

Per mantenere la griglia del canvas sincronizzata con il canvas sullo schermo, dimensiona sempre con un ResizeObserver e regola la griglia del canvas su devicePixelContentBoxSize:

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

Converti i pixel CSS in coordinate della griglia del canvas

Un gestore paint spesso combina le misurazioni del DOM con le chiamate di disegno, ad esempio per posizionare un elemento in base alle sue dimensioni. I due utilizzano unità diverse:

  • I pixel del canvas descrivono la griglia del canvas, che viene dimensionata in base a canvas.width e canvas.height. Ad esempio, i parametri dx, dy, dwidth e dheight dei metodi di disegno drawElementImage() o altri parametri delle API canvas, come fillRect() e translate().
  • I pixel CSS descrivono la pagina. Le misurazioni DOM come offsetWidth e clientWidth e le coordinate degli eventi del puntatore come offsetX sono in pixel CSS.

Di seguito è riportata una panoramica delle unità utilizzate dai vari parametri dell'API:

Valore Unità
canvas.width e canvas.height Pixel della griglia del canvas
Parametri di disegno 2D, tra cui dx, dy, dwidth e dheight di drawElementImage() Pixel della griglia del canvas
width e height restituiti da canvas.getElementImageDefaultSize() Pixel della griglia del canvas
Rettangoli di origine: sx, sy, swidth e sheight di drawElementImage() e texElementSubImage2D(); sourceX, sourceY, sourceWidth e sourceHeight di drawElementImageToTexture() Pixel CSS
Dimensioni texture: width e height opzioni di texElementSubImage2D(); size di drawElementImageToTexture() Pixel di texture. Per impostazione predefinita, le dimensioni dell'elemento in pixel della griglia del canvas, arrotondate per eccesso
Geometria dell'elemento: getElementTransform() e le opzioni canvasTransform e canvasClip di updateElementGeometry() Pixel CSS
Misurazioni DOM, come offsetWidth, clientWidth, getBoundingClientRect(), coordinate degli eventi del puntatore e contentBoxSize di una voce di ResizeObserver Pixel CSS
devicePixelContentBoxSize di una voce di ResizeObserver Pixel del dispositivo

Se utilizzi le unità sbagliate, le dimensioni dei contenuti HTML disegnati nel canvas potrebbero non soddisfare le tue aspettative. Devi convertire il valore dei pixel CSS in pixel della griglia del canvas quando disegni l'HTML nel canvas. Per farlo, calcola il rapporto tra le dimensioni della griglia del canvas e le dimensioni CSS del canvas: canvas.width / cssContentWidth in orizzontale e canvas.height / cssContentHeight in verticale. Il tuo osservatore di ridimensionamento è un luogo comodo per calcolare il rapporto come segue:

// The number of canvas grid pixels per CSS pixel
let scaleX = 1;
let scaleY = 1;

const observer = new ResizeObserver(([entry]) => {
  const dpc = entry.devicePixelContentBoxSize;
  const cssContentWidth = entry.contentRect.width;
  const cssContentHeight = entry.contentRect.height;

  // Best practice: make the canvas grid match the physical pixel grid
  if (dpc) {
    canvas.width = dpc[0].inlineSize;
    canvas.height = dpc[0].blockSize;
  } else {
    canvas.width = Math.round(cssContentWidth * window.devicePixelRatio);
    canvas.height = Math.round(cssContentHeight * window.devicePixelRatio);
  }

  scaleX = canvas.width / cssContentWidth;
  scaleY = canvas.height / cssContentHeight;
});

const supportsDevicePixelContentBox =
  typeof ResizeObserverEntry !== 'undefined' &&
  'devicePixelContentBoxSize' in ResizeObserverEntry.prototype;
const options = supportsDevicePixelContentBox ? { box: 'device-pixel-content-box' } : {};
observer.observe(canvas, options);

Poi, moltiplica i valori dei pixel CSS per scaleX e scaleY prima di passarli al metodo di disegno. Ad esempio, per centrare un elemento disegnabile nel canvas:

const ctx = canvas.getContext('2d');
const card = document.getElementById('card');

canvas.onpaint = () => {
  ctx.reset();

  // offsetWidth and offsetHeight are in CSS pixels.
  // Convert them to canvas grid coordinates.
  const width = card.offsetWidth * scaleX;
  const height = card.offsetHeight * scaleY;
  const x = (canvas.width - width) / 2;
  const y = (canvas.height - height) / 2;

  // drawElementImage() takes canvas grid coordinates.
  ctx.drawElementImage(card, x, y);
};

Infine, poiché drawElementImage() disegna il bordo dell'elemento, utilizza box-sizing: border-box per includere spaziatura interna e bordo in width e height della scheda, in modo che la scheda venga disegnata con le dimensioni impostate:

#card {
  /* Make width and height include padding and border */
  box-sizing: border-box;
  width: 200px;
  padding: 16px;
}

Informazioni sull'accessibilità di HTML in Canvas

In genere, i contenuti HTML di un <canvas content="drawable"> sono esposti all'accessibilità nello stesso modo del DOM normale, il che significa che sono rappresentati nell'albero di accessibilità, a meno che non siano nascosti esplicitamente utilizzando attributi come aria-hidden="true" o inert. Tuttavia, esiste un'eccezione fondamentale: un elemento drawable la cui geometria visualizzata non è stata ancora stabilita o aggiornata.

Un elemento drawable all'interno di un elemento <canvas content="drawable"> non ha una geometria visualizzata se:

  • Non è ancora stato eseguito il rendering con drawElementImage(), che aggiorna automaticamente la geometria per impostazione predefinita.
  • La sua geometria non è stata aggiornata esplicitamente con updateElementGeometry().

Se un elemento drawable non ha una geometria di rendering, questo elemento drawable e il relativo sottoalbero drawable vengono segnalati all'albero di accessibilità senza informazioni sulla geometria. Ciò è utile, ad esempio, per consentire agli screen reader di accedere ai contenuti semantici, segnalando al contempo agli strumenti di assistenza visiva (come le lenti d'ingrandimento) di filtrare gli elementi non disegnati.

L'esempio seguente conserva la semantica di una figura, di un'immagine e di una didascalia, mentre l'immagine e la figura vengono disegnate separatamente:

<canvas id="canvas" width="400" height="300" content="drawable">
  <figure id="figure">
    <img drawable id="image" src="..." alt="image alt text" />
    <figcaption drawable id="caption">A caption</figcaption>
  </figure>
</canvas>
<script>
  const ctx = canvas.getContext('2d');

  canvas.onpaint = () => {
    ctx.reset();
    // Note: Because `preserveElementGeometry` defaults to false, the
    // `drawElementImage` call automatically updates geometry, ensuring
    // the image and caption have geometry information for accessibility.
    ctx.drawElementImage(image, 0, 0);
    ctx.drawElementImage(caption, 0, 200);
  };
</script>

Strategie di riserva

L'API HTML-in-Canvas è stata rilasciata in Chrome 157 e potrebbe non essere supportata in tutti i browser. Per assicurarti che la tua soluzione funzioni senza problemi per tutti, controlla se il browser supporta l'API e, se necessario, utilizza una soluzione alternativa:

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

Se l'API non è supportata, a seconda degli obiettivi di implementazione, puoi scegliere una delle seguenti alternative:

Passaggi successivi