@koiosdigital/matrx-render 0.1.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,22 @@
1
+ import { a as Canvas, W as Widget, B as Bounds } from '../widget-DABrpucj.js';
2
+ export { e as bounds } from '../widget-DABrpucj.js';
3
+
4
+ /**
5
+ * Test utilities — port of pixlet render/testutil.go. Expected images are
6
+ * ASCII art with a color palette; comparison is byte-exact against the
7
+ * premultiplied canvas, just like the Go tests compare against image.RGBA.
8
+ */
9
+
10
+ type Palette = Record<string, [number, number, number, number]>;
11
+ declare const DEFAULT_PALETTE: Palette;
12
+ declare function printImage(im: Canvas, palette?: Palette): string;
13
+ /**
14
+ * Assert the canvas matches the expected ASCII art. Throws with an
15
+ * EXPECTED/ACTUAL dump on mismatch (Go's checkImage returns an error).
16
+ */
17
+ declare function checkImage(expected: string[], actual: Canvas, palette?: Palette): void;
18
+ /** Port of testutil.PaintWidget: canvas sized by the widget's paintBounds,
19
+ * painted with the given layout bounds. */
20
+ declare function paintWidget(w: Widget, b: Bounds, frameIdx: number): Canvas;
21
+
22
+ export { DEFAULT_PALETTE, type Palette, checkImage, paintWidget, printImage };
@@ -0,0 +1,76 @@
1
+ import {
2
+ Canvas,
3
+ DrawContext,
4
+ bounds
5
+ } from "../chunk-NASKU36A.js";
6
+
7
+ // src/testing/index.ts
8
+ var DEFAULT_PALETTE = {
9
+ r: [255, 0, 0, 255],
10
+ g: [0, 255, 0, 255],
11
+ b: [0, 0, 255, 255],
12
+ w: [255, 255, 255, 255],
13
+ ".": [0, 0, 0, 0],
14
+ x: [0, 0, 0, 255]
15
+ };
16
+ function printImage(im, palette = DEFAULT_PALETTE) {
17
+ const toAscii = /* @__PURE__ */ new Map();
18
+ for (const [ch, rgba] of Object.entries(palette)) {
19
+ toAscii.set(rgba.join(","), ch);
20
+ }
21
+ let out = "";
22
+ for (let y = 0; y < im.height; y++) {
23
+ for (let x = 0; x < im.width; x++) {
24
+ out += toAscii.get(im.at(x, y).join(",")) ?? "?";
25
+ }
26
+ out += "\n";
27
+ }
28
+ return out;
29
+ }
30
+ function diff(expected, actual, palette) {
31
+ return `EXPECTED
32
+ ${expected.join("\n")}
33
+ ACTUAL
34
+ ${printImage(actual, palette)}`;
35
+ }
36
+ function checkImage(expected, actual, palette = DEFAULT_PALETTE) {
37
+ if (expected.length !== actual.height) {
38
+ throw new Error(
39
+ `expected ${expected.length} rows, found ${actual.height}
40
+ ` + diff(expected, actual, palette)
41
+ );
42
+ }
43
+ for (let y = 0; y < actual.height; y++) {
44
+ const row = expected[y];
45
+ if (row.length !== actual.width) {
46
+ throw new Error(
47
+ `row ${y}: expected ${row.length} columns, found ${actual.width}
48
+ ` + diff(expected, actual, palette)
49
+ );
50
+ }
51
+ for (let x = 0; x < actual.width; x++) {
52
+ const want = palette[row[x]];
53
+ const got = actual.at(x, y);
54
+ if (!want || want[0] !== got[0] || want[1] !== got[1] || want[2] !== got[2] || want[3] !== got[3]) {
55
+ throw new Error(
56
+ `color differs at ${x},${y}
57
+ ` + diff(expected, actual, palette)
58
+ );
59
+ }
60
+ }
61
+ }
62
+ }
63
+ function paintWidget(w, b, frameIdx) {
64
+ const pb = w.paintBounds(b, frameIdx);
65
+ const canvas = new Canvas(pb.w, pb.h);
66
+ const dc = new DrawContext(canvas);
67
+ w.paint(dc, b, frameIdx);
68
+ return canvas;
69
+ }
70
+ export {
71
+ DEFAULT_PALETTE,
72
+ bounds,
73
+ checkImage,
74
+ paintWidget,
75
+ printImage
76
+ };
@@ -0,0 +1,84 @@
1
+ import { W as Widget, B as Bounds, D as DrawContext, a as Canvas } from './widget-DABrpucj.js';
2
+
3
+ /**
4
+ * Image — port of pixlet render/image.go. Decodes PNG / animated GIF /
5
+ * WebP (via an injected decoder), scales with nearest-neighbor when
6
+ * width/height are set (aspect preserved when only one is given), and for
7
+ * animated GIFs replays Go Image.InitFromGIF's frame-composition logic —
8
+ * including its disposal-method quirks — exactly.
9
+ *
10
+ * JPEG and SVG are not supported yet (M3); they fail with a clear error.
11
+ */
12
+
13
+ /** Optional WebP decoder (WASM), injected by the host/runtime. */
14
+ type WebpDecoder = (data: Uint8Array) => Promise<{
15
+ width: number;
16
+ height: number;
17
+ rgba: Uint8Array;
18
+ }>;
19
+ declare function setWebpDecoder(decoder: WebpDecoder | null): void;
20
+ declare class Image implements Widget {
21
+ /** Binary image data. */
22
+ src: Uint8Array;
23
+ width: number;
24
+ height: number;
25
+ /** (Read-only) frame delay in ms, for animated GIFs. */
26
+ delay: number;
27
+ private imgs;
28
+ constructor(props: {
29
+ src: Uint8Array;
30
+ width?: number;
31
+ height?: number;
32
+ });
33
+ private initFromGif;
34
+ init(): Promise<void>;
35
+ private frame;
36
+ size(): [number, number];
37
+ paintBounds(_b: Bounds, frameIdx: number): Bounds;
38
+ paint(dc: DrawContext, _b: Bounds, frameIdx: number): void;
39
+ frameCount(): number;
40
+ }
41
+
42
+ /**
43
+ * Animated WebP encoding — the TS counterpart of pixlet encode/webp.go +
44
+ * encode/encode.go.
45
+ *
46
+ * Per-frame bitstream encoding is delegated to an injected `FrameEncoder`
47
+ * (WASM libwebp — see webp-jsquash.ts); this module owns the deterministic
48
+ * parts: frame-duration policy (delay + maxDuration cap, ported from
49
+ * EncodeWebP) and the animation container mux (VP8X/ANIM/ANMF assembly,
50
+ * the moral equivalent of libwebp's WebPAnimEncoderAssemble with
51
+ * kmin=kmax=0 — every frame an independent keyframe, no blending).
52
+ *
53
+ * Determinism: container assembly is pure; frame bitstreams are
54
+ * deterministic for a pinned libwebp build + settings (handoff §8 — fold
55
+ * the encoder version into the cache key at the render-plane layer).
56
+ */
57
+
58
+ /** Encodes one straight-RGBA frame to a complete (still) WebP file. */
59
+ type FrameEncoder = (rgba: Uint8Array, width: number, height: number) => Promise<Uint8Array>;
60
+ /** encode.go DefaultScreenDelayMillis. */
61
+ declare const DEFAULT_SCREEN_DELAY_MILLIS = 50;
62
+ interface EncodeWebpOptions {
63
+ encoder: FrameEncoder;
64
+ /**
65
+ * Frame delay in ms (Root.delay). Values <= 0 fall back to the default
66
+ * 50ms, mirroring encode.go ScreensFromRoots.
67
+ */
68
+ delayMs?: number;
69
+ /**
70
+ * Cap on total animation duration in ms; 0 disables the cap. Frame
71
+ * durations are truncated and trailing frames dropped, mirroring
72
+ * EncodeWebP's remainingDuration loop.
73
+ */
74
+ maxDurationMs?: number;
75
+ /** ANIM loop count; 0 = loop forever (libwebp default). */
76
+ loopCount?: number;
77
+ }
78
+ /**
79
+ * Encode frames to an animated WebP. Returns an empty array for zero frames
80
+ * (EncodeWebP returns []byte{}).
81
+ */
82
+ declare function encodeWebp(frames: Canvas[], opts: EncodeWebpOptions): Promise<Uint8Array>;
83
+
84
+ export { DEFAULT_SCREEN_DELAY_MILLIS as D, type FrameEncoder as F, Image as I, type WebpDecoder as W, encodeWebp as e, setWebpDecoder as s };
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Colors. Mirrors `pixlet/render/colors.go` (ParseColor) and Go's
3
+ * `color.NRGBA` — straight (non-premultiplied) 8-bit RGBA.
4
+ */
5
+ /** Straight-alpha 8-bit color, like Go color.NRGBA. */
6
+ interface Color {
7
+ readonly r: number;
8
+ readonly g: number;
9
+ readonly b: number;
10
+ readonly a: number;
11
+ }
12
+ declare const WHITE: Color;
13
+ declare const BLACK: Color;
14
+ declare const TRANSPARENT: Color;
15
+ /**
16
+ * Parse `#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA` (leading `#` optional — one is
17
+ * stripped, mirroring Go strings.TrimPrefix). Port of render.ParseColor.
18
+ */
19
+ declare function parseColor(scol: string): Color;
20
+ /**
21
+ * 16-bit premultiplied color, i.e. what Go's `color.NRGBA.RGBA()` returns.
22
+ * All blend math runs on these, mirroring Go exactly:
23
+ * v16 = (v8 * 0x101 * a8) / 0xff (integer division)
24
+ */
25
+ interface Color16 {
26
+ readonly r: number;
27
+ readonly g: number;
28
+ readonly b: number;
29
+ readonly a: number;
30
+ }
31
+ declare function color16(c: Color): Color16;
32
+
33
+ /**
34
+ * Canvas — an RGBA pixel buffer with premultiplied alpha, mirroring Go's
35
+ * `image.RGBA` (which is what gg renders into). 4 bytes per pixel, row-major.
36
+ */
37
+
38
+ declare class Canvas {
39
+ readonly width: number;
40
+ readonly height: number;
41
+ /** Premultiplied RGBA, length = width * height * 4. */
42
+ readonly pix: Uint8Array;
43
+ constructor(width: number, height: number);
44
+ /**
45
+ * Fill every pixel with `c`, ignoring any clip — mirrors gg.Clear()
46
+ * (draw.Src over the whole image). Used by Root for solid backgrounds.
47
+ */
48
+ clear(c: Color): void;
49
+ /** Premultiplied RGBA of pixel (x, y). Out of bounds returns zeros. */
50
+ at(x: number, y: number): [number, number, number, number];
51
+ }
52
+
53
+ /**
54
+ * Analytic polygon-coverage rasterization for affine-transformed rectangles
55
+ * (Transformation with scale/rotate, and eventually Circle/Plot paths).
56
+ *
57
+ * Parity note: pixlet rasterizes through freetype/raster, whose span
58
+ * accumulation produces near-exact area coverage. We compute exact per-pixel
59
+ * polygon area (Sutherland–Hodgman clip against each pixel square). Results
60
+ * are integer-identical for axis-aligned integer rectangles and within the
61
+ * project's perceptual-tolerance bar (§14, "locked") for AA edges. Integer
62
+ * translations never reach this path — they use the exact fast paths.
63
+ */
64
+ interface Pt {
65
+ x: number;
66
+ y: number;
67
+ }
68
+
69
+ /**
70
+ * DrawContext — the subset of tidbyt/gg's Context that pixlet's widgets use:
71
+ * Push/Pop, Translate/Scale/Rotate (+ *About variants), SetColor,
72
+ * DrawRectangle+Fill, DrawRectangle+Clip, DrawImage.
73
+ *
74
+ * Two rendering regimes, chosen per-operation:
75
+ *
76
+ * - Integer translation (all of pixlet's static widgets): exact fast paths
77
+ * whose integer blend math mirrors freetype/gg byte-for-byte. This is the
78
+ * regime the ported pixlet unit tests pin down.
79
+ * - General affine (Transformation's scale/rotate): rectangles become
80
+ * convex quads filled via analytic polygon coverage; images are
81
+ * inverse-mapped with bilinear sampling (x/image/draw-style). Perceptual
82
+ * parity per the locked bar (§14) — byte-exact freetype span replication
83
+ * is deliberately out of scope.
84
+ *
85
+ * The clip is a full-canvas alpha mask (like gg's `mask *image.Alpha`).
86
+ * Intersection is per-pixel multiplication — gg.Clip()'s DrawMask(Over onto
87
+ * empty) composition. Push/Pop snapshot {matrix, color, mask}; masks are
88
+ * immutable once installed, so snapshots share them.
89
+ */
90
+
91
+ declare class DrawContext {
92
+ readonly canvas: Canvas;
93
+ private st;
94
+ private stack;
95
+ constructor(canvas: Canvas);
96
+ push(): void;
97
+ pop(): void;
98
+ translate(dx: number, dy: number): void;
99
+ scale(sx: number, sy: number): void;
100
+ /** Anticlockwise rotation about the origin, radians. */
101
+ rotate(angle: number): void;
102
+ scaleAbout(sx: number, sy: number, x: number, y: number): void;
103
+ rotateAbout(angle: number, x: number, y: number): void;
104
+ setColor(c: Color): void;
105
+ /** Transformed corners of rect (x,y,w,h), in path order. */
106
+ private rectQuad;
107
+ /**
108
+ * gg DrawRectangle(x,y,w,h) + Fill().
109
+ * A negative width/height rectangle is a reversed-winding path covering
110
+ * [x+w, x] × [y+h, y]; the non-zero winding rule still fills it.
111
+ */
112
+ fillRect(x: number, y: number, w: number, h: number): void;
113
+ /**
114
+ * gg DrawRectangle(x,y,w,h) + Clip(): intersect the clip region with the
115
+ * given rectangle (in current-transform space).
116
+ */
117
+ clipRect(x: number, y: number, w: number, h: number): void;
118
+ /**
119
+ * gg SetPixel: sets the pixel to the current color, converting like Go's
120
+ * image.RGBA.Set (NRGBA → premultiplied). Note: like gg, this *ignores*
121
+ * the clip mask and compositing — it is a raw store. Plot depends on it.
122
+ */
123
+ setPixel(x: number, y: number): void;
124
+ /** Apply the current matrix to a point (gg TransformPoint). */
125
+ transformPoint(x: number, y: number): Pt;
126
+ /**
127
+ * Fill an arbitrary polygon given in local (pre-transform) coordinates
128
+ * with the current color — the gg path-Fill equivalent for non-rect
129
+ * shapes (Circle, PieChart). Coverage-based AA, honors the clip mask.
130
+ */
131
+ fillPolygon(localPts: Pt[]): void;
132
+ /** gg DrawImage(src, x, y) with the current transform and clip. */
133
+ drawCanvas(src: Canvas, x: number, y: number): void;
134
+ }
135
+
136
+ /**
137
+ * Bounds is the (width, height) of a layout rectangle.
138
+ *
139
+ * Pixlet passes Go `image.Rectangle` values constructed as
140
+ * `image.Rect(0, 0, w, h)` throughout layout, and widgets only ever consume
141
+ * `Dx()`/`Dy()`. Go canonicalizes rectangles — negative extents swap min/max —
142
+ * so `Dx()`/`Dy()` come out as the *absolute value*. Pixlet relies on this:
143
+ * e.g. `Padding.PaintBounds` computes `bounds.Dx()-Left-Right`, which can go
144
+ * negative and silently becomes positive again (see TestPadding "small
145
+ * bounds": a 4×4 bound with pad l1/t2/r3/b4 measures the child against 0×2).
146
+ *
147
+ * `bounds()` mirrors that behavior exactly. Do not "fix" it — parity with the
148
+ * Go renderer depends on it.
149
+ */
150
+ interface Bounds {
151
+ readonly w: number;
152
+ readonly h: number;
153
+ }
154
+ /** Mirror of Go `image.Rect(0, 0, w, h)` → `(Dx(), Dy())`. */
155
+ declare function bounds(w: number, h: number): Bounds;
156
+
157
+ /** Widget contract. Mirrors pixlet render/widget.go. */
158
+
159
+ interface Widget {
160
+ /** Bounds of the area that will actually be drawn to when paint() is called. */
161
+ paintBounds(bounds: Bounds, frameIdx: number): Bounds;
162
+ paint(dc: DrawContext, bounds: Bounds, frameIdx: number): void;
163
+ frameCount(): number;
164
+ /** Widgets can require initialization (Text, Image, WrappedText). */
165
+ init?(): void | Promise<void>;
166
+ /** WidgetStaticSize: inherent size known before painting. */
167
+ size?(): [number, number];
168
+ }
169
+ /** Computes a (mod m), non-negative. */
170
+ declare function modInt(a: number, m: number): number;
171
+ /** Maximum frame count of a slice of widgets (minimum 1). */
172
+ declare function maxFrameCount(widgets: Widget[]): number;
173
+
174
+ export { type Bounds as B, type Color16 as C, DrawContext as D, TRANSPARENT as T, type Widget as W, Canvas as a, type Color as b, BLACK as c, WHITE as d, bounds as e, color16 as f, modInt as g, maxFrameCount as m, parseColor as p };
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@koiosdigital/matrx-render",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": "./dist/index.js",
7
+ "./testing": "./dist/testing/index.js",
8
+ "./webp-jsquash": "./dist/encode/webp-jsquash.js",
9
+ "./package.json": "./package.json"
10
+ },
11
+ "dependencies": {
12
+ "@jsquash/webp": "^1.5.0",
13
+ "fflate": "^0.8.3",
14
+ "@koiosdigital/matrx-sdk": "0.1.0"
15
+ },
16
+ "devDependencies": {
17
+ "@types/node": "^26.1.0",
18
+ "typescript": "^5.8.0",
19
+ "vitest": "^3.2.0"
20
+ },
21
+ "license": "GPL-3.0-only",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/koiosdigital/matrx-render.git",
25
+ "directory": "packages/render"
26
+ },
27
+ "files": [
28
+ "dist"
29
+ ],
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "scripts": {
34
+ "build": "tsc -p tsconfig.json",
35
+ "test": "vitest run",
36
+ "gen:fonts": "node scripts/gen-fonts.mjs",
37
+ "build:dist": "tsup src/index.ts src/testing/index.ts src/encode/webp-jsquash.ts --format esm --dts --clean --out-dir dist"
38
+ },
39
+ "main": "./dist/index.js",
40
+ "types": "./dist/index.d.ts"
41
+ }