lecodes-web-shared 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.
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "lecodes-web-shared",
3
+ "version": "2.0.0",
4
+ "description": "What every web renderer and host of LeCodes shares: the fetch-buffer and media-player registries, the presentation model (router, presentPlayer, native views, touch), svg/video leaves, fonts, source-map errors, the canvas host",
5
+ "type": "module",
6
+ "main": "src/sourcemapError.ts",
7
+ "dependencies": {},
8
+ "devDependencies": {
9
+ "typescript": "~5.8.3",
10
+ "vite": "^6.3.5"
11
+ },
12
+ "scripts": {
13
+ "test": "bun test",
14
+ "typecheck": "tsc --noEmit -p tsconfig.json"
15
+ },
16
+ "files": [
17
+ "src"
18
+ ],
19
+ "publishConfig": {
20
+ "access": "public"
21
+ }
22
+ }
@@ -0,0 +1,93 @@
1
+ Copyright 2011 The Roboto Project Authors (https://github.com/googlefonts/roboto-classic)
2
+
3
+ This Font Software is licensed under the SIL Open Font License, Version 1.1.
4
+ This license is copied below, and is also available with a FAQ at:
5
+ https://openfontlicense.org
6
+
7
+
8
+ -----------------------------------------------------------
9
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10
+ -----------------------------------------------------------
11
+
12
+ PREAMBLE
13
+ The goals of the Open Font License (OFL) are to stimulate worldwide
14
+ development of collaborative font projects, to support the font creation
15
+ efforts of academic and linguistic communities, and to provide a free and
16
+ open framework in which fonts may be shared and improved in partnership
17
+ with others.
18
+
19
+ The OFL allows the licensed fonts to be used, studied, modified and
20
+ redistributed freely as long as they are not sold by themselves. The
21
+ fonts, including any derivative works, can be bundled, embedded,
22
+ redistributed and/or sold with any software provided that any reserved
23
+ names are not used by derivative works. The fonts and derivatives,
24
+ however, cannot be released under any other type of license. The
25
+ requirement for fonts to remain under this license does not apply
26
+ to any document created using the fonts or their derivatives.
27
+
28
+ DEFINITIONS
29
+ "Font Software" refers to the set of files released by the Copyright
30
+ Holder(s) under this license and clearly marked as such. This may
31
+ include source files, build scripts and documentation.
32
+
33
+ "Reserved Font Name" refers to any names specified as such after the
34
+ copyright statement(s).
35
+
36
+ "Original Version" refers to the collection of Font Software components as
37
+ distributed by the Copyright Holder(s).
38
+
39
+ "Modified Version" refers to any derivative made by adding to, deleting,
40
+ or substituting -- in part or in whole -- any of the components of the
41
+ Original Version, by changing formats or by porting the Font Software to a
42
+ new environment.
43
+
44
+ "Author" refers to any designer, engineer, programmer, technical
45
+ writer or other person who contributed to the Font Software.
46
+
47
+ PERMISSION & CONDITIONS
48
+ Permission is hereby granted, free of charge, to any person obtaining
49
+ a copy of the Font Software, to use, study, copy, merge, embed, modify,
50
+ redistribute, and sell modified and unmodified copies of the Font
51
+ Software, subject to the following conditions:
52
+
53
+ 1) Neither the Font Software nor any of its individual components,
54
+ in Original or Modified Versions, may be sold by itself.
55
+
56
+ 2) Original or Modified Versions of the Font Software may be bundled,
57
+ redistributed and/or sold with any software, provided that each copy
58
+ contains the above copyright notice and this license. These can be
59
+ included either as stand-alone text files, human-readable headers or
60
+ in the appropriate machine-readable metadata fields within text or
61
+ binary files as long as those fields can be easily viewed by the user.
62
+
63
+ 3) No Modified Version of the Font Software may use the Reserved Font
64
+ Name(s) unless explicit written permission is granted by the corresponding
65
+ Copyright Holder. This restriction only applies to the primary font name as
66
+ presented to the users.
67
+
68
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69
+ Software shall not be used to promote, endorse or advertise any
70
+ Modified Version, except to acknowledge the contribution(s) of the
71
+ Copyright Holder(s) and the Author(s) or with their explicit written
72
+ permission.
73
+
74
+ 5) The Font Software, modified or unmodified, in part or in whole,
75
+ must be distributed entirely under this license, and must not be
76
+ distributed under any other license. The requirement for fonts to
77
+ remain under this license does not apply to any document created
78
+ using the Font Software.
79
+
80
+ TERMINATION
81
+ This license becomes null and void if any of the above conditions are
82
+ not met.
83
+
84
+ DISCLAIMER
85
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93
+ OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file
@@ -0,0 +1,103 @@
1
+ // The web PAINTER host for `_creatorCanvas`: the HostCanvas table of the wasm runtime (host.gen.ts
2
+ // `WasmHostCanvas`) over OffscreenCanvas. The runtime's AnyCanvas core interprets the SDK's opcode
3
+ // stream and SVG into a draw list; this host keeps one OffscreenCanvas per surface id and replays the
4
+ // list with the vendored AnyCanvas web painter (./gen, `bun run vendor:canvas`). The browser gives text
5
+ // shaping, emoji, font fallback and image decoding for free.
6
+ //
7
+ // `readSurface` / `getSurface` / `surfaceToDataUrl` are how the baked pixels reach the engines (a GPU
8
+ // texture), the canvas UI renderer (`UIImage(canvas)` draws the live OffscreenCanvas) and the DOM
9
+ // renderer (a data URL).
10
+
11
+ import { readDrawList } from "./gen/drawlist"
12
+ import { paint, cssFont } from "./gen/painter"
13
+
14
+ export interface CanvasHost {
15
+ /** The HostCanvas table the wasm runtime calls (a web host's canvas table). */
16
+ table: {
17
+ paint(surfaceId: number, pw: number, ph: number, scale: number, words: Float32Array, strings: string[]): void
18
+ measureText(text: string, family: string, size: number, weight: number, italic: boolean, out: Float32Array): void
19
+ decodeImage(bufferId: number, surfaceId: number, size: Int32Array): boolean
20
+ readPixels(surfaceId: number, size: Int32Array): Uint8Array | null
21
+ releaseSurface(surfaceId: number): void
22
+ }
23
+ /** Straight (non-premultiplied) RGBA8 pixels of a baked surface, for engine texture upload. */
24
+ readSurface(surfaceId: number): { data: Uint8ClampedArray, width: number, height: number } | null
25
+ /** The baked surface as a drawable canvas (device px) — a `CanvasSurfaceProvider` for canvas-ui's
26
+ * `UIImage(canvas)` (lecodes-headless BrowserHost). */
27
+ getSurface(surfaceId: number): { canvas: OffscreenCanvas, width: number, height: number } | null
28
+ /** The baked surface as a data-uri, for the DOM renderer's `<img>` (`UIImage(canvas)`). */
29
+ surfaceToDataUrl(surfaceId: number): string | null
30
+ }
31
+
32
+ /** `fontFamily` maps a draw-list family to what the page can render (a registered font's real name);
33
+ * the default keeps the family as is. Image decoding is asynchronous in the browser while the table is
34
+ * synchronous, so `decodeImage` answers false: `Canvas.loadImage(fetchResponse)` rejects on the web
35
+ * (SVG goes through the core and works). */
36
+ export const createCanvasHost = (fontFamily?: (family: string) => string): CanvasHost => {
37
+ const supported = typeof OffscreenCanvas !== "undefined"
38
+ const surfaces = new Map<number, OffscreenCanvas>()
39
+ let measureCtx: OffscreenCanvasRenderingContext2D | null = null
40
+
41
+ const ensure = (surfaceId: number, pw: number, ph: number): OffscreenCanvas | null => {
42
+ if (!supported) return null
43
+ let cv = surfaces.get(surfaceId)
44
+ if (!cv) { cv = new OffscreenCanvas(pw, ph); surfaces.set(surfaceId, cv) }
45
+ else if (cv.width !== pw || cv.height !== ph) { cv.width = pw; cv.height = ph }
46
+ return cv
47
+ }
48
+
49
+ const table: CanvasHost["table"] = {
50
+ paint: (surfaceId, pw, ph, _scale, words, strings) => {
51
+ const cv = ensure(surfaceId, Math.max(1, pw), Math.max(1, ph))
52
+ const ctx = cv?.getContext("2d")
53
+ if (!cv || !ctx) return
54
+ ctx.setTransform(1, 0, 0, 1, 0, 0)
55
+ ctx.clearRect(0, 0, cv.width, cv.height)
56
+ // `words` is a view into wasm memory, valid for this call only: decode before returning.
57
+ paint(ctx, readDrawList(words, strings), { image: id => surfaces.get(id) ?? null, fontFamily })
58
+ },
59
+ measureText: (text, family, size, weight, italic, out) => {
60
+ out[0] = 0; out[1] = 0; out[2] = 0
61
+ if (!supported) return
62
+ if (!measureCtx) measureCtx = new OffscreenCanvas(1, 1).getContext("2d")
63
+ if (!measureCtx) return
64
+ measureCtx.font = cssFont({ family, size, weight, italic }, fontFamily ? fontFamily(family) : family)
65
+ const m = measureCtx.measureText(text)
66
+ out[0] = m.width
67
+ out[1] = m.fontBoundingBoxAscent ?? m.actualBoundingBoxAscent
68
+ out[2] = m.fontBoundingBoxDescent ?? m.actualBoundingBoxDescent
69
+ },
70
+ decodeImage: () => false,
71
+ readPixels: (surfaceId, size) => {
72
+ const s = readSurface(surfaceId)
73
+ if (!s) return null
74
+ size[0] = s.width; size[1] = s.height
75
+ return new Uint8Array(s.data.buffer, s.data.byteOffset, s.data.byteLength)
76
+ },
77
+ releaseSurface: (surfaceId) => { surfaces.delete(surfaceId) },
78
+ }
79
+
80
+ const readSurface = (surfaceId: number) => {
81
+ const cv = surfaces.get(surfaceId)
82
+ const ctx = cv?.getContext("2d")
83
+ if (!cv || !ctx) return null
84
+ const img = ctx.getImageData(0, 0, cv.width, cv.height) // straight RGBA8 per the Canvas2D spec
85
+ return { data: img.data, width: cv.width, height: cv.height }
86
+ }
87
+
88
+ const getSurface = (surfaceId: number) => {
89
+ const cv = surfaces.get(surfaceId)
90
+ return cv ? { canvas: cv, width: cv.width, height: cv.height } : null
91
+ }
92
+
93
+ const surfaceToDataUrl = (surfaceId: number): string | null => {
94
+ const cv = surfaces.get(surfaceId)
95
+ if (!cv || typeof document === "undefined") return null
96
+ const el = document.createElement("canvas")
97
+ el.width = cv.width; el.height = cv.height
98
+ el.getContext("2d")?.drawImage(cv, 0, 0)
99
+ return el.toDataURL()
100
+ }
101
+
102
+ return { table, readSurface, getSurface, surfaceToDataUrl }
103
+ }
@@ -0,0 +1,138 @@
1
+ // GENERATED by scripts/vendor-canvas.ts from engines/canvas/painters/web/src/drawlist.ts @ ae6bc30b952b — do not edit here.
2
+ // Bump the submodule and run `bun run vendor:canvas`; `bun run check:canvas-ops` fails when this copy is stale.
3
+ // The draw list in TypeScript: the typed commands, the reader of the word buffer (spec/draw.h) and
4
+ // the reader of the core's JSON dump. Both produce the same DrawCommand objects — the tests hold the
5
+ // two readers against each other on every golden.
6
+ import {
7
+ DRAW, Paint as PaintKind, PathVerb, LineJoinName, LineCapName, TextAlignName, TextBaselineName, FillRuleName, SpreadName,
8
+ } from "./spec"
9
+
10
+ export type Color = [number, number, number, number]
11
+ export type Matrix6 = [number, number, number, number, number, number]
12
+ export interface Stop { offset: number, color: Color }
13
+ export interface ColorPaint { kind: "color", alpha: number, color: Color }
14
+ export interface LinearPaint { kind: "linear", alpha: number, matrix: Matrix6, spread: string, stops: Stop[] }
15
+ export interface RadialPaint { kind: "radial", alpha: number, matrix: Matrix6, fx: number, fy: number, r0: number, spread: string, stops: Stop[] }
16
+ export type Paint = ColorPaint | LinearPaint | RadialPaint
17
+ export interface Stroke { width: number, join: string, cap: string, miterLimit: number, dashOffset: number, dash: number[] }
18
+ export interface Font { family: string, size: number, weight: number, italic: boolean }
19
+ export type PathSeg = ["M", number, number] | ["L", number, number] | ["Q", number, number, number, number] | ["C", number, number, number, number, number, number] | ["Z"]
20
+
21
+ export type DrawCommand =
22
+ | { cmd: "setTransform", matrix: Matrix6 }
23
+ | { cmd: "save" }
24
+ | { cmd: "restore" }
25
+ | { cmd: "clip", rule: string, path: PathSeg[] }
26
+ | { cmd: "fillPath", rule: string, paint: Paint, path: PathSeg[] }
27
+ | { cmd: "strokePath", stroke: Stroke, paint: Paint, path: PathSeg[] }
28
+ | { cmd: "fillText", text: string, x: number, y: number, maxWidth: number, font: Font, align: string, baseline: string, letterSpacing: number, paint: Paint }
29
+ | { cmd: "strokeText", text: string, x: number, y: number, maxWidth: number, font: Font, align: string, baseline: string, letterSpacing: number, stroke: Stroke, paint: Paint }
30
+ | { cmd: "drawImage", surface: number, src: [number, number, number, number], dst: [number, number, number, number], alpha: number }
31
+ | { cmd: "clearRect", rect: [number, number, number, number] }
32
+
33
+ class Reader {
34
+ pos = 0
35
+ constructor(readonly w: Float32Array, readonly strings: readonly string[]) {}
36
+ f(): number { if (this.pos >= this.w.length) throw new Error("draw list: truncated"); return this.w[this.pos++] }
37
+ i(): number { return this.f() | 0 }
38
+ str(): string { const i = this.i(); if (i < 0 || i >= this.strings.length) throw new Error(`draw list: bad string index ${i}`); return this.strings[i] }
39
+ color(): Color { return [this.f(), this.f(), this.f(), this.f()] }
40
+ matrix(): Matrix6 { return [this.f(), this.f(), this.f(), this.f(), this.f(), this.f()] }
41
+ paint(): Paint {
42
+ const kind = this.i(), alpha = this.f()
43
+ if (kind === PaintKind.COLOR) return { kind: "color", alpha, color: this.color() }
44
+ const matrix = this.matrix()
45
+ let fx = 0, fy = 0, r0 = 0
46
+ if (kind === PaintKind.RADIAL) { fx = this.f(); fy = this.f(); r0 = this.f() }
47
+ const spread = SpreadName[this.i()] ?? "pad"
48
+ const n = this.i()
49
+ const stops: Stop[] = []
50
+ for (let k = 0; k < n; k++) stops.push({ offset: this.f(), color: this.color() })
51
+ return kind === PaintKind.RADIAL ? { kind: "radial", alpha, matrix, fx, fy, r0, spread, stops } : { kind: "linear", alpha, matrix, spread, stops }
52
+ }
53
+ stroke(): Stroke {
54
+ const width = this.f(), join = LineJoinName[this.i()] ?? "miter", cap = LineCapName[this.i()] ?? "butt"
55
+ const miterLimit = this.f(), dashOffset = this.f(), n = this.i()
56
+ const dash: number[] = []
57
+ for (let k = 0; k < n; k++) dash.push(this.f())
58
+ return { width, join, cap, miterLimit, dashOffset, dash }
59
+ }
60
+ font(): Font { return { family: this.str(), size: this.f(), weight: this.i(), italic: this.i() !== 0 } }
61
+ path(): PathSeg[] {
62
+ const n = this.i()
63
+ const end = this.pos + n
64
+ if (end > this.w.length) throw new Error("draw list: truncated path")
65
+ const segs: PathSeg[] = []
66
+ while (this.pos < end) {
67
+ switch (this.i()) {
68
+ case PathVerb.MOVE: segs.push(["M", this.f(), this.f()]); break
69
+ case PathVerb.LINE: segs.push(["L", this.f(), this.f()]); break
70
+ case PathVerb.QUAD: segs.push(["Q", this.f(), this.f(), this.f(), this.f()]); break
71
+ case PathVerb.CUBIC: segs.push(["C", this.f(), this.f(), this.f(), this.f(), this.f(), this.f()]); break
72
+ case PathVerb.CLOSE: segs.push(["Z"]); break
73
+ default: throw new Error("draw list: bad path verb")
74
+ }
75
+ }
76
+ return segs
77
+ }
78
+ rect4(): [number, number, number, number] { return [this.f(), this.f(), this.f(), this.f()] }
79
+ }
80
+
81
+ /** Decode a word buffer + string table into commands. A command this build does not know is skipped. */
82
+ export const readDrawList = (words: Float32Array, strings: readonly string[]): DrawCommand[] => {
83
+ const r = new Reader(words, strings)
84
+ const out: DrawCommand[] = []
85
+ while (r.pos + 2 <= words.length) {
86
+ const cmd = r.i(), len = r.i()
87
+ const end = r.pos + len
88
+ if (len < 0 || end > words.length) throw new Error("draw list: truncated command")
89
+ switch (cmd) {
90
+ case DRAW.SET_TRANSFORM: out.push({ cmd: "setTransform", matrix: r.matrix() }); break
91
+ case DRAW.SAVE: out.push({ cmd: "save" }); break
92
+ case DRAW.RESTORE: out.push({ cmd: "restore" }); break
93
+ case DRAW.CLIP: { const rule = FillRuleName[r.i()] ?? "nonzero"; out.push({ cmd: "clip", rule, path: r.path() }); break }
94
+ case DRAW.FILL_PATH: { const rule = FillRuleName[r.i()] ?? "nonzero"; const paint = r.paint(); out.push({ cmd: "fillPath", rule, paint, path: r.path() }); break }
95
+ case DRAW.STROKE_PATH: { const stroke = r.stroke(); const paint = r.paint(); out.push({ cmd: "strokePath", stroke, paint, path: r.path() }); break }
96
+ case DRAW.FILL_TEXT: case DRAW.STROKE_TEXT: {
97
+ const text = r.str(), x = r.f(), y = r.f(), maxWidth = r.f(), font = r.font()
98
+ const align = TextAlignName[r.i()] ?? "start", baseline = TextBaselineName[r.i()] ?? "alphabetic", letterSpacing = r.f()
99
+ if (cmd === DRAW.STROKE_TEXT) { const stroke = r.stroke(); out.push({ cmd: "strokeText", text, x, y, maxWidth, font, align, baseline, letterSpacing, stroke, paint: r.paint() }) }
100
+ else out.push({ cmd: "fillText", text, x, y, maxWidth, font, align, baseline, letterSpacing, paint: r.paint() })
101
+ break
102
+ }
103
+ case DRAW.DRAW_IMAGE: { const surface = r.i(); const src = r.rect4(); const dst = r.rect4(); out.push({ cmd: "drawImage", surface, src, dst, alpha: r.f() }); break }
104
+ case DRAW.CLEAR_RECT: out.push({ cmd: "clearRect", rect: r.rect4() }); break
105
+ default: break // unknown: skipped by len
106
+ }
107
+ if (r.pos !== end) {
108
+ if (r.pos > end) throw new Error(`draw list: command ${cmd} overran its length`)
109
+ r.pos = end
110
+ }
111
+ }
112
+ return out
113
+ }
114
+
115
+ /** The core's JSON dump (ac_drawlist_json / acdump) → commands. */
116
+ export const parseDrawListJson = (text: string): DrawCommand[] => {
117
+ const v = JSON.parse(text)
118
+ if (!Array.isArray(v)) throw new Error("draw list JSON: expected an array")
119
+ for (const c of v) if (typeof c !== "object" || typeof c.cmd !== "string") throw new Error("draw list JSON: bad command")
120
+ return v as DrawCommand[]
121
+ }
122
+
123
+ /** The binary file acdump / the golden test write: u32 wordCount, f32 words, u32 stringCount, (u32 len, utf8)*. */
124
+ export const readDrawListBinary = (bytes: Uint8Array): { words: Float32Array, strings: string[] } => {
125
+ const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
126
+ let pos = 0
127
+ const wordCount = dv.getUint32(pos, true); pos += 4
128
+ const words = new Float32Array(wordCount)
129
+ for (let i = 0; i < wordCount; i++) { words[i] = dv.getFloat32(pos, true); pos += 4 }
130
+ const stringCount = dv.getUint32(pos, true); pos += 4
131
+ const strings: string[] = []
132
+ const dec = new TextDecoder()
133
+ for (let i = 0; i < stringCount; i++) {
134
+ const len = dv.getUint32(pos, true); pos += 4
135
+ strings.push(dec.decode(bytes.subarray(pos, pos + len))); pos += len
136
+ }
137
+ return { words, strings }
138
+ }
@@ -0,0 +1,176 @@
1
+ // GENERATED by scripts/vendor-canvas.ts from engines/canvas/painters/web/src/painter.ts @ ae6bc30b952b — do not edit here.
2
+ // Bump the submodule and run `bun run vendor:canvas`; `bun run check:canvas-ops` fails when this copy is stale.
3
+ // The web painter: replays a draw list on anything shaped like a CanvasRenderingContext2D — the
4
+ // browser, an OffscreenCanvas, node-canvas / @napi-rs/canvas. A loop with one case per command, no
5
+ // state of its own: the list carries absolute transforms and resolved paints.
6
+ //
7
+ // Platform limits, stated: Canvas2D gradients only PAD (reflect / repeat draw as pad); a gradient
8
+ // under a non-similarity transform (skew, non-uniform scale — SVG objectBoundingBox on a non-square
9
+ // shape) is drawn by transforming the context, which also distorts a STROKE's pen; letterSpacing
10
+ // applies where the context supports it (Chrome 99+, Safari 17+; not node-canvas).
11
+ import type { DrawCommand, Paint, Stroke, Font, PathSeg, Matrix6, Color } from "./drawlist"
12
+
13
+ /** The subset of CanvasRenderingContext2D the painter uses. */
14
+ export interface Canvas2DLike {
15
+ save(): void
16
+ restore(): void
17
+ setTransform(a: number, b: number, c: number, d: number, e: number, f: number): void
18
+ transform(a: number, b: number, c: number, d: number, e: number, f: number): void
19
+ beginPath(): void
20
+ moveTo(x: number, y: number): void
21
+ lineTo(x: number, y: number): void
22
+ quadraticCurveTo(cx: number, cy: number, x: number, y: number): void
23
+ bezierCurveTo(c1x: number, c1y: number, c2x: number, c2y: number, x: number, y: number): void
24
+ closePath(): void
25
+ fill(rule?: "nonzero" | "evenodd"): void
26
+ stroke(): void
27
+ clip(rule?: "nonzero" | "evenodd"): void
28
+ fillStyle: any
29
+ strokeStyle: any
30
+ lineWidth: number
31
+ lineJoin: any
32
+ lineCap: any
33
+ miterLimit: number
34
+ setLineDash(segments: number[]): void
35
+ lineDashOffset: number
36
+ globalAlpha: number
37
+ font: string
38
+ textAlign: any
39
+ textBaseline: any
40
+ letterSpacing?: string
41
+ fillText(text: string, x: number, y: number, maxWidth?: number): void
42
+ strokeText(text: string, x: number, y: number, maxWidth?: number): void
43
+ drawImage(image: any, sx: number, sy: number, sw: number, sh: number, dx: number, dy: number, dw: number, dh: number): void
44
+ clearRect(x: number, y: number, w: number, h: number): void
45
+ createLinearGradient(x0: number, y0: number, x1: number, y1: number): any
46
+ createRadialGradient(x0: number, y0: number, r0: number, x1: number, y1: number, r1: number): any
47
+ }
48
+
49
+ export interface PainterHooks {
50
+ /** The drawable for a surface id (a canvas, an ImageBitmap, an Image); null skips the blit. */
51
+ image?(surface: number): any
52
+ /** Map a font family to what this context can render (a registered font's real name, a fallback). */
53
+ fontFamily?(family: string): string
54
+ }
55
+
56
+ const GENERIC_FAMILIES = new Set(["serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui", "ui-serif", "ui-sans-serif", "ui-monospace", "ui-rounded", "emoji", "math", "fangsong"])
57
+
58
+ /** A draw-list color as CSS. */
59
+ export const cssColor = (c: Color): string => `rgba(${Math.round(c[0] * 255)}, ${Math.round(c[1] * 255)}, ${Math.round(c[2] * 255)}, ${c[3]})`
60
+
61
+ /** A draw-list font as a CSS font shorthand. */
62
+ export const cssFont = (f: Font, family = f.family): string => {
63
+ const fam = GENERIC_FAMILIES.has(family) ? family : `"${family.replace(/"/g, "'")}"`
64
+ return `${f.italic ? "italic " : ""}${f.weight} ${f.size}px ${fam}`
65
+ }
66
+
67
+ const apply = (m: Matrix6, x: number, y: number): [number, number] => [m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]]
68
+
69
+ // A similarity transform (uniform scale + rotation + translation) keeps circles circles, so the
70
+ // gradient can be expressed in user space without transforming the context.
71
+ const isSimilarity = (m: Matrix6): boolean => Math.abs(m[0] - m[3]) < 1e-6 && Math.abs(m[1] + m[2]) < 1e-6
72
+
73
+ const buildPath = (ctx: Canvas2DLike, path: PathSeg[]): void => {
74
+ ctx.beginPath()
75
+ for (const s of path) {
76
+ switch (s[0]) {
77
+ case "M": ctx.moveTo(s[1], s[2]); break
78
+ case "L": ctx.lineTo(s[1], s[2]); break
79
+ case "Q": ctx.quadraticCurveTo(s[1], s[2], s[3], s[4]); break
80
+ case "C": ctx.bezierCurveTo(s[1], s[2], s[3], s[4], s[5], s[6]); break
81
+ case "Z": ctx.closePath(); break
82
+ }
83
+ }
84
+ }
85
+
86
+ const gradientStops = (g: any, paint: Paint & { stops: { offset: number, color: Color }[] }): void => {
87
+ for (const s of paint.stops) g.addColorStop(s.offset, cssColor(s.color))
88
+ }
89
+
90
+ // Sets fillStyle / strokeStyle + globalAlpha, then runs `draw`. A gradient with a general matrix is
91
+ // drawn inside a context transform (the path, already built, is not affected — Canvas2D keeps paths
92
+ // in device space).
93
+ const withPaint = (ctx: Canvas2DLike, paint: Paint, which: "fill" | "stroke", draw: () => void): void => {
94
+ ctx.globalAlpha = paint.alpha
95
+ if (paint.kind === "color") {
96
+ ctx[which === "fill" ? "fillStyle" : "strokeStyle"] = cssColor(paint.color)
97
+ draw()
98
+ return
99
+ }
100
+ const m = paint.matrix
101
+ if (isSimilarity(m)) {
102
+ let g: any
103
+ if (paint.kind === "linear") {
104
+ const [x0, y0] = apply(m, 0, 0), [x1, y1] = apply(m, 1, 0)
105
+ g = ctx.createLinearGradient(x0, y0, x1, y1)
106
+ } else {
107
+ const s = Math.hypot(m[0], m[1])
108
+ const [cx, cy] = apply(m, 0, 0), [fx, fy] = apply(m, paint.fx, paint.fy)
109
+ g = ctx.createRadialGradient(fx, fy, paint.r0 * s, cx, cy, s)
110
+ }
111
+ gradientStops(g, paint)
112
+ ctx[which === "fill" ? "fillStyle" : "strokeStyle"] = g
113
+ draw()
114
+ return
115
+ }
116
+ ctx.save()
117
+ ctx.transform(m[0], m[1], m[2], m[3], m[4], m[5])
118
+ const g = paint.kind === "linear" ? ctx.createLinearGradient(0, 0, 1, 0) : ctx.createRadialGradient(paint.fx, paint.fy, paint.r0, 0, 0, 1)
119
+ gradientStops(g, paint)
120
+ ctx[which === "fill" ? "fillStyle" : "strokeStyle"] = g
121
+ draw()
122
+ ctx.restore()
123
+ }
124
+
125
+ const applyStroke = (ctx: Canvas2DLike, s: Stroke): void => {
126
+ ctx.lineWidth = s.width
127
+ ctx.lineJoin = s.join
128
+ ctx.lineCap = s.cap
129
+ ctx.miterLimit = s.miterLimit
130
+ ctx.setLineDash(s.dash)
131
+ ctx.lineDashOffset = s.dashOffset
132
+ }
133
+
134
+ const applyText = (ctx: Canvas2DLike, c: { font: Font, align: string, baseline: string, letterSpacing: number }, hooks: PainterHooks): void => {
135
+ ctx.font = cssFont(c.font, hooks.fontFamily ? hooks.fontFamily(c.font.family) : c.font.family)
136
+ ctx.textAlign = c.align
137
+ ctx.textBaseline = c.baseline
138
+ if ("letterSpacing" in ctx) ctx.letterSpacing = `${c.letterSpacing}px`
139
+ }
140
+
141
+ /** Replay `commands` on `ctx`. The context should start with the identity transform and an empty
142
+ * save stack (the painter sets the identity itself); clearing the surface is the caller's decision.
143
+ * The replay runs inside one save, so no clip, transform or style it sets outlives it (Canvas2D
144
+ * keeps a clip set outside any save, and the next paint's clearRect would clear only that clip). */
145
+ export const paint = (ctx: Canvas2DLike, commands: DrawCommand[], hooks: PainterHooks = {}): void => {
146
+ ctx.save()
147
+ let saves = 0 // a list restore never pops the wrapper save
148
+ try {
149
+ ctx.setTransform(1, 0, 0, 1, 0, 0)
150
+ ctx.globalAlpha = 1
151
+ for (const c of commands) {
152
+ switch (c.cmd) {
153
+ case "setTransform": ctx.setTransform(c.matrix[0], c.matrix[1], c.matrix[2], c.matrix[3], c.matrix[4], c.matrix[5]); break
154
+ case "save": ctx.save(); saves++; break
155
+ case "restore": if (saves > 0) { ctx.restore(); saves-- } break
156
+ case "clip": buildPath(ctx, c.path); ctx.clip(c.rule as "nonzero" | "evenodd"); break
157
+ case "fillPath": buildPath(ctx, c.path); withPaint(ctx, c.paint, "fill", () => ctx.fill(c.rule as "nonzero" | "evenodd")); break
158
+ case "strokePath": buildPath(ctx, c.path); applyStroke(ctx, c.stroke); withPaint(ctx, c.paint, "stroke", () => ctx.stroke()); break
159
+ case "fillText": applyText(ctx, c, hooks); withPaint(ctx, c.paint, "fill", () => c.maxWidth > 0 ? ctx.fillText(c.text, c.x, c.y, c.maxWidth) : ctx.fillText(c.text, c.x, c.y)); break
160
+ case "strokeText": applyText(ctx, c, hooks); applyStroke(ctx, c.stroke); withPaint(ctx, c.paint, "stroke", () => c.maxWidth > 0 ? ctx.strokeText(c.text, c.x, c.y, c.maxWidth) : ctx.strokeText(c.text, c.x, c.y)); break
161
+ case "drawImage": {
162
+ const img = hooks.image?.(c.surface)
163
+ if (!img) break
164
+ ctx.globalAlpha = c.alpha
165
+ ctx.drawImage(img, c.src[0], c.src[1], c.src[2], c.src[3], c.dst[0], c.dst[1], c.dst[2], c.dst[3])
166
+ break
167
+ }
168
+ case "clearRect": ctx.clearRect(c.rect[0], c.rect[1], c.rect[2], c.rect[3]); break
169
+ default: { const never: never = c; throw new Error(`unhandled draw command ${(never as DrawCommand).cmd}`) }
170
+ }
171
+ }
172
+ } finally {
173
+ for (; saves > 0; saves--) ctx.restore()
174
+ ctx.restore()
175
+ }
176
+ }