Mengekspor konten HTML dari kanvas

Fitur web: HTML di kanvas

Aplikasi web sering kali perlu mengambil dan mengekspor konten HTML yang kaya—seperti dasbor yang disesuaikan, dokumen bergaya, atau diagram interaktif—sebagai gambar statis atau rekaman video. Secara historis, untuk mencapai hal ini diperlukan library pihak ketiga yang besar yang mengurai node DOM dan properti CSS secara manual untuk merekonstruksi faksimili visual di kanvas. Pendekatan ini memerlukan komputasi yang mahal, rentan terhadap error, dan sering kali gagal mendukung fitur tata letak CSS modern. Dengan HTML-in-Canvas API, developer dapat merender elemen DOM asli langsung ke konteks kanvas. Karena mesin rendering native browser menggambar subhierarki HTML dengan akurasi piksel yang sempurna, menangkap output visual yang tepat sebagai gambar atau streaming video sangat efisien menggunakan metode kanvas bawaan seperti toDataURL(), toBlob(), atau captureStream().

Cara menerapkan

  1. Periksa apakah HTML-in-Canvas didukung di browser:

    if ('content' in HTMLCanvasElement.prototype) {
      // Use HTML in Canvas API
    } else {
      // Use fallback strategy
    }
    
  2. Lakukan inisialisasi kanvas untuk mendukung rendering elemen HTML turunan dengan menambahkan atribut content="drawable" ke elemen HTML <canvas>. Tempatkan konten HTML Anda di dalam elemen <canvas>, dan tambahkan atribut drawable ke setiap elemen yang Anda gambar. Elemen dengan drawable menangkap subhierarkinya, kecuali turunan bertingkat yang juga merupakan drawable:

    <canvas id="canvas" content="drawable">
      <div id="html-content" drawable></div>
    </canvas>
    
  3. Skalakan petak kanvas agar sesuai dengan faktor skala perangkat untuk mencegah keburaman:

    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. Render konten HTML ke kanvas di dalam pengendali peristiwa canvas.onpaint.

  5. Dalam konteks 2D, gunakan metode drawElementImage. Fungsi ini menggambar snapshot terakhir elemen drawable, dan subhierarki yang dapat digambarnya (tidak termasuk turunan lain yang dapat digambar), dimulai dari kotak batas elemen, sebelum transformasi CSS:

canvas.onpaint = () => {
  ctx.reset();
  // Draw the form element at x:0, y:0
  ctx.drawElementImage(form_element, 0, 0);
};
  • Dalam konteks WebGL dan WebGPU, Anda menyalin elemen ke dalam tekstur. Ukur tekstur dari canvas.captureElementImage(element). width dan height-nya berada dalam piksel petak kanvas (backing store), jadi bulatkan dengan Math.ceil(). captureElementImage() menampilkan error jika elemen belum memiliki data gambar: panggil requestPaint(), dan coba lagi pada peristiwa paint berikutnya.
// 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;
}
  • Dalam konteks WebGL, alokasikan terlebih dahulu dukungan tekstur menggunakan metode texImage2D. Cuplikan dalam panduan ini menggunakan konteks WebGL 2 (canvas.getContext("webgl2")). Alokasikan penyimpanan tekstur dengan memanggil texImage2D() dengan null, bukan data piksel, yang mencadangkan memori GPU dengan ukuran dan format tertentu tanpa mengupload piksel apa pun. Ikuti aturan berikut:
    • Ubah ukuran tekstur dari captureElementImage(), seperti yang ditunjukkan di atas.
    • Alokasikan level 0 hanya pada upload pertama, atau saat ukuran berubah, karena alokasi ulang akan menghapus tekstur. Gunakan penyimpanan yang dapat diubah: penyimpanan texStorage2D() yang tidak dapat diubah tidak dapat diubah ukurannya saat elemen berubah ukuran.
// 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 };
  • Kemudian, dalam konteks WebGL, render konten HTML ke tekstur menggunakan metode texElementSubImage2D. Ikuti aturan berikut:
    • Teruskan { width, height } dalam konfigurasi. Hal ini membuat salinan mengisi tekstur yang dialokasikan, terlepas dari cara kanvas diskalakan.
    • Alokasikan ulang tekstur jika ukurannya berubah, seperti yang ditunjukkan di atas.
