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 +22 -0
- package/src/assets/Roboto-OFL.txt +93 -0
- package/src/assets/Roboto-Variable.woff2 +0 -0
- package/src/canvas/createCanvasHost.ts +103 -0
- package/src/canvas/gen/drawlist.ts +138 -0
- package/src/canvas/gen/painter.ts +176 -0
- package/src/canvas/gen/spec.ts +201 -0
- package/src/sourcemapError.ts +178 -0
- package/src/ui/nativeView.ts +12 -0
- package/src/ui/safeAreaSpec.ts +30 -0
- package/src/ui/viewScale.ts +19 -0
- package/src/utils/fetch.ts +162 -0
- package/src/utils/fonts.ts +88 -0
- package/src/utils/mediaplayer.ts +128 -0
- package/src/utils/svg.ts +61 -0
- package/src/vite-env.d.ts +1 -0
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
|
+
}
|