Xuất nội dung HTML từ canvas

Tính năng web: HTML trong canvas

Ứng dụng web thường cần chụp và xuất nội dung HTML đa dạng (chẳng hạn như trang tổng quan tuỳ chỉnh, tài liệu được tạo kiểu hoặc biểu đồ tương tác) dưới dạng hình ảnh tĩnh hoặc bản ghi video. Trước đây, để làm được điều này, bạn cần có các thư viện bên thứ ba cồng kềnh, phân tích cú pháp các nút DOM và thuộc tính CSS theo cách thủ công để tạo lại bản sao trực quan trên canvas. Phương pháp này tốn nhiều tài nguyên tính toán, dễ xảy ra lỗi và thường không hỗ trợ các tính năng bố cục CSS hiện đại. Với HTML-in-Canvas API, nhà phát triển có thể kết xuất các phần tử DOM thực trực tiếp vào ngữ cảnh canvas. Vì công cụ kết xuất gốc của trình duyệt vẽ cây con HTML với độ chính xác đến từng pixel, nên việc ghi lại kết quả hình ảnh chính xác dưới dạng hình ảnh hoặc luồng video sẽ rất hiệu quả khi sử dụng các phương thức canvas tích hợp sẵn như toDataURL(), toBlob() hoặc captureStream().

Cách triển khai

  1. Kiểm tra xem trình duyệt có hỗ trợ HTML-in-Canvas hay không:

    if ('content' in HTMLCanvasElement.prototype) {
      // Use HTML in Canvas API
    } else {
      // Use fallback strategy
    }
    
  2. Khởi chạy canvas để hỗ trợ việc kết xuất các phần tử HTML con bằng cách thêm thuộc tính content="drawable" vào phần tử HTML <canvas>. Đặt nội dung HTML của bạn vào bên trong phần tử <canvas> và thêm thuộc tính drawable vào mọi phần tử mà bạn vẽ. Một phần tử có drawable sẽ thu thập cây con của nó, ngoại trừ các phần tử con lồng nhau cũng là drawable:

    <canvas id="canvas" content="drawable">
      <div id="html-content" drawable></div>
    </canvas>
    
  3. Điều chỉnh tỷ lệ lưới canvas cho phù hợp với hệ số tỷ lệ của thiết bị để tránh bị mờ:

    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. Hiển thị nội dung HTML cho canvas bên trong trình xử lý sự kiện canvas.onpaint.

  5. Trong ngữ cảnh 2D, hãy sử dụng phương thức drawElementImage. Hàm này vẽ ảnh chụp nhanh gần đây nhất của một phần tử drawable và cây con có thể vẽ của phần tử đó (không bao gồm các phần tử con có thể vẽ khác), bắt đầu từ hộp đường viền của phần tử, trước khi biến đổi CSS:

canvas.onpaint = () => {
  ctx.reset();
  // Draw the form element at x:0, y:0
  ctx.drawElementImage(form_element, 0, 0);
};
  • Trong bối cảnh WebGL và WebGPU, bạn sao chép phần tử vào một kết cấu. Định cỡ hoạ tiết từ canvas.captureElementImage(element). width và height của nó nằm trong pixel lưới canvas (kho lưu trữ sao lưu), vì vậy, hãy làm tròn chúng bằng Math.ceil(). captureElementImage() sẽ gửi nếu phần tử chưa có bản ghi vẽ: gọi requestPaint() và thử lại trong sự kiện paint tiếp theo.
// 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;
}
  • Trong ngữ cảnh WebGL, trước tiên, hãy phân bổ trước kết cấu sao lưu bằng phương thức texImage2D. Các đoạn mã trong hướng dẫn này sử dụng ngữ cảnh WebGL 2 (canvas.getContext("webgl2")). Phân bổ bộ nhớ kết cấu bằng cách gọi texImage2D() bằng null thay vì dữ liệu pixel, giúp dự trữ bộ nhớ GPU có kích thước và định dạng nhất định mà không cần tải bất kỳ pixel nào lên. Hãy tuân theo các quy tắc sau:
    • Định cỡ hoạ tiết từ captureElementImage(), như minh hoạ ở trên.
    • Chỉ phân bổ cấp 0 trong lần tải lên đầu tiên hoặc khi kích thước thay đổi, vì việc phân bổ lại sẽ xoá kết cấu. Sử dụng bộ nhớ có thể thay đổi: không thể đổi kích thước bộ nhớ texStorage2D() không thể thay đổi khi phần tử thay đổi kích thước.
// 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 };
  • Sau đó, trong bối cảnh WebGL, hãy kết xuất nội dung HTML vào kết cấu bằng phương thức texElementSubImage2D. Hãy tuân theo các quy tắc sau:
    • Truyền { width, height } vào cấu hình. Thao tác này giúp bản sao lấp đầy kết cấu được phân bổ bất kể canvas được điều chỉnh theo tỷ lệ như thế nào.
    • Phân bổ lại kết cấu nếu kích thước thay đổi, như minh hoạ ở trên.
