ייצוא תוכן HTML מ-Canvas

תכונות אינטרנט: HTML ב-Canvas

אפליקציות אינטרנט צריכות לעיתים קרובות לתעד ולייצא תוכן HTML עשיר, כמו מרכזי בקרה בהתאמה אישית, מסמכים מעוצבים או תרשימים אינטראקטיביים, כתמונות סטטיות או כהקלטות וידאו. בעבר, כדי לעשות את זה היה צריך להשתמש בספריות גדולות של צד שלישי שמנתחות באופן ידני צומתי DOM ומאפייני CSS כדי לשחזר פקסימיליה חזותית ב-canvas. הגישה הזו דורשת משאבי מחשוב רבים, היא מועדת לשגיאות ולעיתים קרובות לא תומכת בתכונות פריסה מודרניות של CSS. באמצעות HTML-in-Canvas API, מפתחים יכולים להציג רכיבי DOM אמיתיים ישירות בהקשר של ה-canvas. מנוע הרינדור המקורי של הדפדפן מצייר את עץ המשנה של ה-HTML בדיוק מושלם ברמת הפיקסלים, ולכן אפשר לתעד את הפלט החזותי המדויק כתמונה או כסטרימינג של וידאו ביעילות רבה באמצעות שיטות מובנות של רכיב ה-canvas, כמו 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 בתוך גורם מטפל באירועים מסוג canvas.onpaint.

  5. בהקשר דו-ממדי, משתמשים ב-method‏ 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 הם בפיקסלים של רשת ה-canvas (מאגר הגיבוי), אז צריך לעגל אותם כלפי מעלה באמצעות 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 במקום נתוני פיקסלים. הפעולה הזו שומרת זיכרון 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, מעבדים את תוכן ה-HTML למרקם באמצעות השיטה texElementSubImage2D. פועלים לפי הכללים הבאים:
    • מעבירים את { width, height } בהגדרה. הוא גורם לעותק למלא את הטקסטורה שהוקצתה, בלי קשר לקנה המידה של ה-canvas.
    • אם הגודל השתנה, מקצים מחדש את הטקסטורה, כמו שמוצג למעלה.
// 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 כדי לדגום אותה ב-shaders. שימוש שגוי מדווח כשגיאת אימות של 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 מעובד ב-canvas. ‫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 יתאים לפיקסלים המשורטטים שלו. לדוגמה, כדי שהמשתמשים יוכלו ללחוץ עליו ולהקליד בו. רכיב canvas בודק את מיקום המצביע רק ברכיבים שמשורטטים באמצעות השיטה הדו-ממדית drawElementImage(), או רשומים באמצעות updateElementGeometry().

  • במקרה של הקשר דו-ממדי, הדפדפן מסנכרן את הרכיב באופן אוטומטי. הפונקציה drawElementImage() מתאימה את גבולות בדיקת מיקום המצביע של ה-DOM ואת טבעות המיקוד של קורא המסך לקואורדינטות ששורטטו, כך שאין צורך להגדיר את style.transform. כדי לנהל את הגיאומטריה באופן ידני, למשל כשמציירים את אותו אלמנט יותר מפעם אחת, מעבירים את { preserveElementGeometry: true } אל drawElementImage() ומחילים טרנספורמציות באמצעות canvas.updateElementGeometry().

  • במקרה של תלת-ממד עם WebGL או WebGPU, אי אפשר לדעת איפה רכיב ה-HTML משורטט, אז צריך להגדיר את זה. מחשבים DOMMatrix שממפה את תיבת הגבול של הרכיב, בפיקסלים של CSS, ל-canvas, בפיקסלים של 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 צריך להיות כדי להתאים לשרטוט התלת-ממדי. הדפדפן מבצע את חלוקת הפרספקטיבה.
    // 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. משתמשים בשיטות רגילות לייצוא של רכיבי canvas, כמו 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 לכל רכיב שמשרטטים, כולל רכיבים צאצאים ישירים של ה-canvas.
  • חובה: צריך להשתמש בגורם מטפל באירועים onpaint כדי להציג את תוכן ה-HTML ב-canvas.
  • חובה: צריך להשתמש ב-method‏ drawElementImage ב-2D, או ב-drawElementImageToTexture ב-WebGPU, כדי לעבד את תוכן ה-HTML ב-canvas.
  • חובה: ב-WebGL, צריך להקצות מראש את מאגר הטקסטורות באמצעות texImage2D() פעם אחת (כשהוא מאותחל או כשגודל הרכיב משתנה). מקצים מחדש את הטקסטורה רק כשהגודל משתנה, ומגדירים את TEXTURE_MIN_FILTER ל-LINEAR.
  • חובה: ב-WebGL, צריך להשתמש ב-method‏ texElementSubImage2D כדי לעבד את תוכן ה-HTML למרקם שהוקצה מראש באמצעות texImage2D(), בגודל של captureElementImage().
  • חובה: ב-WebGPU, צריך להגדיר את גודל הטקסטורה מ-captureElementImage(), וליצור אותה מחדש רק כשהגודל משתנה. יוצרים את הטקסטורה עם שימוש ב-COPY_DST וב-RENDER_ATTACHMENT, ומעבירים את גודל הטקסטורה בתור size.
  • חובה: לסנכרן את הגיאומטריה של רכיב ה-HTML עם התוכן שעבר עיבוד. במצב דו-ממדי, drawElementImage עושה את זה באופן אוטומטי. ב-WebGL וב-WebGPU, מפעילים את canvas.updateElementGeometry(element, { canvasTransform }).
  • חובה: צריך להשתמש ב-ResizeObserver כדי לבדוק את גודל המסך ולעדכן את גודל אזור העריכה כך שיתאים לפיקסלים של המכשיר.
  • אל תטמיעו תוכן ממקורות שונים ב-Canvas, כי אין תמיכה בכך.
  • אל תפעילו את ResizeObserver בתוך הגורם המטפל באירועים onpaint, כי זה עלול לגרום לדליפות זיכרון.

אסטרטגיות חלופיות

ה-API של HTML-in-Canvas לא נתמך כרגע בכל הדפדפנים המודרניים, ולכן בדרך כלל נדרשת אסטרטגיית גיבוי. עם זאת, לאור היתרונות של ה-API הזה בשיפור הביצועים, כדאי להשתמש ב-HTML-in-Canvas אם הדפדפן תומך בו.

אם אתם צריכים לייצא תוכן HTML מקנבס, השתמשו בספריות כמו html2canvas,‏ dom-to-image או snapdom.

כדי לתעד אינטראקציות של HTML פריים אחר פריים, למשל לצורך סטרימינג, צריך לתעד מוטציות של DOM באמצעות ספריות כמו rrweb.

לחלופין, אפשר להציג אזהרה שהדפדפן לא תומך בייצוא של מדיה ב-HTML כי הוא לא תומך ב-HTML-in-Canvas.