WICG / html-in-canvas

Contribute to WICG/html-in-canvas development by creating an account on GitHub.

HTML-in-Canvas
3.1k 130 1 更新于

HTML-in-Canvas

本提案旨在使用 2D 和 3D <canvas> 自定义 HTML 内容的渲染。

状态

本文档为持续更新的说明文档,会根据收到的反馈不断调整。 本文档中描述的 API 已在 Chromium 中通过功能标记实现,可在 chrome://flags/#canvas-draw-element 中启用。

动机

目前没有 Web API 能够便捷地将文本及其他内容的复杂布局渲染到 <canvas> 中。因此,基于 <canvas> 的内容在可访问性、国际化、性能与渲染质量上均存在短板。

使用场景

  • Canvas 中带样式、已布局的内容。Canvas 对高质量样式化文本的需求十分强烈,典型场景包括图表组件(图例、坐标轴等)、创意工具中的富内容面板、游戏内菜单。
  • 可访问性优化。当前用于 <canvas> 可访问性的降级内容无法保证与实际渲染内容完全一致,且这类降级内容通常难以生成。使用本 API 后,绘制到 Canvas 中的元素将与其对应的 Canvas 降级内容保持一致。
  • 为 HTML 元素合成特效。CSS 已提供有限的特效能力,如滤镜、backdrop-filter、混合模式,但开发者希望能将通用 WebGL 着色器应用于 HTML。
  • 3D 环境中的 HTML 渲染。网站与游戏的 3D 场景需要在 3D 表面中渲染高质量 2D 内容。
  • 媒体导出。需要将 HTML 内容导出为图片或视频。

提案方案

该方案引入三大核心基础能力:用于启用 Canvas 子元素的属性、将子元素绘制到 Canvas 的方法,以及用于处理更新的事件。

1. layoutsubtree 属性

<canvas> 元素上的 layoutsubtree 属性会启用其后代元素的布局与命中测试。该属性会使 Canvas 的直接子元素创建层叠上下文、成为所有后代元素的包含块,并启用绘制隔离。Canvas 子元素默认可见,但其渲染结果对用户不可见,除非通过调用 drawElementImage()(见下文)将其显式绘制到 Canvas 中。

2. drawElementImage(及 WebGL/WebGPU 对应方法)

drawElementImage() 方法将 Canvas 的子元素绘制到 Canvas 中,并返回一个可应用于 element.style.transform 的变换矩阵,用于将 DOM 位置与绘制位置对齐。 在 paint 事件触发前,浏览器会记录 Canvas 所有子元素的渲染快照。在 paint 事件期间调用时,drawElementImage() 会绘制子元素在当前帧的显示效果;在 paint 事件外调用时,则使用上一帧的快照。若在初始快照记录完成前调用 drawElementImage() 传入子元素,会抛出异常。

要求与约束

  • 在最近一次渲染更新中,<canvas> 上必须指定 layoutsubtree
  • 在最近一次渲染更新中,目标 element 必须是 <canvas> 的直接子元素。
  • 在最近一次渲染更新中,目标 element 必须生成盒子(即不能为 display: none)。
  • 变换:绘制时会应用 Canvas 当前的变换矩阵;源元素上的 CSS 变换在绘制时会被忽略(但仍会影响命中测试与可访问性,见下文)。
  • 裁剪:溢出内容(布局溢出与绘制溢出)会被裁剪至元素的边框盒。
  • 尺寸:可选的 width/height 参数指定 Canvas 坐标系中的目标矩形。若省略,默认按元素在 Canvas 外的实际屏幕尺寸与比例在 Canvas 坐标系中渲染。

WebGL/WebGPU 支持: 为 3D 上下文添加类似方法:WebGLRenderingContext.texElementImage2DcopyElementImageToTexture

3. paint 事件

paint 事件被添加到 canvas 元素,当任意 Canvas 子元素的渲染发生变化时触发。该事件在 update-the-rendering 流程中交叉观察器步骤执行完成后触发。事件包含发生变化的 Canvas 子元素列表。 由于 Canvas 子元素的 CSS 变换在绘制时被忽略,修改变换不会在下一帧触发 paint 事件。在 paint 事件中执行的 Canvas 绘制命令会在当前帧生效,但在 paint 事件中做出的 DOM 修改要到下一帧才会显示。

为支持逐帧更新的应用模式,新增 requestPaint() 函数,可强制 paint 事件触发一次,即使子元素未发生变化(作用类似 requestAnimationFrame())。

4. captureElementImage