// 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);
  }
};
  • Trong ngữ cảnh WebGPU, hãy sử dụng phương thức drawElementImageToTexture. Hãy tuân theo các quy tắc sau:
    • Tạo hoạ tiết bằng cách sử dụng COPY_DST và RENDER_ATTACHMENT, đồng thời thêm TEXTURE_BINDING để lấy mẫu trong chương trình đổ bóng. Cách sử dụng sai sẽ được báo cáo là lỗi xác thực WebGPU, chứ không phải là ngoại lệ.
    • Truyền kích thước hoạ tiết dưới dạng size. Nếu không có size, thì một phần tử lớn hơn sẽ được cắt bớt cho vừa với hoạ tiết. Với size, phần tử được điều chỉnh theo tỷ lệ để lấp đầy phần tử đó.
    • Tạo lại hoạ tiết khi kích thước phần tử thay đổi và tạo lại các nhóm liên kết tham chiếu đến hoạ tiết đó.
// 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);
  }
};

Khi sử dụng vòng lặp requestAnimationFrame để kết xuất cảnh, hãy gọi canvas.requestPaint() trong vòng lặp để đảm bảo rằng nội dung HTML được kết xuất vào canvas. event.changedElements liệt kê các phần tử có hoạt động kết xuất đã thay đổi. Đảm bảo rằng bạn chỉ vẽ lại phần tử hoặc tải lại kết cấu của phần tử nếu có bản cập nhật cho các phần tử HTML con:

  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. Đồng bộ hoá hình học của phần tử.

Trình duyệt cần biết vị trí vẽ phần tử HTML để vị trí DOM của phần tử đó khớp với các pixel được vẽ, chẳng hạn như để người dùng có thể nhấp và nhập vào phần tử đó. Canvas chỉ thử nghiệm nhấn các phần tử được vẽ bằng phương thức 2D drawElementImage() hoặc được đăng ký bằng updateElementGeometry().

  • Đối với trường hợp ngữ cảnh 2D, trình duyệt sẽ tự động đồng bộ hoá phần tử. drawElementImage() điều chỉnh ranh giới thử nghiệm nhấn DOM và vòng lấy tiêu điểm của trình đọc màn hình theo toạ độ được vẽ, vì vậy, bạn không cần đặt style.transform. Để quản lý hình học theo cách thủ công, ví dụ: khi bạn vẽ cùng một phần tử nhiều lần, hãy truyền { preserveElementGeometry: true } đến drawElementImage() và áp dụng các phép biến đổi bằng canvas.updateElementGeometry().

  • Đối với trường hợp 3D có WebGL hoặc WebGPU, canvas không thể biết phần tử HTML được vẽ ở đâu, vì vậy, bạn phải cho canvas biết. Tính toán một DOMMatrix liên kết hộp viền của phần tử (tính bằng pixel CSS) với canvas (tính bằng pixel CSS). Trình duyệt cần liên kết từ không gian toạ độ 3D vào không gian toạ độ CSS bằng cách sử dụng phép biến đổi khung nhìn. Để hỗ trợ việc này, hãy làm như sau:

    • Chuẩn hoá phần tử HTML. Các phần tử HTML được định kích thước bằng pixel (ví dụ: rộng 200 pixel). Tuy nhiên, WebGL thường coi các đối tượng là "hình vuông đơn vị", ví dụ: từ -0,5 đến 0, 5. Nếu bạn không chuẩn hoá, nút 200px của bạn sẽ trông lớn hơn 200 lần. Bước này cũng lật trục Y, vì trong CSS, hướng xuống là dương, nhưng trong WebGL, hướng lên là dương.
    • Chuyển đổi Ma trận MVP WebGL thành Ma trận DOM.
    • Ánh xạ tới khung nhìn canvas. Bước này là giai đoạn "điều chỉnh lại tỷ lệ": bước này kéo giãn toán học không gian đơn vị đó ra để khớp với kích thước pixel CSS của phần tử <canvas> trên màn hình và lật lại trục Y.
    • Tính toán phép biến đổi cuối cùng. Nhân các ma trận theo thứ tự: Khung nhìn * MVP * Chuẩn hoá. Việc kết hợp các phép biến đổi này thành một phép biến đổi cuối cùng sẽ tạo ra một "bản đồ" cho trình duyệt biết chính xác vị trí của phần tử HTML đó để căn chỉnh với bản vẽ 3D. Trình duyệt thực hiện phép chia phối cảnh.
    // 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);
    }
    