// 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);
  }
};
  • Dalam konteks WebGPU, gunakan metode drawElementImageToTexture. Ikuti aturan berikut:
    • Buat tekstur dengan penggunaan COPY_DST dan RENDER_ATTACHMENT, lalu tambahkan TEXTURE_BINDING untuk mengambil sampelnya di shader. Penggunaan yang salah dilaporkan sebagai error validasi WebGPU, bukan sebagai pengecualian.
    • Teruskan ukuran tekstur sebagai size. Tanpa size, elemen yang lebih besar akan dipangkas ke tekstur. Dengan size, elemen diskalakan untuk mengisinya.
    • Buat ulang tekstur saat ukuran elemen berubah, dan buat ulang bind group yang mereferensikannya.
// 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);
  }
};

Saat menggunakan loop requestAnimationFrame untuk merender tampilan, panggil canvas.requestPaint() dalam loop untuk memastikan konten HTML dirender ke kanvas. event.changedElements mencantumkan elemen yang rendering-nya berubah. Pastikan Anda hanya menggambar ulang elemen, atau mengupload ulang teksturnya, jika ada update pada elemen HTML turunan:

  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. Menyinkronkan geometri elemen.

Browser perlu mengetahui tempat elemen HTML digambar, sehingga posisi DOM-nya cocok dengan piksel yang digambar, misalnya, sehingga pengguna dapat mengklik dan mengetik di dalamnya. Kanvas hanya melakukan hit test pada elemen yang digambar dengan metode drawElementImage() 2D, atau didaftarkan dengan updateElementGeometry().

  • Untuk kasus konteks 2D, browser akan menyinkronkan elemen secara otomatis. drawElementImage() menyelaraskan batas hit-test DOM dan cincin fokus pembaca layar dengan koordinat yang digambar, sehingga Anda tidak perlu menetapkan style.transform. Untuk mengelola geometri secara manual, misalnya, saat Anda menggambar elemen yang sama lebih dari sekali, teruskan { preserveElementGeometry: true } ke drawElementImage(), dan terapkan transformasi dengan canvas.updateElementGeometry().

  • Untuk kasus 3D dengan WebGL atau WebGPU, kanvas tidak dapat mengetahui tempat elemen HTML digambar, jadi Anda harus memberi tahunya. Menghitung DOMMatrix yang memetakan kotak batas elemen, dalam piksel CSS, ke kanvas, dalam piksel CSS. Browser perlu memetakan dari ruang koordinat 3D ke ruang koordinat CSS menggunakan transformasi area pandang. Untuk memfasilitasi hal ini, lakukan hal berikut:

    • Menormalisasi elemen HTML. Elemen HTML diukur dalam piksel (misalnya, lebar 200 px). Namun, WebGL biasanya memperlakukan objek sebagai "persegi satuan", misalnya, berkisar dari -0,5 hingga 0,5. Jika Anda tidak menormalisasi, tombol 200 px akan terlihat 200 kali lebih besar. Langkah ini juga membalik sumbu Y, karena di CSS, arah ke bawah bernilai positif, tetapi di WebGL, arah ke atas bernilai positif.
    • Mengonversi Matriks MVP WebGL menjadi Matriks DOM.
    • Petakan ke area pandang kanvas. Langkah ini adalah fase "penskalaan ulang": langkah ini memperluas matematika ruang unit tersebut kembali agar sesuai dengan dimensi piksel CSS elemen <canvas> Anda di layar, dan membalik sumbu Y kembali.
    • Menghitung transformasi akhir. Kalikan matriks secara berurutan: Area pandang * MVP * Normalisasi. Menggabungkannya menjadi satu transformasi akhir akan menghasilkan "peta" yang memberi tahu browser tempat elemen HTML tersebut harus diletakkan agar sejajar dengan gambar 3D. Browser melakukan pembagian perspektif.
    // 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);
    }
    