为支持 Worker 中的 OffscreenCanvas,可通过 canvas.captureElementImage(element) 将元素快照捕获为 ElementImage 对象。该对象可被转移到 Worker 并绘制到 OffscreenCanvas

同步机制

命中测试、交叉观察器、可访问性等浏览器功能依赖元素的 DOM 位置。为保证这些功能正常工作,应更新元素的 transform 属性,使 DOM 位置与绘制位置一致。

计算匹配绘制位置的 CSS 变换 CSS 变换的通用公式为:
$$T_{\text{origin}}^{-1} \cdot S_{\text{css} \to \text{grid}}^{-1} \cdot T_{\text{draw}} \cdot S_{\text{css} \to \text{grid}} \cdot T_{\text{origin}} $$

其中:

  • $$T_{\text{draw}}$$:在 Canvas 网格坐标系中绘制元素所用的变换矩阵。 对 drawElementImage 而言,该矩阵为 $$CTM \cdot T_{(\text{x}, \text{y})} \cdot S_{(\text{destScale})}$$,其中 $$CTM$$ 为当前变换矩阵,$$T_{(\text{x}, \text{y})}$$ 为 x、y 参数对应的平移矩阵,$$S_{(\text{destScale})}$$ 为宽高参数对应的缩放矩阵。
  • $$T_{\text{origin}}$$:元素计算后 transform-origin 的平移矩阵。
  • $$S_{\text{css} \to \text{grid}}$$:将 CSS 像素转换为 Canvas 网格像素的缩放矩阵。

为辅助同步,drawElementImage() 会返回可应用于元素的 CSS 变换,使其位置保持同步。对 3D 上下文,提供 getElementTransform(element, drawTransform) 辅助方法,传入通用变换矩阵即可返回 CSS 变换。

Worker 线程中用于绘制元素的变换需要同步回 DOM;若位置固定,可直接通过 postMessage() 传回主线程。若位置动态变化,可在主线程计算位置,并在发送 ElementImage 对象到 Worker 线程的同时更新 element.style.transform

基础示例

a screenshot showing a form element with a blinking cursor
<canvas id="canvas" style="width: 400px; height: 200px;" layoutsubtree>  <form id="form_element">    <label for="name">name:</label>    <input id="name">  </form></canvas> <script>  const ctx = document.getElementById('canvas').getContext('2d');   canvas.onpaint = () => {    ctx.reset();    const transform = ctx.drawElementImage(form_element, 100, 0);    form_element.style.transform = transform.toString();  };   // 将 Canvas 网格尺寸适配设备像素比,避免模糊  const observer = new ResizeObserver(([entry]) => {    canvas.width = entry.devicePixelContentBoxSize[0].inlineSize;    canvas.height = entry.devicePixelContentBoxSize[0].blockSize;  });  observer.observe(canvas, {box: 'device-pixel-content-box'});</script>

OffscreenCanvas 示例

本示例在 Worker 中使用 OffscreenCanvas。在 paint 事件中,将 Canvas 子表单捕获为 ElementImage 对象并转移到 Worker 进行绘制。

<!DOCTYPE html><canvas id="canvas" style="width: 400px; height: 200px;" layoutsubtree>  <form id="form_element">    <label for="name">name:</label>    <input id="name">  </form></canvas><script>  const workerCode = `    let ctx;    self.onmessage = (e) => {      if (e.data.canvas) {        ctx = e.data.canvas.getContext('2d');      }      if (e.data.width && e.data.height) {        ctx.canvas.width = e.data.width;        ctx.canvas.height = e.data.height;      }      if (e.data.elementImage) {        ctx.reset();        const transform = ctx.drawElementImage(e.data.elementImage, 100, 0);        self.postMessage({transform: transform});      }    };  `;   const worker = new Worker(URL.createObjectURL(new Blob([workerCode])));  const offscreen = canvas.transferControlToOffscreen();   worker.postMessage({ canvas: offscreen }, [offscreen]);   canvas.onpaint = (event) => {    const elementImage = canvas.captureElementImage(form_element)    worker.postMessage({ elementImage: elementImage }, [elementImage]);  };   // 同步元素的 CSS 变换以匹配绘制位置  worker.onmessage = ({data}) => {    form_element.style.transform = data.transform.toString();  };   // 将 Canvas 网格尺寸适配设备像素比,避免模糊  const observer = new ResizeObserver(([entry]) => {    worker.postMessage({      width: entry.devicePixelContentBoxSize[0].inlineSize,      height: entry.devicePixelContentBoxSize[0].blockSize    });    canvas.requestPaint();  });  observer.observe(canvas, { box: 'device-pixel-content-box' });</script>