Sau đó, truyền phép biến đổi đến canvas.updateElementGeometry() dưới dạng canvasTransform. Thao tác này sẽ di chuyển phần tử HTML để đặt trực tiếp lên trên các pixel được kết xuất. Điều này đảm bảo rằng khi người dùng nhấp vào một nút hoặc chọn văn bản, họ thực sự đang nhấp vào phần tử HTML thực. Trình duyệt cũng sử dụng phép biến đổi cho các ranh giới hỗ trợ tiếp cận của phần tử, ví dụ: cho vòng lấy tiêu điểm của trình đọc màn hình:

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

Sử dụng các tuỳ chọn này để kiểm soát thử nghiệm nhấn:

  • Mỗi lệnh gọi updateElementGeometry() sẽ di chuyển phần tử lên đầu thứ tự thử nghiệm nhấn. Thẻ/vé { preserveHitTestOrder: true } sẽ giữ nguyên vị trí.
  • Gọi canvas.clearElementGeometry(element) để xoá một phần tử khỏi thử nghiệm nhấn, ví dụ: khi bạn ẩn phần tử đó.
  1. Sử dụng các phương thức xuất canvas thông thường như toDataURL(), toBlob() hoặc captureStream(). Dữ liệu được xuất sẽ bao gồm nội dung HTML được kết xuất.

Mã mẫu

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

Các phương pháp hay nhất

  • BẮT BUỘC: Kiểm tra khả năng hỗ trợ của trình duyệt đối với API HTML-in-Canvas trước khi sử dụng.
  • BẮT BUỘC: Luôn thêm thuộc tính content="drawable" vào phần tử <canvas>.
  • BẮT BUỘC: Thêm thuộc tính drawable vào mọi phần tử mà bạn vẽ, bao gồm cả các phần tử con trực tiếp của canvas.
  • BẮT BUỘC: Sử dụng trình xử lý sự kiện onpaint để hiển thị nội dung HTML trên canvas.
  • BẮT BUỘC: Sử dụng phương thức drawElementImage trong 2D hoặc drawElementImageToTexture trong WebGPU để kết xuất nội dung HTML vào canvas.
  • BẮT BUỘC: Trong WebGL, hãy phân bổ trước vùng đệm kết cấu bằng texImage2D() một lần (khi vùng đệm được khởi chạy hoặc khi kích thước phần tử thay đổi). Chỉ phân bổ lại kết cấu khi kích thước thay đổi và đặt TEXTURE_MIN_FILTER thành LINEAR.
  • BẮT BUỘC: Trong WebGL, hãy sử dụng phương thức texElementSubImage2D để kết xuất nội dung HTML thành một hoạ tiết mà bạn phân bổ trước bằng texImage2D(), có kích thước từ captureElementImage().
  • BẮT BUỘC: Trong WebGPU, hãy định kích thước hoạ tiết từ captureElementImage() và chỉ tạo lại hoạ tiết khi kích thước thay đổi. Tạo kết cấu bằng cách sử dụng COPY_DST và RENDER_ATTACHMENT, đồng thời truyền kích thước kết cấu dưới dạng size.
  • BẮT BUỘC: Đồng bộ hoá hình học của phần tử HTML với nội dung được kết xuất. Trong 2D, drawElementImage sẽ tự động thực hiện việc này. Trong WebGL và WebGPU, hãy gọi canvas.updateElementGeometry(element, { canvasTransform }).
  • BẮT BUỘC: Sử dụng ResizeObserver để quan sát kích thước màn hình và cập nhật kích thước canvas cho phù hợp với pixel của thiết bị.
  • KHÔNG nhúng nội dung trên nhiều nguồn gốc vào canvas vì tính năng này không được hỗ trợ.
  • KHÔNG khởi động ResizeObserver trong trình xử lý sự kiện onpaint vì việc này có thể dẫn đến rò rỉ bộ nhớ.

Chiến lược dự phòng

API HTML-in-Canvas hiện không được hỗ trợ trong tất cả các trình duyệt hiện đại, do đó, bạn thường cần có chiến lược dự phòng. Tuy nhiên, do lợi ích về hiệu suất được cải thiện của API này, bạn nên sử dụng HTML-in-Canvas nếu trình duyệt hỗ trợ.

Đối với trường hợp sử dụng cần xuất nội dung HTML từ canvas, hãy sử dụng các thư viện như html2canvas, dom-to-image hoặc snapdom.

Để ghi lại từng khung hình của các hoạt động tương tác HTML (ví dụ: để phát trực tuyến), hãy ghi lại các đột biến DOM bằng cách sử dụng các thư viện như rrweb.

Ngoài ra, hãy triển khai cảnh báo rằng trình duyệt không hỗ trợ tính năng xuất nội dung nghe nhìn HTML vì trình duyệt không hỗ trợ HTML-in-Canvas.