Kemudian, teruskan transformasi ke canvas.updateElementGeometry() sebagai canvasTransform. Tindakan ini akan memindahkan elemen HTML agar berada tepat di atas piksel yang dirender. Hal ini memastikan bahwa saat pengguna mengklik tombol atau memilih teks, mereka benar-benar mengklik elemen HTML yang sebenarnya. Browser juga menggunakan transformasi untuk batas aksesibilitas elemen, misalnya, untuk cincin fokus pembaca layar:

  canvas.updateElementGeometry(targetHTMLElement, {
    canvasTransform: computeCanvasTransform(canvas, targetHTMLElement, htmlElementMVP),
  });

Gunakan opsi ini untuk mengontrol hit testing:

  • Setiap panggilan updateElementGeometry() akan memindahkan elemen ke bagian atas urutan hit-testing. Pass { preserveHitTestOrder: true } untuk mempertahankan posisinya.
  • Panggil canvas.clearElementGeometry(element) untuk menghapus elemen dari hit testing, misalnya, saat Anda menyembunyikannya.
  1. Gunakan metode ekspor kanvas reguler seperti toDataURL(), toBlob(), atau captureStream(). Data yang diekspor akan menyertakan konten HTML yang dirender.

Kode contoh

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

Praktik Terbaik

  • WAJIB: Periksa dukungan browser untuk HTML-in-Canvas API sebelum menggunakannya.
  • WAJIB: Selalu tambahkan atribut content="drawable" ke elemen <canvas>.
  • WAJIB: Tambahkan atribut drawable ke setiap elemen yang Anda gambar, termasuk turunan langsung kanvas.
  • WAJIB: Gunakan pengendali peristiwa onpaint untuk merender konten HTML ke kanvas.
  • WAJIB: Gunakan metode drawElementImage dalam 2D, atau drawElementImageToTexture di WebGPU, untuk merender konten HTML ke kanvas.
  • WAJIB: Di WebGL, alokasikan buffer tekstur terlebih dahulu dengan texImage2D() satu kali (saat diinisialisasi atau saat ukuran elemen berubah). Alokasikan ulang tekstur hanya saat ukuran berubah, dan tetapkan TEXTURE_MIN_FILTER ke LINEAR.
  • WAJIB: Di WebGL, gunakan metode texElementSubImage2D untuk merender konten HTML ke tekstur yang telah Anda alokasikan sebelumnya dengan texImage2D(), berukuran dari captureElementImage().
  • WAJIB: Di WebGPU, tentukan ukuran tekstur dari captureElementImage(), dan buat ulang hanya saat ukurannya berubah. Buat tekstur dengan penggunaan COPY_DST dan RENDER_ATTACHMENT, lalu teruskan ukuran tekstur sebagai size.
  • WAJIB: Sinkronkan geometri elemen HTML dengan konten yang dirender. Dalam 2D, drawElementImage melakukannya secara otomatis. Di WebGL dan WebGPU, panggil canvas.updateElementGeometry(element, { canvasTransform }).
  • WAJIB: Gunakan ResizeObserver untuk mengamati ukuran layar dan memperbarui ukuran kanvas agar sesuai dengan piksel perangkat.
  • JANGAN menyematkan konten lintas origin di kanvas, karena tidak didukung.
  • JANGAN melakukan inisialisasi ResizeObserver dalam pengendali peristiwa onpaint, karena dapat menyebabkan kebocoran memori.

Strategi penggantian

HTML-in-Canvas API saat ini tidak didukung di semua browser modern, sehingga strategi penggantian biasanya diperlukan. Namun, mengingat manfaat peningkatan performa API ini, HTML-in-Canvas harus digunakan jika browser mendukungnya.

Untuk kasus penggunaan yang mengharuskan konten HTML diekspor dari kanvas, gunakan library seperti html2canvas, dom-to-image, atau snapdom.

Untuk menangkap interaksi HTML frame demi frame, misalnya, untuk streaming, tangkap mutasi DOM menggunakan library seperti rrweb.

Atau, terapkan peringatan bahwa ekspor media HTML tidak didukung di browser karena tidak mendukung HTML-in-Canvas.