Skip to content

render: add browser CanvasKit direct renderer#996

Closed
seo-rii wants to merge 1 commit into
edwardkim:develfrom
seo-rii:render-p16
Closed

render: add browser CanvasKit direct renderer#996
seo-rii wants to merge 1 commit into
edwardkim:develfrom
seo-rii:render-p16

Conversation

@seo-rii

@seo-rii seo-rii commented May 18, 2026

Copy link
Copy Markdown
Contributor

What

  • Studio에 browser CanvasKit renderer를 추가합니다.
  • renderer=canvaskit / renderer=skia alias로 opt-in browser backend를 선택할 수 있게 합니다.
  • canvaskitMode=default|compat, canvaskitSurface=auto|webgpu|webgl|software, renderProfile resolver를 추가합니다.
  • CanvasKit renderer는 PageLayerTree JSON을 직접 replay합니다.
  • 기본 Canvas2D path가 CanvasKit bundle을 바로 가져오지 않도록 renderer module은 dynamic import로 분리했습니다.
  • browser 쪽 PageLayerTree 타입과 getPageLayerTreeObject(page, profile) bridge를 추가했습니다.
  • CanvasKit renderer source가 Canvas2D overlay replay를 호출하지 않는 regression test를 추가했습니다.

Why

P15는 CanvasKit replay policy를 diagnostics-only API로 먼저 열었습니다. 이번 P16은 그 다음 단계로, Studio에서 실제 browser CanvasKit backend를 opt-in으로 실행할 수 있게 하는 foundation입니다.

여기서 중요한 기준은 CanvasKit을 Canvas2D-assisted preview로 두지 않는 것입니다. defaultcompat 모두 Canvas2D overlay fallback이 아니라 CanvasKit direct replay mode로 다룹니다. 아직 모든 op를 완전하게 그리는 단계는 아니고, unsupported op는 조용히 Canvas2D로 덮지 않고 renderer diagnostics에 남깁니다.

Compatibility

  • 기본 renderer는 계속 Canvas2D입니다.
  • 기존 Canvas2D overlay path는 기본 path에서 유지됩니다.
  • CanvasKit은 query/localStorage opt-in일 때만 초기화됩니다.
  • P15의 getCanvasKitReplayPlan(page, mode) API는 유지합니다.
  • public native Skia/PDF API는 바꾸지 않습니다.

Non-goals

  • CanvasKit full parity를 이 PR에서 닫지 않습니다.
  • TextRun effects, equation/raw SVG, glyph sidecar direct replay를 이 PR에서 완성하지 않습니다.
  • CanvasKit/native raster/PDF 전체 visual diff pipeline은 이 PR 범위가 아닙니다.

Validation

  • wasm-pack build --target web --dev
  • npm --prefix rhwp-studio test
  • npm --prefix rhwp-studio run build
  • cargo test test_canvaskit_replay_plan_export_uses_mode_policy --lib
  • git diff --check

Refs #536

@seo-rii seo-rii marked this pull request as ready for review May 18, 2026 11:53
Copilot AI review requested due to automatic review settings May 18, 2026 11:53

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 of PageLayerTree ops.
  • New render-backend.ts resolvers (resolveRenderBackend, resolveCanvasKitRenderMode, resolveCanvasKitSurfaceRequest, resolveRenderProfile) plus storage persistence, wired in main.tsCanvasViewPageRenderer.
  • PageLayerTree type model in core/types.ts and getPageLayerTreeObject bridge in wasm-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

  • drawMarginGuides creates a brand new SkSurface over the same HTMLCanvasElement after renderPage already created, flushed, and deleted a previous surface. With a GPU MakeCanvasSurface backend (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 by renderPage (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.preference for '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 generic MakeCanvasSurface, which always picks the default backend (typically WebGL). A user requesting ?canvaskitSurface=webgpu will silently get whatever the default is, with no unsupportedReason recorded. 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

  • imageForOp uses the full base64-encoded image bytes as the Map key (${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, or imageHashes/imageKeys from LayerResources) 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

  • parseCssColor only handles 6-digit hex and rgb()/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 a FontMgr with system fonts) before claiming default/compat are 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, when op.style is undefined the fallback object sets fillColor: null, but LayerShapeStyle.fillColor is typed string | null | undefined and the rest of the renderer treats only truthy strings as "has fill" — so the null is fine. However, op.lineStyle?.color may also be undefined, and drawStyledPath then sees strokeColor: undefined and skips the stroke entirely, while drawStyledShape would 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

  • renderImage calls MakeImageFromEncoded synchronously on each new image and inserts into the cache, then unconditionally invokes asyncResourceReady?.() 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

  • getPageLayerTreeObject calls JSON.parse on the JSON returned by WASM without a try/catch, and returns the parsed object cast as PageLayerTree with no validation other than filling in profile. 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 least root) 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.

Comment thread rhwp-studio/src/core/wasm-bridge.ts
Comment thread rhwp-studio/src/view/render-backend.ts
Comment thread rhwp-studio/src/main.ts Outdated
Comment thread rhwp-studio/src/view/render-backend.ts Outdated
Comment thread rhwp-studio/src/view/canvaskit/policy.ts
Comment thread rhwp-studio/src/view/canvaskit-renderer.ts Outdated
Comment thread rhwp-studio/src/view/page-renderer.ts
Comment thread rhwp-studio/src/view/canvaskit-renderer.ts Outdated
@edwardkim edwardkim self-requested a review May 19, 2026 11:29
@edwardkim edwardkim added the enhancement New feature or request label May 19, 2026
@edwardkim edwardkim added this to the v1.0.0 milestone May 19, 2026
edwardkim added a commit that referenced this pull request May 19, 2026
@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.
edwardkim added a commit that referenced this pull request May 19, 2026
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@edwardkim

Copy link
Copy Markdown
Owner

P16 browser CanvasKit direct renderer를 devel에 반영했습니다 (옵션 A — 본질 커밋 cherry-pick, 원작자 메타데이터 보존).

처리:

  • 본질 커밋 cherry-pick (933c056e) + package-lock 재생성 + no-ff merge (3d4a9c34)
  • 자기 검증: npm test 25/25 (신규 6 + 기존 19, 회귀 0) + npm build (canvaskit dynamic chunk 분리 확인) + cargo test 1307 + clippy -D + fmt 0

시각 판정 중 CanvasKit opt-in 모드에서 텍스트가 출력되지 않는 현상을 확인했습니다. 이는 PR에 명시된 non-goal (fontFamily 별 typeface 매핑 / glyph sidecar direct replay 미완성) 범위로, 후속 폰트 단계에서 다룰 컨텍스트를 코드 주석으로 명시했습니다. 후속 폰트 PR을 기대합니다.

canvaskit-wasm@0.41.1 = BSD-3-Clause, 프로젝트 라이센스와 호환됨을 확인했습니다.

@edwardkim edwardkim closed this May 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants