lecodes-web-canvas 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,87 @@
1
+ // Loads the reused creator-ui (Yoga + style parser) WASM. The glue is built `-sENVIRONMENT=web`,
2
+ // so headless (Bun) needs a `window` shim and the wasm bytes handed in via `wasmBinary` (proven to
3
+ // work in a probe: shim + wasmBinary → a full layout round-trip).
4
+ //
5
+ // This file is BROWSER-SAFE: it has no Node imports. The headless (fs-based) loader lives in
6
+ // `loadCreatorUiNode.ts` so a browser bundle (viewer/frontend) never pulls `node:fs` into its graph.
7
+
8
+ /** The subset of the emscripten module the adapter uses. */
9
+ export interface CreatorUiModule {
10
+ _malloc(size: number): number
11
+ _free(ptr: number): void
12
+ stringToUTF8(str: string, ptr: number, maxBytes: number): void
13
+ UTF8ToString(ptr: number): string
14
+ lengthBytesUTF8(str: string): number
15
+ addFunction(fn: (...args: any[]) => any, sig: string): number
16
+ HEAPF32: Float32Array
17
+ HEAPU32: Uint32Array
18
+
19
+ _setMeasureFunc(funcPtr: number): void
20
+ // The paint state (creator-ui/paint.gen.h): the dirty records of the frame + a node's record in place.
21
+ _paintCollect(): number
22
+ _paintNodes(): number
23
+ _paintRecords(): number
24
+ _cuiPaintOf(nodePtr: number): number
25
+ _cuiStringOf(id: number): number
26
+ _cuiGradientOf(id: number, outCountPtr: number): number
27
+
28
+ _createNodeRoot(screenId: number): number
29
+ _createNode(parentId: number, index: number, hasMeasureFunc: number): number
30
+ _removeNode(parentId: number, nodeId: number): void
31
+ _freeNode(rootId: number): void
32
+ _freeWidget(rootId: number): void
33
+
34
+ _setFloatExt(layer: number, nodeId: number, keyPtr: number, value: number): void
35
+ _setStringExt(layer: number, nodeId: number, keyPtr: number, valuePtr: number): void
36
+ _resetProp(nodeId: number, keyPtr: number): void
37
+ // Layer-scoped clear (`prop: null` inside a `$class` block, `$landscape`/`$portrait` too): the
38
+ // null lives in `layer` (reset semantics while active); layer 256 keeps _resetProp's erase semantics.
39
+ _resetPropExt(layer: number, nodeId: number, keyPtr: number): void
40
+ _markNodeDirty(nodeId: number): void
41
+
42
+ _getLayer(nodeId: number, keyPtr: number): number
43
+ _enableNodeLayer(nodeId: number, layer: number): void
44
+ _disableNodeLayer(nodeId: number, layer: number): void
45
+
46
+ _calculate(rootId: number, width: number, height: number): void
47
+ _calculateWidget(rootId: number, width: number, height: number): void
48
+ _getLayoutPointer(nodeId: number, outPtr: number): number
49
+ _getPaddingPointer(nodeId: number, outPtr: number): number
50
+ _getScreenId(nodeId: number): number
51
+ _getContentHeight(nodeId: number): number
52
+ _getContentWidth(nodeId: number): number
53
+
54
+ _setConditionValue(width: number, height: number): void
55
+ // (top, right, bottom, left[, nodePtr]) — CSS order; passing a node re-applies the dynamic
56
+ // `safe-*` styles over that subtree immediately (recalcPaddings).
57
+ _setSafePaddings(top: number, right: number, bottom: number, left: number, nodePtr?: number): void
58
+ // Which edges are exact-height system bars (comfort-* adds its knob) vs clearance insets
59
+ // (comfort-* floors). Bitmask: top=1, right=2, bottom=4, left=8. Call before _setSafePaddings.
60
+ _setSafeAreaBars(barEdges: number): void
61
+
62
+ // Theme variables (docs/ui-theme-plan.md): stage one key (both args are C strings; value 0
63
+ // removes), then _applyTheme() re-resolves dynamic styles on every live root — returns 1 when
64
+ // anything re-dispatched (relayout + repaint wanted).
65
+ _setThemeValue(namePtr: number, valuePtr: number): void
66
+ _applyTheme(): number
67
+
68
+ // The core's color parser (color.h): CSS color string → packed 0xRRGGBBAA (web build). The
69
+ // SAME parser node color styles go through — used for theme color values that never ride a
70
+ // node (the themable default text color).
71
+ _getColor(valuePtr: number): number
72
+ }
73
+
74
+ type CreateModule = (opts: Record<string, unknown>) => Promise<CreatorUiModule>
75
+
76
+ export type { CreateModule }
77
+
78
+ /** Load the module from a caller-provided emscripten factory + wasm bytes (browser path). */
79
+ export async function loadCreatorUiFromModule(
80
+ createModule: CreateModule,
81
+ wasmBinary: ArrayBuffer | Uint8Array,
82
+ ): Promise<CreatorUiModule> {
83
+ if (typeof (globalThis as any).window === "undefined") {
84
+ ;(globalThis as any).window = globalThis
85
+ }
86
+ return createModule({ wasmBinary, locateFile: () => "" })
87
+ }
@@ -0,0 +1,131 @@
1
+ // The paint state reader (creator-ui/paint.gen.h, CuiPaint.gen.ts): the runtime keeps one resolved
2
+ // paint record per node; this renderer READS it — the dirty records `_paintCollect` hands out once
3
+ // per frame, or a node's record in place (`_cuiPaintOf`) whenever it measures. The per-prop style
4
+ // handlers this file replaced (2026-09-24) wrote the same ResolvedStyle field by field; here one
5
+ // record write fills it, and the text style arrives EFFECTIVE (the core inherits), so no ancestor
6
+ // walk remains anywhere in the renderer.
7
+ import type { ResolvedStyle } from "../../types"
8
+ import { CUI_FIELDS, CUI_NAMES, CUI_PAINT_WORDS, CuiFontStyle, CuiOverflow, CuiScrollDirection, cuiBit } from "../CuiPaint.gen"
9
+ import { readGradientStructs } from "./readGradient"
10
+
11
+ /** The wasm module surface the reader needs (a subset of CreatorUiModule). */
12
+ export interface PaintModule {
13
+ HEAPF32: Float32Array
14
+ HEAPU32: Uint32Array
15
+ UTF8ToString(ptr: number): string
16
+ _malloc(size: number): number
17
+ _paintCollect(): number
18
+ _paintNodes(): number
19
+ _paintRecords(): number
20
+ _cuiPaintOf(nodePtr: number): number
21
+ _cuiStringOf(id: number): number
22
+ _cuiGradientOf(id: number, outCountPtr: number): number
23
+ }
24
+
25
+ const F = CUI_FIELDS
26
+ const ALIGN: ResolvedStyle["textAlign"][] = ["start", "center", "end", "start", "end"] // start center end left right
27
+
28
+ let countPtr = 0
29
+
30
+ /** Read the record at `ptr` (a word-aligned CUIPaint) into `style`. */
31
+ export function readPaintRecord(M: PaintModule, ptr: number, style: ResolvedStyle): void {
32
+ const u32 = M.HEAPU32, f32 = M.HEAPF32
33
+ const w = ptr >>> 2
34
+ const presentLo = u32[w], presentHi = u32[w + 1]
35
+ const has = (bit: number) => cuiBit(presentLo, presentHi, bit)
36
+ const str = (word: number): string | undefined => {
37
+ const id = u32[w + word]
38
+ return id === 0 ? undefined : M.UTF8ToString(M._cuiStringOf(id))
39
+ }
40
+
41
+ // box
42
+ style.backgroundColor = u32[w + F.backgroundColor.word] >>> 0
43
+ style.opacity = f32[w + F.opacity.word]
44
+ style.dim = f32[w + F.dim.word]
45
+ style.display = CUI_NAMES.display[u32[w + F.display.word]]
46
+ style.overflow = u32[w + F.overflow.word] === CuiOverflow.hidden ? "hidden" : "visible"
47
+ style.scrollAxis = u32[w + F.scrollDirection.word] === CuiScrollDirection.horizontal ? "x" : "y"
48
+ // borders: the core fans borderWidth / borderColor out into the four sides (paint.cpp)
49
+ style.border = {
50
+ top: { width: f32[w + F.borderTopWidth.word], color: u32[w + F.borderTopColor.word] >>> 0 },
51
+ right: { width: f32[w + F.borderRightWidth.word], color: u32[w + F.borderRightColor.word] >>> 0 },
52
+ bottom: { width: f32[w + F.borderBottomWidth.word], color: u32[w + F.borderBottomColor.word] >>> 0 },
53
+ left: { width: f32[w + F.borderLeftWidth.word], color: u32[w + F.borderLeftColor.word] >>> 0 },
54
+ }
55
+ style.borderRadius = {
56
+ tl: f32[w + F.borderTopLeftRadius.word], tr: f32[w + F.borderTopRightRadius.word],
57
+ br: f32[w + F.borderBottomRightRadius.word], bl: f32[w + F.borderBottomLeftRadius.word],
58
+ }
59
+ // background layers
60
+ style.backgroundImage = str(F.backgroundImage.word)
61
+ style.backgroundSize = CUI_NAMES.backgroundSize[u32[w + F.backgroundSize.word]] as ResolvedStyle["backgroundSize"]
62
+ const gradientId = u32[w + F.backgroundGradient.word]
63
+ if (gradientId === 0) style.backgroundGradients = undefined
64
+ else {
65
+ if (countPtr === 0) countPtr = M._malloc(4)
66
+ const gp = M._cuiGradientOf(gradientId, countPtr)
67
+ const count = M.HEAPU32[countPtr >>> 2]
68
+ style.backgroundGradients = gp !== 0 && count > 0 ? readGradientStructs(M.HEAPF32, M.HEAPU32, gp, count) : undefined
69
+ }
70
+ // transform: the 2D affine [a,b,c,d,e,f] of the 3x3 (column-major m0 m1 m3 m4 m6 m7); identity = absent
71
+ const t = w + F.transform.word
72
+ const a = f32[t], b = f32[t + 1], c = f32[t + 3], d = f32[t + 4], e = f32[t + 6], f = f32[t + 7]
73
+ style.transform = a === 1 && b === 0 && c === 0 && d === 1 && e === 0 && f === 0 ? undefined : [a, b, c, d, e, f]
74
+ const o = w + F.transformOrigin.word
75
+ style.transformOrigin = { x: f32[o], y: f32[o + 1], xPercent: u32[o + 2] !== 0, yPercent: u32[o + 3] !== 0 }
76
+
77
+ // text (effective; absent = the renderer default — core/text.ts DEFAULTS)
78
+ style.color = has(F.color.bit) ? u32[w + F.color.word] >>> 0 : undefined
79
+ style.fontFamily = has(F.fontFamily.bit) ? str(F.fontFamily.word) : undefined
80
+ style.fontSize = f32[w + F.fontSize.word]
81
+ style.fontWeight = f32[w + F.fontWeight.word]
82
+ style.fontStyle = u32[w + F.fontStyle.word] === CuiFontStyle.italic ? "italic" : "normal"
83
+ style.lineHeight = f32[w + F.lineHeight.word]
84
+ style.letterSpacing = f32[w + F.letterSpacing.word]
85
+ style.textAlign = ALIGN[u32[w + F.textAlign.word]] ?? "start"
86
+ style.textDecoration = CUI_NAMES.textDecoration[u32[w + F.textDecoration.word]] as ResolvedStyle["textDecoration"]
87
+ style.lineClamp = f32[w + F.lineClamp.word]
88
+ style.textOverflow = CUI_NAMES.textOverflow[u32[w + F.textOverflow.word]] as ResolvedStyle["textOverflow"]
89
+
90
+ // image / input / widget (presence props: absent = the renderer default)
91
+ style.objectFit = CUI_NAMES.objectFit[u32[w + F.objectFit.word]] as ResolvedStyle["objectFit"]
92
+ style.tintColor = has(F.tintColor.bit) ? u32[w + F.tintColor.word] >>> 0 : undefined
93
+ style.placeholder = str(F.placeholder.word)
94
+ style.placeholderColor = has(F.placeholderColor.bit) ? u32[w + F.placeholderColor.word] >>> 0 : undefined
95
+ style.inputType = CUI_NAMES.type[u32[w + F.type.word]]
96
+ style.overlayColor = has(F.overlayColor.bit) ? u32[w + F.overlayColor.word] >>> 0 : undefined
97
+ }
98
+
99
+ /** The node's record has changes the frame's sync has not taken yet. */
100
+ export function paintPending(M: PaintModule, nodePtr: number): boolean {
101
+ const p = M._cuiPaintOf(nodePtr)
102
+ if (p === 0) return false
103
+ const w = p >>> 2
104
+ return M.HEAPU32[w + 2] !== 0 || M.HEAPU32[w + 3] !== 0
105
+ }
106
+
107
+ /** Read a live node's current record in place (a measure that runs before this frame's sync). */
108
+ export function readPaintOf(M: PaintModule, nodePtr: number, style: ResolvedStyle): boolean {
109
+ const p = M._cuiPaintOf(nodePtr)
110
+ if (p === 0) return false
111
+ readPaintRecord(M, p, style)
112
+ return true
113
+ }
114
+
115
+ /**
116
+ * Once per frame, before layout: every record that changed since the last call, into its node's
117
+ * style via `apply`. Returns the number of records taken.
118
+ */
119
+ export function syncPaint(M: PaintModule, apply: (nodePtr: number, read: (style: ResolvedStyle) => void) => void): number {
120
+ const n = M._paintCollect()
121
+ if (n === 0) return 0
122
+ const nodes = M._paintNodes() >>> 2
123
+ const records = M._paintRecords()
124
+ for (let i = 0; i < n; i++) {
125
+ const ptr = M.HEAPU32[nodes + i]
126
+ const rec = records + i * CUI_PAINT_WORDS * 4
127
+ apply(ptr, (style) => readPaintRecord(M, rec, style))
128
+ }
129
+ return n
130
+ }
131
+
@@ -0,0 +1,62 @@
1
+ import type { Gradient, GradientStop, RadialExtent } from "../../types"
2
+
3
+ // Decodes the creator-ui CUIGradient struct (creator-ui/include/creator-ui/creator-ui.h) out of the
4
+ // wasm heap. wasm32 layout, all fields 4-byte aligned:
5
+ // CUIGradient { float angle@0; u32 type@4; u32 stopCount@8; u32 _pad@12; CUIGradientStop* stops@16;
6
+ // u32 shape@20; u32 extent@24; float cx@28; float cy@32;
7
+ // float rx@36; float ry@40; u32 rUnitX@44; u32 rUnitY@48 }
8
+ // CUIGradientStop { u32 color@0; float position@4 } (8 bytes, tightly packed)
9
+ // The radial fields are appended AFTER the 4-byte `stops` pointer, so the linear prefix
10
+ // (angle/type/stopCount/stops) is byte-identical to before. `color` is COLOR_FORMAT = 0xRRGGBBAA on
11
+ // web (== our RGBA), so no conversion is needed. extent == 4 means explicit rx/ry (rUnit 0 = px, 1 =
12
+ // box-axis fraction). Pure (takes heap views) so it can be unit-tested with a synthetic buffer.
13
+
14
+ const EXTENTS: RadialExtent[] = ["farthest-corner", "closest-side", "closest-corner", "farthest-side"]
15
+
16
+ // sizeof(CUIGradient) on wasm32 (13 × 4-byte fields), pinned by a static_assert in gradient.h. The
17
+ // multi-layer handler hands us a pointer to `count` of these laid out back-to-back.
18
+ const CUI_GRADIENT_STRIDE = 52
19
+
20
+ /** Decode a `count`-long CUIGradient array (the background-image layer stack, [0] = topmost). */
21
+ export function readGradientStructs(f32: Float32Array, u32: Uint32Array, ptr: number, count: number): Gradient[] {
22
+ const out: Gradient[] = []
23
+ for (let k = 0; k < count; k++) out.push(readGradientStruct(f32, u32, ptr + k * CUI_GRADIENT_STRIDE))
24
+ return out
25
+ }
26
+
27
+ export function readGradientStruct(f32: Float32Array, u32: Uint32Array, ptr: number): Gradient {
28
+ const i = ptr >> 2
29
+ const angle = f32[i]
30
+ const type = u32[i + 1]
31
+ const stopCount = u32[i + 2]
32
+ const stopsPtr = u32[i + 4]
33
+
34
+ const stops: GradientStop[] = []
35
+ for (let s = 0; s < stopCount; s++) {
36
+ const b = (stopsPtr + s * 8) >> 2
37
+ stops.push({ color: u32[b] >>> 0, position: f32[b + 1] })
38
+ }
39
+
40
+ if (type === 1) {
41
+ const extentCode = u32[i + 6]
42
+ const base = {
43
+ type: "radial" as const,
44
+ shape: (u32[i + 5] === 1 ? "circle" : "ellipse") as "circle" | "ellipse",
45
+ cx: f32[i + 7],
46
+ cy: f32[i + 8],
47
+ stops,
48
+ }
49
+ if (extentCode === 4) {
50
+ return {
51
+ ...base,
52
+ extent: "explicit",
53
+ rx: f32[i + 9],
54
+ ry: f32[i + 10],
55
+ rxUnit: u32[i + 11] === 1 ? "fraction" : "px",
56
+ ryUnit: u32[i + 12] === 1 ? "fraction" : "px",
57
+ }
58
+ }
59
+ return { ...base, extent: EXTENTS[extentCode] ?? "farthest-corner" }
60
+ }
61
+ return { type: "linear", angle, stops }
62
+ }
@@ -0,0 +1,258 @@
1
+ import type { BorderRadius, Gradient, ImageInfo, RadialGradient, Rect, RGBA } from "../types"
2
+ import type { FontSpec } from "../host/Host"
3
+ import type { Borders, DrawImageOpts, Painter } from "./Painter"
4
+ import { rgbaToCss } from "../core/util/colors"
5
+ import { clampRadius, computeObjectFit, gradientLine, radialGeometry } from "../core/util/geometry"
6
+
7
+ /** Painter backend over a CanvasRenderingContext2D (browser). */
8
+ type AnyCtx = CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D
9
+
10
+ export class Canvas2DPainter implements Painter {
11
+ private ctx: AnyCtx
12
+ private dpr: number
13
+ // Stack of in-progress opacity groups: each captures the parent ctx + an offscreen layer + alpha.
14
+ private groups: { parent: AnyCtx; layer: HTMLCanvasElement | OffscreenCanvas; alpha: number }[] = []
15
+
16
+ constructor(ctx: CanvasRenderingContext2D, dpr = 1) {
17
+ this.ctx = ctx
18
+ this.dpr = dpr
19
+ }
20
+
21
+ beginGroup(alpha: number) {
22
+ const cur = this.ctx
23
+ const w = (cur.canvas as HTMLCanvasElement | OffscreenCanvas).width
24
+ const h = (cur.canvas as HTMLCanvasElement | OffscreenCanvas).height
25
+ let layer: HTMLCanvasElement | OffscreenCanvas
26
+ if (typeof OffscreenCanvas !== "undefined") {
27
+ layer = new OffscreenCanvas(w, h)
28
+ } else {
29
+ layer = document.createElement("canvas")
30
+ layer.width = w
31
+ layer.height = h
32
+ }
33
+ const lctx = (layer as any).getContext("2d") as AnyCtx
34
+ lctx.setTransform(cur.getTransform()) // paint the subtree in the same coordinate space
35
+ this.groups.push({ parent: cur, layer, alpha })
36
+ this.ctx = lctx
37
+ }
38
+
39
+ endGroup() {
40
+ const g = this.groups.pop()
41
+ if (!g) return
42
+ this.ctx = g.parent
43
+ this.ctx.save()
44
+ this.ctx.setTransform(1, 0, 0, 1, 0, 0) // composite the layer in device pixels
45
+ this.ctx.globalAlpha *= g.alpha
46
+ this.ctx.drawImage(g.layer as CanvasImageSource, 0, 0)
47
+ this.ctx.restore()
48
+ }
49
+
50
+ beginFrame(width: number, height: number) {
51
+ const { ctx, dpr } = this
52
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
53
+ ctx.clearRect(0, 0, width, height)
54
+ ctx.globalAlpha = 1
55
+ }
56
+ endFrame() {}
57
+
58
+ save() {
59
+ this.ctx.save()
60
+ }
61
+ restore() {
62
+ this.ctx.restore()
63
+ }
64
+ setAlpha(multiplier: number) {
65
+ this.ctx.globalAlpha *= multiplier
66
+ }
67
+ translate(x: number, y: number) {
68
+ this.ctx.translate(x, y)
69
+ }
70
+ scale(sx: number, sy: number) {
71
+ this.ctx.scale(sx, sy)
72
+ }
73
+ transform(a: number, b: number, c: number, d: number, e: number, f: number) {
74
+ this.ctx.transform(a, b, c, d, e, f)
75
+ }
76
+
77
+ /** Append a (rounded) rect sub-path to the current path — no beginPath, so callers can build
78
+ * multi-subpath paths (e.g. an even-odd ring). */
79
+ private addRectPath(rect: Rect, radius?: BorderRadius) {
80
+ const ctx = this.ctx
81
+ if (radius && (radius.tl || radius.tr || radius.br || radius.bl)) {
82
+ ctx.roundRect(rect.x, rect.y, rect.width, rect.height, [radius.tl, radius.tr, radius.br, radius.bl])
83
+ } else {
84
+ ctx.rect(rect.x, rect.y, rect.width, rect.height)
85
+ }
86
+ }
87
+
88
+ private path(rect: Rect, radius?: BorderRadius) {
89
+ this.ctx.beginPath()
90
+ this.addRectPath(rect, radius)
91
+ }
92
+
93
+ clipRoundRect(rect: Rect, radius?: BorderRadius) {
94
+ this.path(rect, radius)
95
+ this.ctx.clip()
96
+ }
97
+
98
+ fillRoundRect(rect: Rect, radius: BorderRadius | undefined, color: RGBA) {
99
+ this.path(rect, radius)
100
+ this.ctx.fillStyle = rgbaToCss(color)
101
+ this.ctx.fill()
102
+ }
103
+
104
+ clearRoundRect(rect: Rect, radius: BorderRadius | undefined) {
105
+ const ctx = this.ctx
106
+ ctx.save()
107
+ ctx.globalCompositeOperation = "destination-out"
108
+ ctx.globalAlpha = 1
109
+ this.path(rect, radius)
110
+ ctx.fillStyle = "#000"
111
+ ctx.fill()
112
+ ctx.restore()
113
+ }
114
+
115
+ fillGradient(rect: Rect, radius: BorderRadius | undefined, gradient: Gradient) {
116
+ if (gradient.type === "radial") {
117
+ this.fillRadial(rect, radius, gradient)
118
+ return
119
+ }
120
+ const { x0, y0, x1, y1 } = gradientLine(gradient.angle, rect)
121
+ const grad = this.ctx.createLinearGradient(x0, y0, x1, y1)
122
+ for (const stop of gradient.stops) grad.addColorStop(Math.max(0, Math.min(1, stop.position)), rgbaToCss(stop.color))
123
+ this.path(rect, radius)
124
+ this.ctx.fillStyle = grad
125
+ this.ctx.fill()
126
+ }
127
+
128
+ private fillRadial(rect: Rect, radius: BorderRadius | undefined, gradient: RadialGradient) {
129
+ const { cx, cy, rx, ry } = radialGeometry(gradient, rect)
130
+ const ctx = this.ctx
131
+ // createRadialGradient only makes circles; scale the context anisotropically so a unit-radius
132
+ // circle becomes the rx×ry ellipse. Clip to the (rounded) rect first — the clip is captured in
133
+ // the pre-scale space, so it stays put while we paint the ellipse in the scaled space.
134
+ const sx = rx || 1e-6
135
+ const sy = ry || 1e-6
136
+ ctx.save()
137
+ this.path(rect, radius)
138
+ ctx.clip()
139
+ ctx.translate(cx, cy)
140
+ ctx.scale(sx, sy)
141
+ const grad = ctx.createRadialGradient(0, 0, 0, 0, 0, 1)
142
+ for (const stop of gradient.stops) grad.addColorStop(Math.max(0, Math.min(1, stop.position)), rgbaToCss(stop.color))
143
+ ctx.fillStyle = grad
144
+ // Cover the whole clipped rect in the scaled coordinate space (canvas extends the last stop
145
+ // outward, so pixels past the ellipse edge get the final color, matching CSS).
146
+ const u0 = (rect.x - cx) / sx
147
+ const u1 = (rect.x + rect.width - cx) / sx
148
+ const v0 = (rect.y - cy) / sy
149
+ const v1 = (rect.y + rect.height - cy) / sy
150
+ ctx.fillRect(u0, v0, u1 - u0, v1 - v0)
151
+ ctx.restore()
152
+ }
153
+
154
+ strokeBorder(rect: Rect, radius: BorderRadius | undefined, sides: Borders) {
155
+ const ctx = this.ctx
156
+ const present = (["top", "right", "bottom", "left"] as const).filter((s) => sides[s])
157
+ const uniform =
158
+ present.length === 4 &&
159
+ present.every((s) => sides[s]!.width === sides.top!.width && sides[s]!.color === sides.top!.color)
160
+
161
+ if (uniform) {
162
+ const w = sides.top!.width
163
+ // Border-box: the border lies fully INSIDE rect. Fill the ring between the outer rounded-rect
164
+ // and an inner rounded-rect inset by the border width (inner corner radius = outer − w), as an
165
+ // even-odd fill. We do NOT use a centred ctx.stroke(): a stroke straddles the path (half would
166
+ // sit outside the box), and the inset-path trick relied on roundRect clamping its radius — which
167
+ // @napi-rs/canvas (skia) does differently than browsers, so the border leaked outside the box.
168
+ const outer = clampRadius(radius, rect)
169
+ ctx.beginPath()
170
+ this.addRectPath(rect, outer)
171
+ const inner: Rect = { x: rect.x + w, y: rect.y + w, width: rect.width - 2 * w, height: rect.height - 2 * w }
172
+ if (inner.width > 0 && inner.height > 0) {
173
+ const innerRadius = outer && clampRadius(
174
+ {
175
+ tl: Math.max(0, outer.tl - w), tr: Math.max(0, outer.tr - w),
176
+ br: Math.max(0, outer.br - w), bl: Math.max(0, outer.bl - w),
177
+ },
178
+ inner,
179
+ )
180
+ this.addRectPath(inner, innerRadius || undefined)
181
+ }
182
+ ctx.fillStyle = rgbaToCss(sides.top!.color)
183
+ ctx.fill("evenodd")
184
+ return
185
+ }
186
+
187
+ // Per-side fallback: straight edges (radius ignored for mismatched borders).
188
+ for (const s of present) {
189
+ const { width, color } = sides[s]!
190
+ ctx.fillStyle = rgbaToCss(color)
191
+ if (s === "top") ctx.fillRect(rect.x, rect.y, rect.width, width)
192
+ else if (s === "bottom") ctx.fillRect(rect.x, rect.y + rect.height - width, rect.width, width)
193
+ else if (s === "left") ctx.fillRect(rect.x, rect.y, width, rect.height)
194
+ else ctx.fillRect(rect.x + rect.width - width, rect.y, width, rect.height)
195
+ }
196
+ }
197
+
198
+ private scratch?: OffscreenCanvas | HTMLCanvasElement
199
+ private scratchCtx?: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D
200
+ private getScratch(w: number, h: number) {
201
+ if (!this.scratch) {
202
+ this.scratch = typeof OffscreenCanvas !== "undefined" ? new OffscreenCanvas(w, h) : document.createElement("canvas")
203
+ this.scratchCtx = (this.scratch as any).getContext("2d")
204
+ }
205
+ if (this.scratch.width < w || this.scratch.height < h) {
206
+ this.scratch.width = Math.max(this.scratch.width, w)
207
+ this.scratch.height = Math.max(this.scratch.height, h)
208
+ }
209
+ return this.scratchCtx!
210
+ }
211
+
212
+ drawImage(image: ImageInfo, srcRect: Rect | null, dst: Rect, opts: DrawImageOpts) {
213
+ if (!image.handle) return
214
+ // An explicit crop fills the box; otherwise resolve object-fit.
215
+ const { src, dst: d } = srcRect
216
+ ? { src: srcRect, dst }
217
+ : computeObjectFit(image.naturalWidth, image.naturalHeight, dst, opts.fit)
218
+ // The destination in DEVICE pixels: what a vector source rasterizes at, and the tint scratch's
219
+ // size — the frame is painted under the dpr transform, so a CSS-px scratch would be upscaled.
220
+ const dw = Math.max(1, Math.ceil(d.width * this.dpr))
221
+ const dh = Math.max(1, Math.ceil(d.height * this.dpr))
222
+ const handle = (image.rasterize ? image.rasterize(dw, dh) : image.handle) as CanvasImageSource
223
+
224
+ // When we're drawing the WHOLE image (no atlas crop, not a cover-crop) draw it WITHOUT a source
225
+ // rect. A viewBox-only SVG `<img>` has the browser's default 300×150 raster, so a source crop
226
+ // (computed from the viewBox size) samples the wrong region and the icon vanishes — drawing the
227
+ // whole image straight into the box lets the backend re-rasterize the SVG to fit (matches the DOM,
228
+ // and keeps the working tinted/scratch path consistent with the direct path).
229
+ const whole = !srcRect && opts.fit !== "cover"
230
+
231
+ if (opts.tint === undefined) {
232
+ if (whole) this.ctx.drawImage(handle, d.x, d.y, d.width, d.height)
233
+ else this.ctx.drawImage(handle, src.x, src.y, src.width, src.height, d.x, d.y, d.width, d.height)
234
+ return
235
+ }
236
+ // Tint on an offscreen so `source-in` only recolors the image pixels.
237
+ const sc = this.getScratch(dw, dh)
238
+ sc.clearRect(0, 0, dw, dh)
239
+ sc.globalCompositeOperation = "source-over"
240
+ if (whole) sc.drawImage(handle, 0, 0, dw, dh)
241
+ else sc.drawImage(handle, src.x, src.y, src.width, src.height, 0, 0, dw, dh)
242
+ sc.globalCompositeOperation = "source-in"
243
+ sc.fillStyle = rgbaToCss(opts.tint)
244
+ sc.fillRect(0, 0, dw, dh)
245
+ sc.globalCompositeOperation = "source-over"
246
+ this.ctx.drawImage(sc.canvas as CanvasImageSource, 0, 0, dw, dh, d.x, d.y, d.width, d.height)
247
+ }
248
+
249
+ fillText(text: string, x: number, baselineY: number, font: FontSpec, color: RGBA) {
250
+ const ctx = this.ctx
251
+ ctx.font = `${font.italic ? "italic " : ""}${font.weight} ${font.size}px ${font.family}`
252
+ ctx.textBaseline = "alphabetic"
253
+ ctx.textAlign = "left"
254
+ if ("letterSpacing" in ctx) (ctx as any).letterSpacing = `${font.letterSpacing}px`
255
+ ctx.fillStyle = rgbaToCss(color)
256
+ ctx.fillText(text, x, baselineY)
257
+ }
258
+ }
@@ -0,0 +1,62 @@
1
+ import type { BorderRadius, BorderSide, Gradient, ImageInfo, Rect, RGBA } from "../types"
2
+ import type { FontSpec } from "../host/Host"
3
+
4
+ export type Borders = Partial<Record<"top" | "right" | "bottom" | "left", BorderSide>>
5
+
6
+ export interface DrawImageOpts {
7
+ fit: "cover" | "contain" | "fill"
8
+ tint?: RGBA
9
+ radius?: BorderRadius
10
+ }
11
+
12
+ /**
13
+ * The low-level paint primitive interface. `paintTree` walks the render tree and replays it into a
14
+ * Painter, so every backend (Canvas2D, the test RecordingPainter, the future tgfx painter) shares
15
+ * the SAME paint logic and only the leaf primitives differ. All coordinates are ABSOLUTE px — the
16
+ * render tree is already absolute-positioned, so no transform stack is needed.
17
+ */
18
+ export interface Painter {
19
+ beginFrame(width: number, height: number): void
20
+ endFrame(): void
21
+
22
+ /** Save/restore the clip + alpha + transform state. */
23
+ save(): void
24
+ restore(): void
25
+
26
+ /** Multiply the current alpha (composes through save/restore). */
27
+ setAlpha(multiplier: number): void
28
+
29
+ /** Begin a compositing group: subsequent paint is collected into an offscreen layer and composited
30
+ * once at `alpha` on `endGroup` — true CSS group opacity (a translucent container fades as a unit,
31
+ * not per-primitive). Pair with `endGroup`. */
32
+ beginGroup(alpha: number): void
33
+ endGroup(): void
34
+
35
+ /** Translate the coordinate system (composes through save/restore). */
36
+ translate(x: number, y: number): void
37
+ /** Scale the coordinate system (composes through save/restore). */
38
+ scale(sx: number, sy: number): void
39
+ /** Multiply in a 2D affine matrix [a,b,c,d,e,f] (composes through save/restore). */
40
+ transform(a: number, b: number, c: number, d: number, e: number, f: number): void
41
+
42
+ /** Intersect the clip with a (possibly rounded) rectangle. */
43
+ clipRoundRect(rect: Rect, radius?: BorderRadius): void
44
+
45
+ fillRoundRect(rect: Rect, radius: BorderRadius | undefined, color: RGBA): void
46
+
47
+ /** Take away what is painted in a (possibly rounded) rect, down to transparent: the window a
48
+ * surface UNDER the painted picture is seen through (a native view, a video). What is painted
49
+ * after it goes over the surface. */
50
+ clearRoundRect(rect: Rect, radius: BorderRadius | undefined): void
51
+
52
+ /** Fill a (possibly rounded) rect with a linear or radial gradient. */
53
+ fillGradient(rect: Rect, radius: BorderRadius | undefined, gradient: Gradient): void
54
+
55
+ /** Per-side borders, drawn inside `rect`, honoring `radius`. */
56
+ strokeBorder(rect: Rect, radius: BorderRadius | undefined, sides: Borders): void
57
+
58
+ drawImage(image: ImageInfo, src: Rect | null, dst: Rect, opts: DrawImageOpts): void
59
+
60
+ /** A single, already-wrapped line of text drawn at its baseline. */
61
+ fillText(text: string, x: number, baselineY: number, font: FontSpec, color: RGBA): void
62
+ }