Les applications Web doivent souvent capturer et exporter du contenu HTML enrichi (tableaux de bord personnalisés, documents stylisés ou graphiques interactifs, par exemple) sous forme d'images statiques ou d'enregistrements vidéo. Auparavant, pour y parvenir, il fallait utiliser des bibliothèques tierces volumineuses qui analysaient manuellement les nœuds DOM et les propriétés CSS pour reconstruire un fac-similé visuel sur un canevas. Cette approche est coûteuse en ressources de calcul, sujette aux erreurs et ne prend souvent pas en charge les fonctionnalités de mise en page CSS modernes. Grâce à l'API HTML-in-Canvas, les développeurs peuvent afficher des éléments DOM réels directement dans le contexte du canevas. Comme le moteur de rendu natif du navigateur peint le sous-arbre HTML avec une précision au pixel près, la capture de la sortie visuelle exacte sous forme d'image ou de flux vidéo est très efficace à l'aide de méthodes de canevas intégrées telles que toDataURL(), toBlob() ou captureStream().
Mise en œuvre
Vérifiez si le navigateur est compatible avec HTML-in-Canvas :
if ('content' in HTMLCanvasElement.prototype) { // Use HTML in Canvas API } else { // Use fallback strategy }Initialisez le canevas pour permettre l'affichage des éléments HTML descendants en ajoutant l'attribut
content="drawable"à l'élément HTML<canvas>. Placez votre contenu HTML dans l'élément<canvas>et ajoutez l'attributdrawableà chaque élément que vous dessinez. Un élément avecdrawablecapture sa sous-arborescence, à l'exception des descendants imbriqués qui sont égalementdrawable:<canvas id="canvas" content="drawable"> <div id="html-content" drawable></div> </canvas>Mettez à l'échelle la grille de votre canevas pour qu'elle corresponde au facteur de scaling de l'appareil afin d'éviter le flou :
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);Affichez le contenu HTML sur le canevas dans un gestionnaire d'événements
canvas.onpaint.Dans un contexte 2D, utilisez la méthode
drawElementImage. Il dessine le dernier instantané d'un élémentdrawableet de son sous-arbre drawable (à l'exclusion des autres enfants drawables), en commençant par la zone de bordure de l'élément, avant les transformations CSS :
canvas.onpaint = () => {
ctx.reset();
// Draw the form element at x:0, y:0
ctx.drawElementImage(form_element, 0, 0);
};
- Dans les contextes WebGL et WebGPU, vous copiez l'élément dans une texture. Définissez la taille de la texture à partir de
canvas.captureElementImage(element). Ses valeurswidthetheightsont exprimées en pixels de la grille du canevas (backing store). Arrondissez-les donc avecMath.ceil().captureElementImage()génère une exception si l'élément n'a pas encore d'enregistrement de peinture : appelezrequestPaint(), puis réessayez lors de l'événementpaintsuivant.
// 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;
}
- Dans le contexte WebGL, préallouez d'abord la texture à l'aide de la méthode
texImage2D. Les extraits de ce guide utilisent un contexte WebGL 2 (canvas.getContext("webgl2")). Allouez de l'espace de stockage de texture en appelanttexImage2D()avecnullau lieu de données de pixels, ce qui réserve de la mémoire GPU de la taille et du format donnés sans importer de pixels. Suivez les règles ci-dessous :- Définissez la taille de la texture à partir de
captureElementImage(), comme indiqué ci-dessus. - N'allouez le niveau 0 que lors de la première importation ou lorsque la taille change, car la réallocation efface la texture. Utilisez un stockage modifiable : le stockage
texStorage2D()immuable ne peut pas être redimensionné lorsque l'élément change de taille.
- Définissez la taille de la texture à partir 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 };
- Ensuite, dans le contexte WebGL, affichez le contenu HTML dans la texture à l'aide de la méthode
texElementSubImage2D. Suivez les règles ci-dessous :- Transmettez
{ width, height }dans la configuration. La copie remplit la texture allouée, quelle que soit la mise à l'échelle du canevas. - Réallouez la texture si la taille a changé, comme indiqué ci-dessus.
- Transmettez
// 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);
}
};
- Dans le contexte WebGPU, utilisez la méthode
drawElementImageToTexture. Suivez les règles ci-dessous :- Créez la texture avec les utilisations
COPY_DSTetRENDER_ATTACHMENT, puis ajoutezTEXTURE_BINDINGpour l'échantillonner dans les nuanceurs. Une utilisation incorrecte est signalée comme une erreur de validation WebGPU, et non comme une exception. - Transmettez la taille de la texture en tant que
size. Sanssize, un élément plus grand est découpé selon la texture. Avecsize, l'élément est redimensionné pour le remplir. - Recréez la texture lorsque la taille de l'élément change, puis recréez les groupes de liaisons qui la référencent.
- Créez la texture avec les utilisations
// 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);
}
};
Lorsque vous utilisez une boucle requestAnimationFrame pour afficher la scène, appelez canvas.requestPaint() dans la boucle pour vous assurer que le contenu HTML est affiché sur le canevas. event.changedElements liste les éléments dont l'affichage a changé. Veillez à ne redessiner l'élément ou à ne réimporter sa texture que si les éléments HTML descendants ont été mis à jour :
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
};
- Synchronisez la géométrie de l'élément.
Le navigateur doit savoir où l'élément HTML est dessiné, afin que sa position DOM corresponde aux pixels dessinés, par exemple pour que les utilisateurs puissent cliquer dessus et saisir du texte. Un canevas ne teste que les éléments dessinés avec la méthode 2D drawElementImage() ou enregistrés avec updateElementGeometry().
Dans le cas du contexte 2D, le navigateur synchronise automatiquement l'élément.
drawElementImage()aligne les limites du test de positionnement DOM et les anneaux de sélection du lecteur d'écran sur les coordonnées dessinées. Vous n'avez donc pas besoin de définirstyle.transform. Pour gérer la géométrie manuellement, par exemple lorsque vous dessinez le même élément plusieurs fois, transmettez{ preserveElementGeometry: true }àdrawElementImage()et appliquez des transformations aveccanvas.updateElementGeometry().Pour le cas 3D avec WebGL ou WebGPU, le canevas ne peut pas déterminer où l'élément HTML est dessiné. Vous devez donc le lui indiquer. Calculez un
DOMMatrixqui mappe la zone de bordure de l'élément, en pixels CSS, au canevas, en pixels CSS. Le navigateur doit mapper l'espace de coordonnées 3D vers l'espace de coordonnées CSS à l'aide d'une transformation de fenêtre d'affichage. Pour ce faire, procédez comme suit :- Normalisez l'élément HTML. Les éléments HTML sont dimensionnés en pixels (par exemple, 200 px de large). Cependant, WebGL traite généralement les objets comme des "carrés unitaires", par exemple, allant de -0,5 à 0,5. Si vous ne normalisez pas, votre bouton de 200 px sera 200 fois plus grand. Cette étape inverse également l'axe Y, car en CSS, le bas est positif, mais en WebGL, le haut est positif.
- Convertissez la matrice MVP WebGL en matrice DOM.
- Mappez sur la fenêtre d'affichage du canevas. Cette étape correspond à la phase de "redimensionnement" : elle étire à nouveau les calculs de l'espace unitaire pour qu'ils correspondent aux dimensions en pixels CSS de votre élément
<canvas>à l'écran, et inverse l'axe Y. - Calculez la transformation finale. Multipliez les matrices dans l'ordre : Viewport * MVP * Normalisation. En les combinant en une seule transformation finale, on obtient une "carte" qui indique au navigateur l'emplacement exact de l'élément HTML pour qu'il s'aligne sur le dessin 3D. Le navigateur effectue la division de perspective.
// 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); }
Transmettez ensuite la transformation à canvas.updateElementGeometry() en tant que canvasTransform. L'élément HTML est alors placé directement au-dessus de ses pixels affichés. Cela garantit que lorsqu'un utilisateur clique sur un bouton ou sélectionne du texte, il interagit réellement avec l'élément HTML réel. Le navigateur utilise également la transformation pour les limites d'accessibilité de l'élément, par exemple pour les anneaux de sélection du lecteur d'écran :
canvas.updateElementGeometry(targetHTMLElement, {
canvasTransform: computeCanvasTransform(canvas, targetHTMLElement, htmlElementMVP),
});
Utilisez ces options pour contrôler les tests de positionnement :
- Chaque appel
updateElementGeometry()déplace l'élément en haut de l'ordre de test de positionnement. Transmettez{ preserveHitTestOrder: true }pour conserver sa position. - Appelez
canvas.clearElementGeometry(element)pour supprimer un élément du test de positionnement, par exemple lorsque vous le masquez.
- Utilisez les méthodes d'exportation de canevas standards telles que
toDataURL(),toBlob()oucaptureStream(). Les données exportées incluront le contenu HTML affiché.
Exemple de code
<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>
Bonnes pratiques
- OBLIGATOIRE : Vérifiez la compatibilité du navigateur avec l'API HTML-in-Canvas avant de l'utiliser.
- OBLIGATOIRE : Ajoutez toujours l'attribut
content="drawable"à l'élément<canvas>. - OBLIGATOIRE : Ajoutez l'attribut
drawableà chaque élément que vous dessinez, y compris les enfants directs du canevas. - OBLIGATOIRE : utilisez un gestionnaire d'événements
onpaintpour afficher le contenu HTML sur le canevas. - OBLIGATOIRE : Utilisez la méthode
drawElementImageen 2D oudrawElementImageToTexturedans WebGPU pour afficher le contenu HTML sur le canevas. - OBLIGATOIRE : Dans WebGL, pré-allouez le tampon de texture avec
texImage2D()une fois (lors de son initialisation ou lorsque la taille de l'élément change). Réallouez la texture uniquement lorsque la taille change et définissezTEXTURE_MIN_FILTERsurLINEAR. - OBLIGATOIRE : dans WebGL, utilisez la méthode
texElementSubImage2Dpour afficher le contenu HTML dans une texture que vous préallouez avectexImage2D(), dimensionnée à partir decaptureElementImage(). - OBLIGATOIRE : dans WebGPU, dimensionnez la texture à partir de
captureElementImage()et ne la recréez que lorsque la taille change. Créez la texture avec l'utilisationCOPY_DSTetRENDER_ATTACHMENT, puis transmettez la taille de la texture en tant quesize. - OBLIGATOIRE : synchronisez la géométrie de l'élément HTML avec le contenu affiché. En 2D,
drawElementImagele fait automatiquement. Dans WebGL et WebGPU, appelezcanvas.updateElementGeometry(element, { canvasTransform }). - OBLIGATOIRE : Utilisez
ResizeObserverpour observer la taille de l'écran et mettre à jour la taille du canevas afin qu'elle corresponde aux pixels de l'appareil. - NE PAS intégrer de contenu interorigine dans un canevas, car cela n'est pas possible.
- N'INITIALISEZ PAS
ResizeObserverdans le gestionnaire d'événementsonpaint, car cela peut entraîner des fuites de mémoire.
Stratégies de remplacement
L'API HTML-in-Canvas n'est actuellement pas compatible avec tous les navigateurs récents. Une stratégie de remplacement est donc généralement nécessaire. Toutefois, compte tenu des avantages de cette API en termes de performances, il est préférable d'utiliser HTML-in-Canvas si le navigateur le permet.
Pour exporter du contenu HTML à partir d'un canevas, utilisez des bibliothèques telles que html2canvas, dom-to-image ou snapdom.
Pour capturer les interactions HTML image par image, par exemple pour le streaming, capturez les mutations DOM à l'aide de bibliothèques telles que rrweb.
Vous pouvez également implémenter un avertissement indiquant que l'exportation de contenus multimédias HTML n'est pas possible dans le navigateur, car celui-ci n'est pas compatible avec HTML-in-Canvas.