Веб-приложениям часто требуется захватывать и экспортировать сложный HTML-контент, например персонализированные панели управления, стилизованные документы или интерактивные диаграммы, в виде статических изображений или видеозаписей. Раньше для этого требовались громоздкие сторонние библиотеки, которые вручную анализировали узлы DOM и свойства CSS, чтобы воссоздать визуальную копию на холсте. Этот подход требует больших вычислительных ресурсов, подвержен ошибкам и часто не поддерживает современные функции макета CSS. С помощью HTML-in-Canvas API разработчики могут отрисовывать реальные элементы DOM непосредственно в контексте холста. Поскольку встроенный механизм рендеринга браузера отрисовывает поддерево HTML с точностью до пикселя, захват точного визуального вывода в виде изображения или видеопотока очень эффективен при использовании встроенных методов холста, таких как toDataURL(), toBlob() или captureStream().
Рекомендации по внедрению
Проверьте, поддерживается ли в браузере HTML-in-Canvas:
if ('content' in HTMLCanvasElement.prototype) { // Use HTML in Canvas API } else { // Use fallback strategy }Инициализируйте холст, чтобы поддерживать отрисовку дочерних элементов HTML. Для этого добавьте атрибут
content="drawable"в элемент HTML<canvas>. Разместите HTML-контент внутри элемента<canvas>и добавьте атрибутdrawableв каждый элемент, который вы рисуете. Элемент сdrawableзахватывает свое поддерево, за исключением вложенных потомков, которые также являютсяdrawable:<canvas id="canvas" content="drawable"> <div id="html-content" drawable></div> </canvas>Чтобы избежать размытия, масштабируйте сетку холста в соответствии с коэффициентом масштабирования устройства:
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);Отрисуйте HTML-контент на холсте в обработчике событий
canvas.onpaint.В 2D-контексте используйте метод
drawElementImage. Она рисует последний снимок элементаdrawableи его поддерево (исключая другие дочерние элементы), начиная с границы элемента, до преобразований CSS:
canvas.onpaint = () => {
ctx.reset();
// Draw the form element at x:0, y:0
ctx.drawElementImage(form_element, 0, 0);
};
- В контекстах WebGL и WebGPU вы копируете элемент в текстуру. Размер текстуры:
canvas.captureElementImage(element). Значенияwidthиheightуказываются в пикселях сетки холста (резервного хранилища), поэтому округлите их с помощьюMath.ceil().captureElementImage()выдает ошибку, если у элемента ещё нет записи об отрисовке: вызовитеrequestPaint()и повторите попытку при следующем событии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;
}
- В контексте WebGL сначала предварительно выделите подложку текстуры с помощью метода
texImage2D. Фрагменты кода в этом руководстве используют контекст WebGL 2 (canvas.getContext("webgl2")). Выделите хранилище текстур, вызвавtexImage2D()сnullвместо данных пикселей. Это зарезервирует память графического процессора заданного размера и формата без загрузки пикселей. Следуйте этим правилам:- Задайте размер текстуры, как показано выше (
captureElementImage()). - Выделяйте уровень 0 только при первой загрузке или при изменении размера, поскольку перераспределение очищает текстуру. Используйте изменяемое хранилище: неизменяемое хранилище
texStorage2D()не может менять размер при изменении размера элемента.
- Задайте размер текстуры, как показано выше (
// 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 };
- Затем в контексте WebGL отрисуйте HTML-контент в текстуре с помощью метода
texElementSubImage2D. Следуйте этим правилам:- Передайте
{ width, height }в конфигурации. В результате копия заполняет выделенную текстуру независимо от масштаба холста. - Если размер изменился, перераспределите текстуру, как показано выше.
- Передайте
// 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);
}
};
- В контексте WebGPU используйте метод
drawElementImageToTexture. Следуйте этим правилам:- Создайте текстуру с использованием
COPY_DSTиRENDER_ATTACHMENTи добавьтеTEXTURE_BINDING, чтобы использовать ее в шейдерах. Неправильное использование регистрируется как ошибка проверки WebGPU, а не как исключение. - Передайте размер текстуры как
size. Безsizeболее крупный элемент обрезается по размеру текстуры. Если задано значениеsize, элемент масштабируется, чтобы заполнить контейнер. - Повторно создайте текстуру при изменении размера элемента и перестройте группы привязки, которые на нее ссылаются.
- Создайте текстуру с использованием
// 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);
}
};
Если для отрисовки сцены используется цикл requestAnimationFrame, вызовите в нем canvas.requestPaint(), чтобы HTML-контент отрисовывался на холсте. event.changedElements – список элементов, отрисовка которых изменилась. Перерисовывать элемент или повторно загружать его текстуру нужно только в том случае, если дочерние элементы HTML были обновлены.
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
};
- Синхронизируйте геометрию элемента.
Браузеру необходимо знать, где отрисовывается элемент HTML, чтобы его положение в DOM соответствовало отрисованным пикселям. Например, это нужно, чтобы пользователи могли нажимать на элемент и вводить в него текст. Проверка нажатия на холсте выполняется только для элементов, нарисованных с помощью метода 2D drawElementImage() или зарегистрированных с помощью updateElementGeometry().
В случае с 2D-контекстом браузер синхронизирует элемент автоматически.
drawElementImage()выравнивает границы проверки нажатия DOM и кольца фокуса программы чтения с экрана по нарисованным координатам, поэтому вам не нужно устанавливатьstyle.transform. Чтобы управлять геометрией вручную, например когда вы рисуете один и тот же элемент несколько раз, передайте{ preserveElementGeometry: true }вdrawElementImage()и примените преобразования с помощьюcanvas.updateElementGeometry().В случае с 3D-объектами, созданными с помощью WebGL или WebGPU, холст не может определить, где нарисован элемент HTML, поэтому вам нужно указать это. Вычислите
DOMMatrix, который сопоставляет рамку элемента в пикселях CSS с холстом в пикселях CSS. Браузеру необходимо сопоставить трехмерное пространство координат с пространством координат CSS, используя преобразование области просмотра. Для этого выполните следующие действия:- Нормализуйте элемент HTML. Размеры элементов HTML указываются в пикселях (например, ширина 200 пикселей). Однако WebGL обычно рассматривает объекты как "единичные квадраты", например, в диапазоне от -0,5 до 0, 5. Если вы не нормализуете значения, кнопка размером 200 пикселей будет выглядеть в 200 раз больше. На этом шаге также переворачивается ось Y, поскольку в CSS положительным является направление вниз, а в WebGL – вверх.
- Преобразуйте матрицу MVP WebGL в матрицу DOM.
- Сопоставьте с областью просмотра холста. Этот шаг представляет собой этап "масштабирования": он растягивает математические вычисления единичного пространства обратно, чтобы они соответствовали размерам пикселей CSS вашего элемента
<canvas>на экране, и переворачивает ось Y обратно. - Рассчитайте окончательное преобразование. Умножьте матрицы в следующем порядке: Viewport * MVP * Normalization. Объединив их в одно окончательное преобразование, мы получим "карту", которая точно указывает браузеру, где должен находиться этот элемент HTML, чтобы он совпадал с 3D-рисунком. Браузер выполняет деление перспективы.
// 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); }
Затем передайте преобразование в canvas.updateElementGeometry() как canvasTransform. В результате элемент HTML будет размещен непосредственно над отрисованными пикселями. Это гарантирует, что когда пользователь нажимает кнопку или выбирает текст, он действительно взаимодействует с реальным элементом HTML. Браузер также использует преобразование для границ специальных возможностей элемента, например для колец фокусировки программы чтения с экрана:
canvas.updateElementGeometry(targetHTMLElement, {
canvasTransform: computeCanvasTransform(canvas, targetHTMLElement, htmlElementMVP),
});
Для управления проверкой нажатия используйте следующие параметры:
- Каждый вызов
updateElementGeometry()перемещает элемент в начало порядка проверки нажатия. Передайте мяч игроку{ preserveHitTestOrder: true }, чтобы сохранить позицию. - Вызовите
canvas.clearElementGeometry(element), чтобы удалить элемент из проверки нажатия, например когда вы его скрываете.
- Используйте обычные методы экспорта холста, такие как
toDataURL(),toBlob()илиcaptureStream(). Экспортированные данные будут включать обработанный HTML-контент.
Пример кода
<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>
Рекомендации
- ОБЯЗАТЕЛЬНО. Прежде чем использовать HTML-in-Canvas API, проверьте, поддерживается ли он в браузере.
- ОБЯЗАТЕЛЬНО. Всегда добавляйте атрибут
content="drawable"к элементу<canvas>. - ОБЯЗАТЕЛЬНО. Добавьте атрибут
drawableко всем элементам, которые вы рисуете, включая дочерние элементы холста. - ОБЯЗАТЕЛЬНО. Используйте обработчик событий
onpaint, чтобы отрисовать HTML-контент на холсте. - ОБЯЗАТЕЛЬНО. Используйте метод
drawElementImageв 2D илиdrawElementImageToTextureв WebGPU, чтобы отрисовать HTML-контент на холсте. - ОБЯЗАТЕЛЬНО. В WebGL предварительно выделите буфер текстуры с помощью
texImage2D()один раз (при инициализации или изменении размера элемента). Перераспределяйте текстуру, только когда меняется размер, и задайте дляTEXTURE_MIN_FILTERзначениеLINEAR. - ОБЯЗАТЕЛЬНО. В WebGL используйте метод
texElementSubImage2D, чтобы преобразовать HTML-контент в текстуру, которую вы предварительно выделили с помощьюtexImage2D(), размер которой определяетсяcaptureElementImage(). - ОБЯЗАТЕЛЬНО. В WebGPU задайте размер текстуры из
captureElementImage()и воссоздавайте ее только при изменении размера. Создайте текстуру с использованиемCOPY_DSTиRENDER_ATTACHMENTи передайте размер текстуры какsize. - ОБЯЗАТЕЛЬНО. Синхронизируйте геометрию элемента HTML с отрисованным контентом. В 2D-режиме
drawElementImageделает это автоматически. В WebGL и WebGPU вызовитеcanvas.updateElementGeometry(element, { canvasTransform }). - ОБЯЗАТЕЛЬНО. Используйте
ResizeObserver, чтобы определить размер экрана и изменить размер холста в соответствии с пикселями устройства. - НЕ встраивайте в холст контент из разных источников, так как это не поддерживается.
- НЕ инициализируйте
ResizeObserverв обработчике событийonpaint, так как это может привести к утечке памяти.
Резервные стратегии
HTML-in-Canvas API пока поддерживается не всеми современными браузерами, поэтому обычно требуется резервная стратегия. Однако, учитывая преимущества этого API, HTML-in-Canvas следует использовать, если браузер его поддерживает.
Если вам нужно экспортировать HTML-контент из элемента canvas, используйте библиотеки html2canvas, dom-to-image или snapdom.
Чтобы записывать взаимодействия с HTML кадр за кадром, например для стриминга, фиксируйте изменения DOM с помощью библиотек, таких как rrweb.
Также можно добавить предупреждение о том, что экспорт медиаконтента HTML не поддерживается в браузере, поскольку в нем не поддерживается HTML-in-Canvas.