캔버스에서 HTML 콘텐츠 내보내기

웹 기능: HTML-in-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. <canvas> HTML 요소에 content="drawable" 속성을 추가하여 하위 HTML 요소의 렌더링을 지원하도록 캔버스를 초기화합니다. <canvas> 요소 내에 HTML 콘텐츠를 배치하고 그리는 모든 요소에 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. canvas.onpaint 이벤트 핸들러 내에서 HTML 콘텐츠를 캔버스에 렌더링합니다.

  5. I 2D-kontekst bruker du metoden drawElementImage. CSS 변환 전에 요소의 테두리 상자에서 시작하여 drawable 요소의 마지막 스냅샷과 드로어블 하위 트리 (다른 드로어블 하위 요소 제외)를 그립니다.

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"))를 사용합니다. 픽셀 데이터 대신 null를 사용하여 texImage2D()를 호출하여 텍스처 저장소를 할당합니다. 그러면 픽셀을 업로드하지 않고도 지정된 크기와 형식의 GPU 메모리가 예약됩니다. 다음과 같은 규칙을 따르세요.
    • 위와 같이 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 컨텍스트에서 texElementSubImage2D 메서드를 사용하여 HTML 콘텐츠를 텍스처로 렌더링합니다. 다음과 같은 규칙을 따르세요.
    • 구성에서 { 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()로 등록된 요소만 Hit Test합니다.

  • 2D 컨텍스트의 경우 브라우저가 요소를 자동으로 동기화합니다. drawElementImage()는 DOM Hit Test 경계와 스크린 리더 포커스 링을 그려진 좌표에 맞추므로 style.transform를 설정할 필요가 없습니다. 예를 들어 동일한 요소를 두 번 이상 그릴 때 도형을 수동으로 관리하려면 { preserveElementGeometry: true }를 drawElementImage()에 전달하고 canvas.updateElementGeometry()로 변환을 적용합니다.

  • WebGL 또는 WebGPU를 사용하는 3D의 경우 캔버스는 HTML 요소가 그려지는 위치를 알 수 없으므로 개발자가 알려주어야 합니다. 요소의 테두리 상자(CSS 픽셀 단위)를 캔버스(CSS 픽셀 단위)에 매핑하는 DOMMatrix를 계산합니다. 브라우저는 표시 영역 변환을 사용하여 3D 좌표 공간에서 CSS 좌표 공간으로 매핑해야 합니다. 이를 위해 다음을 수행합니다.

    • HTML 요소를 정규화합니다. HTML 요소의 크기는 픽셀 단위로 지정됩니다 (예: 너비 200px). 하지만 WebGL은 일반적으로 객체를 -0.5에서 0.5 사이의 '단위 정사각형'으로 취급합니다. 정규화하지 않으면 200px 버튼이 200배 더 크게 표시됩니다. 이 단계에서는 Y축도 뒤집습니다. CSS에서는 아래쪽이 양수이지만 WebGL에서는 위쪽이 양수이기 때문입니다.
    • WebGL MVP 행렬을 DOM 행렬로 변환합니다.
    • 캔버스 표시 영역에 매핑합니다. 이 단계는 '재조정' 단계입니다. 단위 공간 수학을 다시 확장하여 화면에 있는 <canvas> 요소의 CSS 픽셀 크기와 일치시키고 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),
  });

다음 옵션을 사용하여 Hit Test를 제어합니다.

  • updateElementGeometry()를 호출할 때마다 요소가 히트 테스트 순서의 맨 위로 이동합니다. { preserveHitTestOrder: true }을(를) 추월하여 순위를 유지하세요.
  • canvas.clearElementGeometry(element)를 호출하여 Hit Test에서 요소를 삭제합니다(예: 요소를 숨길 때).
  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 콘텐츠를 캔버스에 렌더링합니다.
  • 필수: 2D에서는 drawElementImage 메서드를, WebGPU에서는 drawElementImageToTexture를 사용하여 HTML 콘텐츠를 캔버스에 렌더링합니다.
  • 필수: WebGL에서 texImage2D()를 사용하여 텍스처 버퍼를 한 번 사전 할당합니다 (초기화되거나 요소 크기가 변경될 때). 크기가 변경될 때만 텍스처를 재할당하고 TEXTURE_MIN_FILTER를 LINEAR로 설정합니다.
  • 필수: WebGL에서 texElementSubImage2D 메서드를 사용하여 texImage2D()로 미리 할당한 텍스처에 HTML 콘텐츠를 렌더링합니다. 크기는 captureElementImage()입니다.
  • 필수: WebGPU에서 captureElementImage()의 텍스처 크기를 지정하고 크기가 변경될 때만 다시 만듭니다. COPY_DST 및 RENDER_ATTACHMENT 사용으로 텍스처를 만들고 텍스처 크기를 size로 전달합니다.
  • 필수: HTML 요소의 도형을 렌더링된 콘텐츠와 동기화합니다. 2D에서는 drawElementImage가 자동으로 처리합니다. WebGL 및 WebGPU에서 canvas.updateElementGeometry(element, { canvasTransform })를 호출합니다.
  • 필수: ResizeObserver를 사용하여 화면 크기를 관찰하고 기기 픽셀과 일치하도록 캔버스 크기를 업데이트합니다.
  • 교차 출처 콘텐츠는 지원되지 않으므로 캔버스에 삽입하지 마세요.
  • onpaint 이벤트 핸들러 내에서 ResizeObserver를 초기화하지 마세요. 메모리 누수가 발생할 수 있습니다.

대체 전략

HTML-in-Canvas API는 현재 모든 최신 브라우저에서 지원되지 않으므로 일반적으로 대체 전략이 필요합니다. 하지만 이 API의 향상된 성능 이점을 고려할 때 브라우저에서 지원하는 경우 HTML-in-Canvas를 사용해야 합니다.

HTML 콘텐츠를 캔버스에서 내보내야 하는 사용 사례의 경우 html2canvas, dom-to-image 또는 snapdom와 같은 라이브러리를 사용하세요.

스트리밍과 같이 HTML 상호작용을 프레임별로 캡처하려면 rrweb와 같은 라이브러리를 사용하여 DOM 변형을 캡처합니다.

또는 브라우저가 HTML-in-Canvas를 지원하지 않으므로 HTML 미디어 내보내기가 지원되지 않는다는 경고를 구현합니다.