IDL 变更

partial interface HTMLCanvasElement {  [CEReactions, Reflect] attribute boolean layoutSubtree;   attribute EventHandler onpaint;   void requestPaint();   ElementImage captureElementImage(Element element);  DOMMatrix getElementTransform((Element or ElementImage) element, DOMMatrix drawTransform);}; partial interface OffscreenCanvas {  DOMMatrix getElementTransform((Element or ElementImage) element, DOMMatrix drawTransform);}; interface mixin CanvasDrawElementImage {  DOMMatrix drawElementImage((Element or ElementImage) element,                             unrestricted double dx, unrestricted double dy);   DOMMatrix drawElementImage((Element or ElementImage) element,                             unrestricted double dx, unrestricted double dy,                             unrestricted double dwidth, unrestricted double dheight);   DOMMatrix drawElementImage((Element or ElementImage) element,                             unrestricted double sx, unrestricted double sy,                             unrestricted double swidth, unrestricted double sheight,                             unrestricted double dx, unrestricted double dy);   DOMMatrix drawElementImage((Element or ElementImage) element,                             unrestricted double sx, unrestricted double sy,                             unrestricted double swidth, unrestricted double sheight,                             unrestricted double dx, unrestricted double dy,                             unrestricted double dwidth, unrestricted double dheight);}; CanvasRenderingContext2D includes CanvasDrawElementImage;OffscreenCanvasRenderingContext2D includes CanvasDrawElementImage; partial interface WebGLRenderingContext {  void texElementImage2D(GLenum target, GLint level, GLint internalformat,                         GLenum format, GLenum type, (Element or ElementImage) element);}; partial interface GPUQueue {  void copyElementImageToTexture((Element or ElementImage) source,                                 GPUImageCopyTextureTagged destination);}[Exposed=Window]interface PaintEvent : Event {  constructor(DOMString type, optional PaintEventInit eventInitDict);   readonly attribute FrozenArray<Element> changedElements;}; dictionary PaintEventInit : EventInit {  sequence<Element> changedElements = [];};[Exposed=(Window,Worker), Transferable]interface ElementImage {  readonly attribute unsigned long width;  readonly attribute unsigned long height;  undefined close();};

演示示例

在线演示源码)使用 drawElementImage API 绘制旋转的复杂文本。

screenshot showing rotated, complex text drawn into canvas

在线演示源码)使用 drawElementImage API 绘制带多行标签的饼图。

screenshot showing a pie chart

在线演示源码)使用 WebGPU copyElementImage API 在果冻滑块下方绘制 div。

screenshot showing a range slider with a jelly effect

在线演示源码)使用 WebGL texElementImage2D API 将 HTML 绘制到 3D 立方体上。

screenshot showing html content on a 3D cube

基于 three.js 实验扩展实现的相同效果演示地址为此处。更多说明与背景信息见此处

在线演示源码)Canvas 中的交互式内容演示。

screenshot showing a form drawn into canvas

隐私安全的渲染机制

drawElementImage() 方法、其他所有绘制元素图像快照的方法,以及 paint 事件,均不得泄露任何对开发者代码不可见的安全或隐私敏感信息。

渲染(通过 Canvas 像素读取或计时攻击)与失效(通过 onpaint)均存在泄露敏感信息的风险,解决方案是在渲染与失效时排除敏感信息。

敏感信息包括:

  • 嵌入式内容(如 <iframe><img>)、<url> 引用(如 background-imageclip-path)与 SVG(如 <use>)中的跨域数据。注:同源 iframe 仍可正常绘制,但其中的跨域内容不会被绘制。
  • 系统颜色、主题或偏好设置。
  • 拼写与语法标记。
  • 已访问链接信息。
  • JavaScript 无法获取的待填充表单自动完成信息。
  • 亚像素文本抗锯齿。

以下新增信息不视为敏感信息:

  • 搜索文本(页面内查找)与文本片段标记。
  • 滚动条与表单元素外观(Blink 与 WebKit 中已可通过 foreignObject 检测)。
  • 光标闪烁频率。
  • 强制颜色模式(该信息已可通过 forced-colors 媒体查询与系统颜色在 JavaScript 中获取)。

开发者试用信息

可在 Chrome Canary 中通过 chrome://flags/#canvas-draw-element 启用 HTML-in-Canvas 功能。

