render: add browser CanvasKit direct renderer#996
Conversation
There was a problem hiding this comment.
Pull request overview
Adds an opt-in browser CanvasKit (Skia) renderer to Studio that replays PageLayerTree JSON directly (no Canvas2D overlay fallback), wires URL/localStorage resolvers for backend / mode / surface / render-profile, and exposes a new getPageLayerTreeObject bridge plus the full PageLayerTree TypeScript type surface. Canvas2D remains the default; the CanvasKit module is dynamically imported only when selected.
Changes:
- New
CanvasKitLayerRenderer(canvaskit-renderer.ts) and clip-padding policy (canvaskit/policy.ts) for direct replay ofPageLayerTreeops. - New
render-backend.tsresolvers (resolveRenderBackend,resolveCanvasKitRenderMode,resolveCanvasKitSurfaceRequest,resolveRenderProfile) plus storage persistence, wired inmain.ts→CanvasView→PageRenderer. PageLayerTreetype model incore/types.tsandgetPageLayerTreeObjectbridge inwasm-bridge.ts; regression test ensures the CanvasKit renderer source contains no Canvas2D overlay calls.
Reviewed changes
Copilot reviewed 10 out of 11 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| rhwp-studio/src/view/canvaskit-renderer.ts | New direct CanvasKit replay renderer (paint ops, image cache, diagnostics). |
| rhwp-studio/src/view/canvaskit/policy.ts | Clip right-pad helper for compat + fastPreview. |
| rhwp-studio/src/view/render-backend.ts | Backend / mode / surface / profile resolvers + localStorage persistence + clampRenderScale. |
| rhwp-studio/src/view/page-renderer.ts | Branches into renderPageCanvasKit, adds getBackend() and dispose(). |
| rhwp-studio/src/view/canvas-view.ts | Forwards backend/profile/renderer into PageRenderer, disposes on teardown. |
| rhwp-studio/src/main.ts | Resolves backend, lazy-loads CanvasKit, persists choices, exposes dev globals. |
| rhwp-studio/src/core/wasm-bridge.ts | New getPageLayerTreeObject, updated empty-tree fallback JSON, clearLayerResourceCache stub. |
| rhwp-studio/src/core/types.ts | Full PageLayerTree type hierarchy (nodes, ops, resources). |
| rhwp-studio/tests/render-backend.test.ts | Tests for resolvers and a regression check that CanvasKit source has no Canvas2D overlay. |
| rhwp-studio/package.json / package-lock.json | Adds canvaskit-wasm@^0.41.1 runtime dependency. |
Files not reviewed (1)
- rhwp-studio/package-lock.json: Language not supported
Comments suppressed due to low confidence (8)
rhwp-studio/src/view/canvaskit-renderer.ts:121
drawMarginGuidescreates a brand newSkSurfaceover the sameHTMLCanvasElementafterrenderPagealready created, flushed, and deleted a previous surface. With a GPUMakeCanvasSurfacebackend (WebGL/WebGPU), creating a fresh surface on the same canvas typically re-initializes the drawing buffer and discards the previously rendered page content, leaving only the margin guides visible. Even on the software backend the behavior is implementation-dependent. Margin guides should be drawn on the same surface used byrenderPage(or be merged into a single render pass), not on a freshly created surface.
drawMarginGuides(pageInfo: PageInfo, targetCanvas: HTMLCanvasElement, scale: number): void {
if (this.disposed) return;
const surface = this.makeSurface(targetCanvas);
const canvas = surface.getCanvas();
const paint = this.makeStrokePaint('#c0c0c0', 0.3);
const left = pageInfo.marginLeft;
const top = pageInfo.marginHeader + pageInfo.marginTop;
const right = pageInfo.width - pageInfo.marginRight;
const bottom = pageInfo.height - pageInfo.marginFooter - pageInfo.marginBottom;
const length = 15;
try {
canvas.save();
canvas.scale(scale, scale);
canvas.drawLine(left, top - length, left, top, paint);
canvas.drawLine(left, top, left - length, top, paint);
canvas.drawLine(right + length, top, right, top, paint);
canvas.drawLine(right, top, right, top - length, paint);
canvas.drawLine(left - length, bottom, left, bottom, paint);
canvas.drawLine(left, bottom, left, bottom + length, paint);
canvas.drawLine(right, bottom + length, right, bottom, paint);
canvas.drawLine(right, bottom, right + length, bottom, paint);
canvas.restore();
surface.flush();
} finally {
paint.delete?.();
surface.delete?.();
}
}
rhwp-studio/src/view/canvaskit-renderer.ts:156
- The
surfaceRequest.preferencefor'webgpu'and'webgl'is recorded in diagnostics but never applied: only'software'triggers a different code path (MakeSWCanvasSurface); all other preferences fall through to the genericMakeCanvasSurface, which always picks the default backend (typically WebGL). A user requesting?canvaskitSurface=webgpuwill silently get whatever the default is, with nounsupportedReasonrecorded. Either honor the request (e.g. call a WebGPU-specific factory when available) or record a fallback reason in diagnostics so the surface axis is actually meaningful.
private makeSurface(targetCanvas: HTMLCanvasElement): SkSurface {
if (this.surfaceRequest.preference === 'software' && typeof this.canvasKit.MakeSWCanvasSurface === 'function') {
const swSurface = this.canvasKit.MakeSWCanvasSurface(targetCanvas);
if (swSurface) return swSurface;
}
const surface = this.canvasKit.MakeCanvasSurface(targetCanvas)
?? this.canvasKit.MakeSWCanvasSurface?.(targetCanvas);
if (!surface) {
throw new Error('CanvasKit surface를 만들 수 없습니다');
}
return surface;
}
rhwp-studio/src/view/canvaskit-renderer.ts:451
imageForOpuses the full base64-encoded image bytes as theMapkey (${mime}:${base64}). For documents with multiple/large embedded images this duplicates large strings in memory and makes lookups O(n) on the key length. Prefer a stable identifier (imageRef, content hash, orimageHashes/imageKeysfromLayerResources) and fall back to a short hash of the bytes only when no identifier is available.
private imageForOp(op: LayerImageOp): any | null {
const key = op.base64 ? `${op.mime ?? 'application/octet-stream'}:${op.base64}` : null;
if (!key) return null;
const cached = this.imageCache.get(key);
if (cached) return cached;
const bytes = base64ToBytes(op.base64 ?? '');
const image = this.canvasKit.MakeImageFromEncoded(bytes);
if (!image) return null;
this.imageCache.set(key, image);
this.asyncResourceReady?.();
return image;
}
rhwp-studio/src/view/canvaskit-renderer.ts:506
parseCssColoronly handles 6-digit hex andrgb()/rgba()forms. 3-digit shorthand (#fff), 8-digit alpha hex (#rrggbbaa), and named colors are silently mapped to opaque black, which will produce visibly wrong fills/strokes if any layer op emits those forms. At minimum the 3-digit shorthand should be supported, since it is common in CSS-style color strings.
function parseCssColor(value: string): { r: number; g: number; b: number; a: number } {
const trimmed = value.trim();
const hex = /^#?([0-9a-f]{6})$/i.exec(trimmed);
if (hex) {
const n = Number.parseInt(hex[1], 16);
return {
r: (n >> 16) & 0xff,
g: (n >> 8) & 0xff,
b: n & 0xff,
a: 1,
};
}
const rgb = /^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([0-9.]+))?\)$/i.exec(trimmed);
if (rgb) {
return {
r: Number(rgb[1]),
g: Number(rgb[2]),
b: Number(rgb[3]),
a: rgb[4] === undefined ? 1 : Number(rgb[4]),
};
}
return { r: 0, g: 0, b: 0, a: 1 };
}
rhwp-studio/src/view/canvaskit-renderer.ts:358
new this.canvasKit.Font(null, fontSize)uses CanvasKit's default built-in typeface, which does not include Korean (CJK) glyphs. Any Korean text in a HWP document will render as.notdef/ tofu boxes when the CanvasKit backend is selected. This is a significant visible regression vs Canvas2D for the primary target document type. Consider loading at least one CJK-capable typeface (or using aFontMgrwith system fonts) before claimingdefault/compatare usable.
private renderTextRun(canvas: SkCanvas, op: LayerTextRunOp): void {
if (!op.text) return;
const style = op.style ?? {};
const paint = this.makeFillPaint(style.color ?? '#000000');
paint.setAntiAlias?.(true);
const fontSize = style.fontSize ?? Math.max(1, op.bbox.height || 12);
const font = new this.canvasKit.Font(null, fontSize);
const x = op.bbox.x;
const y = op.baseline ?? op.bbox.y + fontSize;
canvas.save();
if ((op.rotation ?? 0) !== 0) {
canvas.rotate(op.rotation, x, y);
}
canvas.drawText(op.text, x, y, paint, font);
canvas.restore();
font.delete?.();
paint.delete?.();
}
rhwp-studio/src/view/canvaskit-renderer.ts:292
- In
renderPath, whenop.styleis undefined the fallback object setsfillColor: null, butLayerShapeStyle.fillColoris typedstring | null | undefinedand the rest of the renderer treats only truthy strings as "has fill" — so thenullis fine. However,op.lineStyle?.colormay also be undefined, anddrawStyledPaththen seesstrokeColor: undefinedand skips the stroke entirely, whiledrawStyledShapewould draw a default 1px black stroke in the same situation. The two helpers therefore have inconsistent "no style at all" fallbacks, which can cause path ops to silently render nothing while equivalent rectangle/ellipse ops show a default outline. Consider making the fallback consistent.
private renderPath(canvas: SkCanvas, op: LayerPathOp): void {
const path = new this.canvasKit.Path();
let currentX = op.bbox.x;
let currentY = op.bbox.y;
for (const command of op.commands ?? []) {
[currentX, currentY] = this.applyPathCommand(path, command, currentX, currentY);
}
const style = op.style ?? {
strokeColor: op.lineStyle?.color,
strokeWidth: op.lineStyle?.width,
fillColor: null,
};
this.drawStyledPath(canvas, path, style);
path.delete?.();
}
rhwp-studio/src/view/canvaskit-renderer.ts:451
renderImagecallsMakeImageFromEncodedsynchronously on each new image and inserts into the cache, then unconditionally invokesasyncResourceReady?.()even though nothing async actually happened. Despite the field name (asyncResourceReady), there is no async path here; the callback will fire on every first-paint of every image, which a consumer might reasonably interpret as "a deferred resource became available, please redraw". This naming/contract mismatch is likely to cause re-render loops or confusing callback semantics. Either remove the callback (since image decoding here is sync), or only fire it from a truly async resource path.
private imageForOp(op: LayerImageOp): any | null {
const key = op.base64 ? `${op.mime ?? 'application/octet-stream'}:${op.base64}` : null;
if (!key) return null;
const cached = this.imageCache.get(key);
if (cached) return cached;
const bytes = base64ToBytes(op.base64 ?? '');
const image = this.canvasKit.MakeImageFromEncoded(bytes);
if (!image) return null;
this.imageCache.set(key, image);
this.asyncResourceReady?.();
return image;
}
rhwp-studio/src/core/wasm-bridge.ts:336
getPageLayerTreeObjectcallsJSON.parseon the JSON returned by WASM without a try/catch, and returns the parsed object cast asPageLayerTreewith no validation other than filling inprofile. Any malformed JSON (e.g. a future WASM version producing an unexpected error string) will throw a SyntaxError up into the render path. Given this is the new primary bridge to the CanvasKit renderer, defensive parsing with a clear error (and shape validation of at leastroot) would significantly improve robustness.
getPageLayerTreeObject(pageNum: number, profile: LayerRenderProfile = 'screen'): PageLayerTree {
if (!this.doc) throw new Error('문서가 로드되지 않았습니다');
const d = this.doc as unknown as {
getPageLayerTreeWithProfile?: (p: number, profile: string) => string;
getPageLayerTree?: (p: number) => string;
};
const json = typeof d.getPageLayerTreeWithProfile === 'function'
? d.getPageLayerTreeWithProfile(pageNum, profile)
: this.getPageLayerTree(pageNum);
const tree = JSON.parse(json) as PageLayerTree;
if (!tree.profile) {
tree.profile = profile;
}
return tree;
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
@seo-rii render 백엔드 시리즈 P16 — Studio opt-in browser CanvasKit backend. ?renderer=canvaskit|skia, canvaskitMode=default|compat, canvaskitSurface, renderProfile resolver. PageLayerTree JSON 직접 replay. dynamic import 로 Canvas2D 기본 경로와 분리 (canvaskit chunk 별도). unsupported op 는 Canvas2D 로 덮지 않고 diagnostics 기록. 옵션 A: 본질 커밋 933c056 cherry-pick (원작자 @seo-rii 보존) + package-lock 재생성 + P16 폰트 한계 주석 2048241 (메인테이너, 후속 폰트 단계 컨텍스트). 자기 검증: npm test 25/25 (PR 6 + 기존 19, 회귀 0) + npm build (canvaskit dynamic chunk 분리 확인) + cargo test 1307 + clippy -D + fmt 0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
P16 browser CanvasKit direct renderer를 devel에 반영했습니다 (옵션 A — 본질 커밋 cherry-pick, 원작자 메타데이터 보존). 처리:
시각 판정 중 CanvasKit opt-in 모드에서 텍스트가 출력되지 않는 현상을 확인했습니다. 이는 PR에 명시된 non-goal (fontFamily 별 typeface 매핑 / glyph sidecar direct replay 미완성) 범위로, 후속 폰트 단계에서 다룰 컨텍스트를 코드 주석으로 명시했습니다. 후속 폰트 PR을 기대합니다. canvaskit-wasm@0.41.1 = BSD-3-Clause, 프로젝트 라이센스와 호환됨을 확인했습니다. |
What
renderer=canvaskit/renderer=skiaalias로 opt-in browser backend를 선택할 수 있게 합니다.canvaskitMode=default|compat,canvaskitSurface=auto|webgpu|webgl|software,renderProfileresolver를 추가합니다.PageLayerTreeJSON을 직접 replay합니다.PageLayerTree타입과getPageLayerTreeObject(page, profile)bridge를 추가했습니다.Why
P15는 CanvasKit replay policy를 diagnostics-only API로 먼저 열었습니다. 이번 P16은 그 다음 단계로, Studio에서 실제 browser CanvasKit backend를 opt-in으로 실행할 수 있게 하는 foundation입니다.
여기서 중요한 기준은 CanvasKit을 Canvas2D-assisted preview로 두지 않는 것입니다.
default와compat모두 Canvas2D overlay fallback이 아니라 CanvasKit direct replay mode로 다룹니다. 아직 모든 op를 완전하게 그리는 단계는 아니고, unsupported op는 조용히 Canvas2D로 덮지 않고 renderer diagnostics에 남깁니다.Compatibility
getCanvasKitReplayPlan(page, mode)API는 유지합니다.Non-goals
Validation
wasm-pack build --target web --devnpm --prefix rhwp-studio testnpm --prefix rhwp-studio run buildcargo test test_canvaskit_replay_plan_export_uses_mode_policy --libgit diff --checkRefs #536