# 01. HTML-in-Canvas API 정확한 스펙 > 조사 기준일: 2026-08-20 > 1차 출처: WICG 공식 explainer(living document), WHATWG HTML PR, Chrome Platform Status, Chrome for Developers 블로그, blink-dev Intent 스레드 > **이 문서의 모든 시그니처는 WICG explainer의 IDL 블록 원문에서 그대로 옮긴 것이다. 추측한 부분은 명시적으로 "미확인"으로 표시했다.** --- ## 0. 한 줄 요약 `` 안에 실제 HTML을 넣고, `paint` 이벤트 안에서 `ctx.drawElementImage(el, x, y)`(2D) / `gl.texElementImage2D(...)`(WebGL) / `device.queue.copyElementImageToTexture(...)`(WebGPU)를 호출하면, 그 HTML의 **살아있는 렌더링 결과**가 캔버스 픽셀(또는 GPU 텍스처)로 들어온다. 원본 요소는 DOM에 그대로 남아 클릭·포커스·접근성·find-in-page가 계속 동작한다. ## 1. 공식 명칭과 저장소 | 항목 | 값 | |---|---| | 기능 이름 | **HTML-in-canvas** (Chrome Platform Status 등록명) | | 인큐베이션 | W3C WICG | | Explainer 저장소 | `https://github.com/WICG/html-in-canvas` | | Explainer 렌더링 | `https://wicg.github.io/html-in-canvas/` (형식 스펙이 아니라 explainer 그 자체) | | 스펙 PR | `https://github.com/whatwg/html/pull/11588` — "Add HTML-in-Canvas APIs", 2025-08-21 개설, **2026-08 현재 open / 미머지** | | chromestatus | `https://chromestatus.com/feature/5172548013916160` | | Chromium 버그 | `https://crbug.com/500967896` (Blink 컴포넌트: `Blink>Canvas`) | | 저자 | Philip Rogers, Stephen Chenney(Igalia), Chris Harrelson, Philip Jägenstedt, Khushal Sagar, Vladimir Levin, Fernando Serboncini | ### 1.1 이름 변천사 — 영상/구 자료의 이름이 지금과 다른 이유 노마드코더 영상 및 상당수의 블로그가 쓰는 `canvas place element` / `drawElement` / `setHitTestRegions()`는 **모두 옛 이름**이다. WICG 저장소 커밋 로그로 확인한 실제 변천: | 날짜 | 변경 | |---|---| | ~2025-08 이전 | 제안 이름 `canvas place element`, 메서드 `drawElement()`, 히트테스트는 `setHitTestRegions()` | | 2025-08-22 | 메서드 rename (`drawElement` → `drawHTMLElement`) | | 2025-09-05 | `drawHTMLElement` → **`drawHTML`** | | 2025-09-11 | `drawHTML` → **`drawElementImage`** ← **현재 이름** | | 2025-10-08 | explainer에서 "place element" 표현 전부 제거 (저장소도 `WICG/canvas-place-element` → `WICG/html-in-canvas`. 옛 저장소는 현재 404) | | 2025-11-08 | **`setHitTestRegions()` 폐기** → "Switch to CSS transforms for hit testing" (반환된 `DOMMatrix`를 `element.style.transform`에 넣는 방식으로 대체) | | 2026-02-10~25 | `paint` 이벤트 / `onpaint` 설계 확정, 이벤트에서 `time` 인자 제거 | | 2026-03-17~31 | `captureElementImage()` + `ElementImage` (OffscreenCanvas 지원) 추가 | | 2026-03-20 | `drawElementImage()` 소스 사각형(sx/sy/swidth/sheight) 오버로드 추가 | | 2026-04-16 / 06-01 | WebGL/WebGPU IDL 대폭 변경 (**아래 4·5절의 "구 시그니처" 주의사항 참조**) | | 2026-06-16 | "privacy-preserving painting" → **"read-back-allowed rendering"** 으로 개념 이름 변경 | | 2026-07-13/14 | 중첩 canvas 허용, `paint` 이벤트가 **역트리 순서**로 발화함을 명시 | > ⚠️ WHATWG 스펙 PR #11588 본문에는 아직 `setHitTestRegions()`와 `drawable` 속성이 남아 있다. **explainer(=구현 기준)와 스펙 PR이 아직 동기화되지 않은 상태**이므로, 구현 기준은 explainer를 따라야 한다. --- ## 2. `layoutsubtree` 속성 **철자: 전부 소문자 `layoutsubtree`** (HTML 속성). IDL 반사 프로퍼티는 camelCase `layoutSubtree`. ```html
``` - `boolean` 타입 → **불리언 속성**. 존재하기만 하면 true다. `layoutsubtree="true"`도 되고(WICG 예제가 이 형태를 쓴다) `layoutsubtree=""`도 된다. `layoutsubtree="false"`라고 써도 **true로 취급**되니 끄려면 속성 자체를 제거해야 한다. - 효과 (explainer 원문 기준): - 캔버스 자손이 **레이아웃에 참여**하고 **히트테스트에 참여**한다. - ``의 **직계 자식**은 stacking context를 생성하고, 모든 자손의 containing block이 되며, **paint containment**를 갖는다. - 자식은 "보이는 것처럼" 동작하지만, `drawElementImage()`로 명시적으로 그려지기 전까지 **사용자에게는 보이지 않는다**. - 이 속성이 없으면 캔버스 자식은 종전대로 fallback 콘텐츠일 뿐이고, `drawElementImage()`는 예외를 던진다. --- ## 3. 2D 컨텍스트 — `drawElementImage()` ### 3.1 IDL (explainer 원문 그대로) ```webidl 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; ``` 오버로드는 인자 개수 **3 / 5 / 7 / 9개** 네 가지다 — `(el, dx, dy)`, `(el, dx, dy, dw, dh)`, `(el, sx, sy, sw, sh, dx, dy)`, `(el, sx, sy, sw, sh, dx, dy, dw, dh)`. 기존 `CanvasRenderingContext2D.drawImage()`와 정확히 같은 모양이고, 첫 인자만 이미지 소스 대신 `Element`(또는 `ElementImage`)로 바뀐 것이다. ### 3.2 반환값 **`DOMMatrix`** — 이 행렬을 `element.style.transform = returned.toString()` 으로 적용하면, DOM 상의 요소 위치가 캔버스에 그려진 위치와 일치한다. 히트테스트·포커스 링·IntersectionObserver·접근성 좌표가 이 DOM 위치를 쓰기 때문에 **이 동기화를 하지 않으면 클릭이 엉뚱한 곳에 떨어진다.** ### 3.3 요구사항·제약 (explainer 원문 항목) - 최근 렌더링 업데이트 시점에 ``에 `layoutsubtree`가 지정되어 있어야 한다. - `element`는 최근 렌더링 업데이트 시점에 ``의 **직계 자식(direct child)** 이어야 한다. - `element`가 **박스를 생성**해야 한다 (즉 `display:none` 이면 안 된다). - **변환(Transforms)**: 캔버스의 현재 변환 행렬(CTM)은 그리기에 적용된다. 반면 **소스 `element`에 걸린 CSS transform은 그리기에서 무시된다.** (다만 히트테스트/접근성에는 계속 영향을 준다 — 그래서 3.2의 동기화가 성립한다.) - **클리핑**: 넘치는 콘텐츠(layout overflow, ink overflow 모두)는 요소의 **border box로 클리핑**된다. - **크기**: `width`/`height` 인자는 캔버스 좌표계의 목적지 사각형이다. 생략하면 **캔버스 밖에 있을 때와 같은 화면상 크기·비율**이 되도록 자동 사이징된다. → 실전 의미: `canvas.width`를 device pixel로 잡으면 x/y 좌표도 device pixel 단위여야 한다 (`x * devicePixelRatio`). ### 3.4 스냅샷 타이밍 (중요) - 캔버스 모든 자식의 렌더링 스냅샷은 **`paint` 이벤트 직전**에 기록된다. - `paint` 이벤트 **안에서** 호출하면 → **현재 프레임**의 모습으로 그려진다. - `paint` 이벤트 **밖에서** 호출하면 → **직전 프레임**의 스냅샷이 쓰인다. - 최초 스냅샷이 기록되기 전에 호출하면 **예외**가 발생한다 (Chromium 구현에서는 `InvalidStateError`). --- ## 4. WebGL — `texElementImage2D()` ### 4.1 현재 IDL (explainer 원문) ```webidl dictionary WebGLCopyElementImageConfig { GLfloat sx; GLfloat sy; GLfloat swidth; GLfloat sheight; GLsizei width; GLsizei height; }; partial interface WebGLRenderingContext { void texElementImage2D(GLenum target, GLenum internalformat, (Element or ElementImage) element, optional WebGLCopyElementImageConfig config = {}); }; ``` 호출: ```js gl.texElementImage2D(gl.TEXTURE_2D, gl.RGBA8, element); ``` ### 4.2 ⚠️ 시그니처가 2026-04~06에 바뀌었다 - **구 시그니처**: `texElementImage2D(target, level, internalformat, format, type, element)` — `texImage2D`와 똑같은 6인자. - **신 시그니처**: `texElementImage2D(target, internalformat, element, config?)` — `level`, `format`, `type`이 사라졌다. - Chrome for Developers 블로그(2026-05-19 최종 수정)의 코드 예제는 **아직 구 시그니처**(`gl.texElementImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, form_element)`)를 보여준다. 반면 WICG 공식 예제 `Examples/webGL.html`은 try/catch로 신·구 양쪽을 지원한다: ```js try { gl.texElementImage2D(gl.TEXTURE_2D, gl.RGBA8, draw_element); // 신 } catch (e) { gl.texElementImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, // 구 gl.UNSIGNED_BYTE, draw_element); } ``` → **실전 코드에서는 이 try/catch 패턴을 그대로 쓰는 게 안전하다.** ### 4.3 알려진 미해결 이슈 - Jake Archibald가 blink-dev Intent 스레드에서 지적: **WebGL 경로에서 텍스처 크기를 지정할 방법이 없다.** `config`의 `width`/`height`가 추가된 배경이지만 논의는 진행 중. --- ## 5. WebGPU — `copyElementImageToTexture()` ### 5.1 현재 IDL (explainer 원문) ```webidl dictionary GPUCopyElementImageDestination { required GPUImageCopyTextureTagged destination; GPUIntegerCoordinate width; GPUIntegerCoordinate height; }; dictionary GPUCopyElementImageSource { required (Element or ElementImage) source; float sx; float sy; float swidth; float sheight; }; partial interface GPUQueue { void copyElementImageToTexture(GPUCopyElementImageSource source, GPUCopyElementImageDestination destination); } ``` ### 5.2 실제 호출 형태 (WICG 젤리 슬라이더 데모 `Examples/webgpu-jelly-slider/src/index.ts` 원문) ```js canvas.onpaint = () => { const sourceDict = { source: valueElement }; const destDict = { destination: { texture: valueRawTexture }, width: width, height: height }; try { device.queue.copyElementImageToTexture(sourceDict, destDict); // 신 } catch (e) { device.queue.copyElementImageToTexture(valueElement, width, height, // 구 { texture: valueRawTexture }); console.log('Note: using old copyElementImageToTexture API'); } // ... transform 동기화 }; ``` > ⚠️ Chrome 블로그는 `device.queue.copyElementImageToTexture(valueElement, { texture: targetTexture })` 라는 **또 다른(간략화된/구) 형태**를 보여준다. IDL과 WICG 데모 소스가 서로 일치하므로 **위 dictionary 2개 형태가 현재 기준**이다. `copyExternalImageToTexture()`의 DOM 요소 버전이라고 생각하면 된다. --- ## 6. `paint` 이벤트 ### 6.1 IDL ```webidl [Exposed=Window] interface PaintEvent : Event { constructor(DOMString type, optional PaintEventInit eventInitDict); readonly attribute FrozenArray changedElements; }; dictionary PaintEventInit : EventInit { sequence changedElements = []; }; ``` `canvas.onpaint = fn` 또는 `canvas.addEventListener('paint', fn)` 둘 다 가능. ### 6.2 동작 규칙 (explainer 원문 기준) - 캔버스 자식들의 **렌더링이 변했을 때** 발화한다 (포커스, 호버, 입력, CSS 애니메이션 등). - 발화 시점: [update-the-rendering](https://html.spec.whatwg.org/#update-the-rendering) 중 **IntersectionObserver 단계가 실행된 직후**. 설계 문서상으로는 "Paint 단계 직후, 루프 없이 프레임당 1회"(explainer의 Option C). - 이벤트는 **바뀐 자식들의 목록**(`changedElements`)을 담는다. - 캔버스 자식의 **CSS transform 변경은 렌더링에서 무시**되므로, transform만 바꿔서는 다음 프레임에 `paint`가 발화하지 않는다. (→ 3.2의 동기화 코드가 무한 루프를 만들지 않는 이유) - `paint` 안에서 한 **캔버스 드로잉 명령은 현재 프레임에 반영**되지만, `paint` 안에서 한 **DOM 변경은 다음 프레임부터** 반영된다. - ``가 여러 개면 `paint`는 **역트리 순서(reverse tree order)** 로 발화한다 → 자손이 조상보다 먼저 발화한다 (중첩 canvas 지원, 2026-07 추가). ### 6.3 `requestPaint()` ```webidl void requestPaint(); ``` 자식이 하나도 안 바뀌어도 `paint`를 **한 번** 강제로 발화시킨다. explainer 표현으로 "`requestAnimationFrame()`과 유사". 매 프레임 갱신이 필요한 앱은 `onpaint` 핸들러 끝에서 `requestPaint()`를 다시 호출해 루프를 만든다. 또한 **최초 1회 호출해서 렌더 파이프라인을 킥스타트**해야 한다(첫 스냅샷 확보). --- ## 7. OffscreenCanvas / Worker — `captureElementImage()` & `ElementImage` ```webidl 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); }; [Exposed=(Window,Worker), Transferable] interface ElementImage { readonly attribute double width; readonly attribute double height; undefined close(); }; ``` - `canvas.captureElementImage(element)` → 요소 렌더링의 **전송 가능한(Transferable) 스냅샷**. - `postMessage(msg, [elementImage])`로 워커에 넘기고, 워커의 `OffscreenCanvasRenderingContext2D.drawElementImage(elementImage, x, y)`로 그린다. - 워커에서 계산된 transform은 `postMessage`로 메인 스레드에 돌려보내 `element.style.transform`에 적용해야 한다. 위치가 동적이면 메인 스레드에서 미리 계산해 `ElementImage` 전송과 동시에 적용하는 편이 낫다. --- ## 8. `getElementTransform()` — 3D 컨텍스트용 동기화 헬퍼 ```webidl DOMMatrix getElementTransform((Element or ElementImage) element, DOMMatrix drawTransform); ``` - **`HTMLCanvasElement`와 `OffscreenCanvas`에 있다.** (Chrome 블로그 본문 산문이 `element.getElementTransform()`이라고 쓴 곳이 있는데, **블로그 자체의 코드 예제와 IDL 모두 `canvas.getElementTransform(el, matrix)`** 이므로 산문 쪽이 오기다.) - WebGL/WebGPU에서는 요소의 최종 화면 위치를 셰이더가 결정하므로 `drawElementImage()`처럼 자동으로 알 수 없다. 그래서 개발자가 MVP 행렬을 스크린 스페이스 행렬로 변환해 넘겨주면, 이 메서드가 `style.transform`에 넣을 `DOMMatrix`를 돌려준다. ### 8.1 변환 공식 (explainer 원문) ``` T_origin⁻¹ · S_css→grid⁻¹ · T_draw · S_css→grid · T_origin ``` - `T_draw`: 캔버스 그리드 좌표계에서 요소를 그린 변환. `drawElementImage`의 경우 `CTM · T_(x,y) · S_(destScale)`. - `T_origin`: 요소의 계산된 `transform-origin` 평행이동 행렬. - `S_css→grid`: CSS 픽셀 → 캔버스 그리드 픽셀 스케일 행렬. ### 8.2 WebGL에서 screenSpaceTransform 만드는 절차 (Chrome 블로그 원문 코드) ```js // 1. WebGL MVP → DOMMatrix const mvpDOM = new DOMMatrix(Array.from(htmlElementMVP)); // 2. HTML 요소 정규화 (px → 1x1 단위 사각형, Y축 뒤집기) const width = targetHTMLElement.offsetWidth; const height = targetHTMLElement.offsetHeight; const cssToUnitSpace = new DOMMatrix() .scale(1 / width, -1 / height, 1) .translate(-width / 2, -height / 2); // 3. 클립 공간 → 캔버스 뷰포트 const clipToCanvasViewport = new DOMMatrix() .translate(canvas.width / 2, canvas.height / 2) .scale(canvas.width / 2, -canvas.height / 2, 1); // 4. 합성: (Clip→Pixels) * MVP * (px→unit) const screenSpaceTransform = clipToCanvasViewport.multiply(mvpDOM).multiply(cssToUnitSpace); // 5. 적용 const computedTransform = canvas.getElementTransform(targetHTMLElement, screenSpaceTransform); if (computedTransform) targetHTMLElement.style.transform = computedTransform.toString(); ``` --- ## 9. 접근성·히트테스트가 유지되는 원리 핵심은 **"같은 요소가 두 곳에 존재"가 아니라 "요소는 DOM에 한 번만 존재하고, 캔버스에는 그 픽셀 복사본이 있다"** 는 것이다. - 히트테스트, 포커스, 키보드 탭 순서, 텍스트 선택, 복사/붙여넣기, 우클릭 컨텍스트 메뉴, find-in-page, 번역, 리더 모드, 확장 프로그램, 브라우저 줌, 자동완성 — **전부 DOM 쪽이 처리한다.** 캔버스는 픽셀만 담당한다. - `layoutsubtree`가 자식들을 **접근성 트리에 노출**시킨다. 기존 canvas fallback 콘텐츠와 달리, 그려진 내용과 접근성 트리가 **구조적으로 일치함이 보장**된다 (explainer가 명시한 주요 동기 중 하나). - 단 **DOM 위치와 그려진 위치가 어긋나면 전부 어긋난다.** → `drawElementImage()`의 반환 `DOMMatrix`(또는 `getElementTransform()`)를 매 프레임 `style.transform`에 반영하는 것이 이 API의 필수 계약이다. - DevTools에서 캔버스 안 HTML을 그대로 인스펙트/스타일 수정할 수 있고, 수정 즉시 텍스처에 반영된다. --- ## 10. 보안·프라이버시: "Read-back-allowed rendering" (구 명칭 "privacy-preserving painting", 2026-06-16 개칭) 캔버스 픽셀은 `getImageData()`로 읽을 수 있고, WebGL/WebGPU에서는 항상 읽을 수 있다. 따라서 **저자 코드가 원래 볼 수 없던 정보는 애초에 그려지지 않는다.** 페인팅(픽셀 읽기·타이밍 공격)과 무효화(`onpaint` 발화 여부) **양쪽 모두**에서 민감 정보를 배제한다. ### 그려지지 않는(=민감) 정보 - **cross-origin 데이터**: `