我们重点关注以下方向的反馈:

  • 哪些内容可正常工作,哪些失败?哪些失败场景最需要修复?
  • 该功能与可访问性功能如何交互?如何优化可访问性支持?

请在此处提交缺陷或设计问题。

备选方案:paint 事件触发时机

需要新增 paint 事件,让开发者有机会在绘制变化时更新 Canvas 渲染。该事件被整合进 update-the-rendering 流程,使 Canvas 更新与 DOM 同步。

update-the-rendering 步骤中,paint 事件有多个可触发时机:

    1. 执行动画帧回调。
  • 16.2.1. 重新计算样式并更新布局。

  • 16.2.6. 分发尺寸观察器回调,必要时循环回到 16.2.1。

  • 选项 A:在尺寸观察器时机触发 paint,必要时循环回到 16.2.1。

    1. 执行交叉观察器更新步骤。
  • 绘制阶段:计算元素的最终绘制输出。该步骤在 update-the-rendering 中无显式命名。

  • 选项 B:在绘制阶段后立即触发 paint,必要时循环回到 16.2.1。

  • 选项 C:在绘制阶段后立即触发 paint

  • 提交/线程切换:将绘制输出传递到其他进程。该步骤在 update-the-rendering 中无显式命名。

注:本提案中的 paint 事件是 Canvas 上新增的事件,而绘制阶段是浏览器按绘制顺序记录渲染树绘制输出的现有操作。

选项 A:在尺寸观察器时机触发 paint,必要时循环执行

与尺寸观察器类似,需要循环处理 paint 事件中可能产生的修改(包括 Canvas 外元素)。无法限制 JavaScript 对 DOM 的任意修改,因此需要比尺寸观察器更多的循环条件,如背景样式变化。循环的缺点是开发者的 Canvas 代码可能每帧执行多次。

一种方案是同步执行绘制阶段,快照 Canvas 子元素的绘制输出。缺点是绘制阶段开销可能较高,且可能需要多次执行。该方案在 Gecko 及其他引擎中因架构限制存在独特的实现难题。

另一种方案是不同步执行绘制阶段,而是记录一个占位符,表示元素在下一次渲染更新中的显示效果(见设计文档)。该模式可在 2D Canvas 中实现,通过缓冲 Canvas 命令直到下一次绘制阶段。当下一次绘制阶段执行时,占位符会被替换为实际渲染结果。getImageData 等需要同步刷新 Canvas 命令缓冲区的操作,会对占位符显示空白或旧数据。 遗憾的是,该方案对 WebGL 存在根本性缺陷——许多 API 需要刷新命令缓冲区(如 getError(),见 WaitForCmd 调用点),调用这类 API 会导致死锁或渲染不一致。因此,paint 事件必须在元素完整绘制显示列表已就绪的时机触发。

选项 B:在绘制阶段后立即触发 paint,必要时循环执行

循环的原因与缺点见上文。 相比选项 A,选项 B 的优势是无需对 Canvas 子元素执行部分绘制;缺点是每次循环需要执行更多 update-the-rendering 步骤。

选项 C:在绘制阶段后立即触发 paint

本 API 采用该设计。 该方案每帧仅执行一次 paint,与浏览器自身绘制阶段一致。为解决 JavaScript 可任意修改 DOM 的问题,需确保 paint 运行前已锁定本次渲染更新的内容,唯一例外是 Canvas 的绘制内容。paint 事件中产生的 DOM 失效会作用于下一帧,而非当前帧。

备选方案:使用 Worker 线程实现线程化特效

为支持线程化特效,我们探索了一种设计方案:将 Canvas 子元素“快照”发送到 Worker 线程。响应线程化滚动与动画时,Worker 线程可将最新快照渲染到 OffscreenCanvas。该模式要求 JavaScript 能在滚动与动画更新时同步调用,而在受限进程中执行线程化滚动更新的架构难以实现这一点。

未来规划:通过自动更新 Canvas 支持线程化特效

为支持滚动、动画等线程化特效,我们计划在未来推出“自动更新 Canvas”模式。

在该模式下,drawElementImage 会记录代表最新渲染的占位符。Canvas 保留命令缓冲区,可在每次滚动或动画更新后自动重放。这使 Canvas 能使用更新后的占位符重新光栅化,整合线程化滚动与动画,无需阻塞主线程。该能力可实现与原生滚动或 Canvas 内动画完全同步的视觉特效,不受主线程影响。该设计适用于 2D 上下文,对 WebGPU 只需少量 API 补充即可实现。

其他文档

作者