برنامههای وب اغلب نیاز دارند محتوای HTML غنی (مانند داشبوردهای سفارشیشده، اسناد سبکدار، یا نمودارهای تعاملی) را بهعنوان تصاویر ثابت یا ضبطهای ویدیویی ضبط و صادر کنند. در گذشته، برای دستیابی به این هدف به کتابخانههای شخص ثالث حجیمی نیاز بود که گرههای DOM و ویژگیهای CSS را بهصورت دستی تجزیه میکردند تا یک نسخه تصویری مشابه را روی بوم بازسازی کنند. این رویکرد ازنظر محاسباتی پرهزینه و مستعد خطا است و اغلب از ویژگیهای چیدمان CSS مدرن پشتیبانی نمیکند. با HTML-in-Canvas API، توسعهدهندگان میتوانند عناصر DOM واقعی را مستقیماً در زمینه بوم ارائه دهند. ازآنجاییکه موتور پردازش بومی مرورگر زیردرخت HTML را با دقت پیکسلی کامل رنگآمیزی میکند، ضبط خروجی بصری دقیق بهعنوان تصویر یا جاریسازی ویدیو بااستفاده از روشهای بوم داخلی مانند toDataURL()، toBlob()، یا captureStream() بسیار کارآمد است.
نحوه پیادهسازی
بررسی کنید که آیا HTML-in-Canvas در مرورگر پشتیبانی میشود یا نه:
if ('content' in HTMLCanvasElement.prototype) { // Use HTML in Canvas API } else { // Use fallback strategy }بوم را با افزودن مشخصه
content="drawable"به عنصر HTML<canvas>مقداردهی اولیه کنید تا از پردازش عناصر HTML فرزند پشتیبانی کند. محتوای HTML خود را داخل عنصر<canvas>قرار دهید و ویژگیdrawableرا به هر عنصری که ترسیم میکنید اضافه کنید. عنصری باdrawableزیردرخت خود را ضبط میکند، بهجز نوادگان تودرتو که آنها همdrawableهستند:<canvas id="canvas" content="drawable"> <div id="html-content" drawable></div> </canvas>برای جلوگیری از تار شدن، شبکه بوم نقاشیتان را با ضریب مقیاس دستگاه مطابقت دهید:
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);محتوای HTML را در بوم داخل کنترلکننده رویداد
canvas.onpaintرندر کنید.در بافت دوبعدی، از روش
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بهجای دادههای پیکسل اختصاص دهید، که حافظه GPU را با اندازه و قالب دادهشده بدون بارگذاری هیچ پیکسلی رزرو میکند. این قوانین را دنبال کنید:- بافت را از
captureElementImage()اندازه کنید، همانطور که در بالا نشان داده شده است. - سطح ۰ را فقط در اولین بارگذاری یا زمانی که اندازه تغییر میکند اختصاص دهید، زیرا تخصیص مجدد بافت را پاک میکند. استفاده از فضای ذخیرهسازی تغییرپذیر: اندازه فضای ذخیرهسازی تغییرناپذیر
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 عناصری را که پردازش آنها تغییر کرده است فهرست میکند. مطمئن شوید که فقط درصورت بهروزرسانی عناصر اچتیامال فرزند، عنصر را دوباره ترسیم کنید یا بافت آن را دوباره بارگذاری کنید:
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
};
- هندسه عنصر را همگامسازی کنید.
مرورگر باید بداند عنصر HTML کجا ترسیم شده است تا موقعیت DOM آن با پیکسلهای ترسیمشدهاش مطابقت داشته باشد، برای مثال، تا کاربران بتوانند روی آن کلیک کنند و در آن تایپ کنند. بوم فقط عناصری را آزمایش میکند که با روش drawElementImage() دوبعدی ترسیم شدهاند یا با updateElementGeometry() ثبت شدهاند.
برای مورد بافت دوبعدی، مرورگر عنصر را بهطور خودکار همگامسازی میکند.
drawElementImage()مرزهای آزمایش برخورد DOM و حلقههای تمرکز صفحهخوان را با مختصات ترسیمشده تراز میکند، بنابراین نیازی به تنظیمstyle.transformندارید. برای مدیریت دستی هندسه، مثلاً وقتی یک عنصر را بیشاز یک بار میکشید،{ preserveElementGeometry: true }را بهdrawElementImage()منتقل کنید و تبدیلها را باcanvas.updateElementGeometry()اعمال کنید.برای مورد سهبعدی با WebGL یا WebGPU، بوم نمیتواند تشخیص دهد که عنصر HTML کجا ترسیم شده است، بنابراین باید به آن بگویید. یک
DOMMatrixرا محاسبه کنید که جعبه حاشیه عنصر را، برحسب پیکسلهای CSS، به بوم، برحسب پیکسلهای CSS، نگاشت میکند. مرورگر باید بااستفاده از تبدیل نمای دید، از فضای مختصات سهبعدی به فضای مختصات CSS نگاشت کند. برای تسهیل این کار، موارد زیر را انجام دهید:- عنصر HTML را نرمالسازی کنید. اندازه عناصر HTML برحسب پیکسل است (برای مثال، عرض ۲۰۰ پیکسل). بااینحال، WebGL معمولاً اشیاء را بهعنوان «مربعهای واحد» درنظر میگیرد، برای مثال، از ۰٫۵- تا ۰٫۵. اگر نرمالسازی نکنید، دکمه ۲۰۰ پیکسلی شما ۲۰۰ برابر بزرگتر بهنظر خواهد رسید. این مرحله همچنین محور Y را برعکس میکند، زیرا در CSS، پایین مثبت است، اما در WebGL، بالا مثبت است.
- تبدیل ماتریس WebGL MVP به ماتریس 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); }
سپس تبدیل را بهعنوان canvasTransform به canvas.updateElementGeometry() منتقل کنید. این کار عنصر HTML را جابهجا میکند تا مستقیماً روی پیکسلهای پردازششده آن قرار گیرد. این کار تضمین میکند که وقتی کاربر روی دکمهای کلیک میکند یا نوشتاری را انتخاب میکند، درواقع روی عنصر HTML واقعی ضربه میزند. مرورگر همچنین از تبدیل برای حدود دسترسپذیری عنصر استفاده میکند، برای مثال، برای حلقههای تمرکز صفحهخوان:
canvas.updateElementGeometry(targetHTMLElement, {
canvasTransform: computeCanvasTransform(canvas, targetHTMLElement, htmlElementMVP),
});
از این گزینهها برای کنترل آزمایش ضربه استفاده کنید:
- هر تماس
updateElementGeometry()عنصر را به بالای ترتیب آزمایش ضربه منتقل میکند. مجوز{ preserveHitTestOrder: true }در جایگاه خود باقی میماند. - برای برداشتن عنصر از آزمایش ضربه، مثلاً وقتی آن را پنهان میکنید،
canvas.clearElementGeometry(element)را فراخوانی کنید.
- از روشهای معمول صادر کردن بوم نقاشی مثل
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 را با محتوای رندرشده همگامسازی کنید. در حالت دوبعدی،
drawElementImageاین کار را بهطور خودکار انجام میدهد. در WebGL و WebGPU، باcanvas.updateElementGeometry(element, { canvasTransform })تماس بگیرید. - اجباری: از
ResizeObserverبرای مشاهده اندازه صفحه و بهروزرسانی اندازه بوم برای مطابقت با پیکسلهای دستگاه استفاده کنید. - محتوای میانمبدئی را در بوم نقاشی جاسازی نکنید، زیرا پشتیبانی نمیشود.
- DO NOT initialize
ResizeObserverwithin theonpaintevent handler, as it may lead to memory leaks.
راهبردهای جایگزین
HTML-in-Canvas API درحالحاضر در همه مرورگرهای مدرن پشتیبانی نمیشود، بنابراین معمولاً به یک استراتژی جایگزین نیاز است. بااینحال، با توجه به مزایای بهبود عملکرد این API، اگر مرورگر از HTML-in-Canvas پشتیبانی میکند، باید از آن استفاده شود.
برای مورداستفادهای که محتوای HTML باید از بوم صادر شود، از کتابخانههایی مثل html2canvas، dom-to-image، یا snapdom استفاده کنید.
برای ضبط تعاملات HTML بهصورت فریمبهفریم، مثلاً برای جاریسازی، جهشهای DOM را بااستفاده از کتابخانههایی مثل rrweb ضبط کنید.
یا هشداری را پیادهسازی کنید که صادر کردن رسانه HTML در مرورگر پشتیبانی نمیشود زیرا از HTML-in-Canvas پشتیبانی نمیکند.