Экспорт HTML-контента из Canvas

Веб-функции: HTML в canvas

Веб-приложениям часто требуется захватывать и экспортировать сложный HTML-контент, например персонализированные панели управления, стилизованные документы или интерактивные диаграммы, в виде статических изображений или видеозаписей. Раньше для этого требовались громоздкие сторонние библиотеки, которые вручную анализировали узлы DOM и свойства CSS, чтобы воссоздать визуальную копию на холсте. Этот подход требует больших вычислительных ресурсов, подвержен ошибкам и часто не поддерживает современные функции макета CSS. С помощью HTML-in-Canvas API разработчики могут отрисовывать реальные элементы DOM непосредственно в контексте холста. Поскольку встроенный механизм рендеринга браузера отрисовывает поддерево HTML с точностью до пикселя, захват точного визуального вывода в виде изображения или видеопотока очень эффективен при использовании встроенных методов холста, таких как toDataURL(), toBlob() или captureStream().

Рекомендации по внедрению

  1. Проверьте, поддерживается ли в браузере HTML-in-Canvas:

    if ('content' in HTMLCanvasElement.prototype) {
      // Use HTML in Canvas API
    } else {
      // Use fallback strategy
    }
    
  2. Инициализируйте холст, чтобы поддерживать отрисовку дочерних элементов HTML. Для этого добавьте атрибут content="drawable" в элемент HTML <canvas>. Разместите HTML-контент внутри элемента <canvas> и добавьте атрибут drawable в каждый элемент, который вы рисуете. Элемент с drawable захватывает свое поддерево, за исключением вложенных потомков, которые также являются drawable:

    <canvas id="canvas" content="drawable">
      <div id="html-content" drawable></div>
    </canvas>
    
  3. Чтобы избежать размытия, масштабируйте сетку холста в соответствии с коэффициентом масштабирования устройства:

    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. Отрисуйте HTML-контент на холсте в обработчике событий canvas.onpaint.

  5. В 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
  };
  1. Синхронизируйте геометрию элемента.

Браузеру необходимо знать, где отрисовывается элемент 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), чтобы удалить элемент из проверки нажатия, например когда вы его скрываете.
  1. Используйте обычные методы экспорта холста, такие как 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.