capture-flow 0.0.0-stage → 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.
Files changed (104) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +238 -2
  3. package/bin/capture-flow.mjs +12 -0
  4. package/package.json +55 -4
  5. package/src/build.ts +186 -0
  6. package/src/capture.ts +77 -0
  7. package/src/cli.ts +139 -0
  8. package/src/index.ts +11 -0
  9. package/src/manifest.ts +98 -0
  10. package/src/render.ts +75 -0
  11. package/src/verify.ts +90 -0
  12. package/vendor/wajha/LICENSE +21 -0
  13. package/vendor/wajha/NOTICE +21 -0
  14. package/vendor/wajha/engine/background.ts +55 -0
  15. package/vendor/wajha/engine/bidi.ts +589 -0
  16. package/vendor/wajha/engine/cache.ts +96 -0
  17. package/vendor/wajha/engine/color.ts +125 -0
  18. package/vendor/wajha/engine/devices/android.ts +53 -0
  19. package/vendor/wajha/engine/devices/browser.ts +170 -0
  20. package/vendor/wajha/engine/devices/finish.ts +154 -0
  21. package/vendor/wajha/engine/devices/geometry.ts +326 -0
  22. package/vendor/wajha/engine/devices/index.ts +143 -0
  23. package/vendor/wajha/engine/devices/ipad.ts +21 -0
  24. package/vendor/wajha/engine/devices/iphone.ts +55 -0
  25. package/vendor/wajha/engine/devices/macbook.ts +187 -0
  26. package/vendor/wajha/engine/devices/mock/apps/calendar.ts +145 -0
  27. package/vendor/wajha/engine/devices/mock/apps/chat.ts +213 -0
  28. package/vendor/wajha/engine/devices/mock/apps/finance.ts +164 -0
  29. package/vendor/wajha/engine/devices/mock/apps/fitness.ts +145 -0
  30. package/vendor/wajha/engine/devices/mock/apps/index.ts +14 -0
  31. package/vendor/wajha/engine/devices/mock/apps/meditation.ts +167 -0
  32. package/vendor/wajha/engine/devices/mock/apps/music.ts +152 -0
  33. package/vendor/wajha/engine/devices/mock/apps/shop.ts +116 -0
  34. package/vendor/wajha/engine/devices/mock/apps/social.ts +152 -0
  35. package/vendor/wajha/engine/devices/mock/apps/tasks.ts +131 -0
  36. package/vendor/wajha/engine/devices/mock/apps/travel.ts +121 -0
  37. package/vendor/wajha/engine/devices/mock/art.ts +557 -0
  38. package/vendor/wajha/engine/devices/mock/chrome.ts +337 -0
  39. package/vendor/wajha/engine/devices/mock/icons.ts +614 -0
  40. package/vendor/wajha/engine/devices/mock/kit.ts +213 -0
  41. package/vendor/wajha/engine/devices/mock/rng.ts +40 -0
  42. package/vendor/wajha/engine/devices/mock/theme.ts +133 -0
  43. package/vendor/wajha/engine/devices/mock/widgets.ts +231 -0
  44. package/vendor/wajha/engine/devices/paths.ts +113 -0
  45. package/vendor/wajha/engine/devices/screen.ts +75 -0
  46. package/vendor/wajha/engine/devices/shadow.ts +124 -0
  47. package/vendor/wajha/engine/devices/slab.ts +200 -0
  48. package/vendor/wajha/engine/devices/vision.ts +43 -0
  49. package/vendor/wajha/engine/devices/watch.ts +14 -0
  50. package/vendor/wajha/engine/geometry.ts +152 -0
  51. package/vendor/wajha/engine/images.ts +96 -0
  52. package/vendor/wajha/engine/index.ts +20 -0
  53. package/vendor/wajha/engine/linebreak.ts +226 -0
  54. package/vendor/wajha/engine/markup.ts +84 -0
  55. package/vendor/wajha/engine/mock.ts +158 -0
  56. package/vendor/wajha/engine/paint.ts +474 -0
  57. package/vendor/wajha/engine/patterns.ts +263 -0
  58. package/vendor/wajha/engine/render.ts +364 -0
  59. package/vendor/wajha/engine/shapes.ts +260 -0
  60. package/vendor/wajha/engine/testing/canvasNode.ts +121 -0
  61. package/vendor/wajha/engine/testing/fakeDevices.ts +85 -0
  62. package/vendor/wajha/engine/text.ts +694 -0
  63. package/vendor/wajha/engine/types.ts +190 -0
  64. package/vendor/wajha/export/paths.ts +197 -0
  65. package/vendor/wajha/export/png.ts +223 -0
  66. package/vendor/wajha/export/types.ts +60 -0
  67. package/vendor/wajha/model/adapt.ts +889 -0
  68. package/vendor/wajha/model/catalog.ts +369 -0
  69. package/vendor/wajha/model/clone.ts +58 -0
  70. package/vendor/wajha/model/colorMath.ts +128 -0
  71. package/vendor/wajha/model/defaults.ts +386 -0
  72. package/vendor/wajha/model/geometry.ts +95 -0
  73. package/vendor/wajha/model/ids.ts +13 -0
  74. package/vendor/wajha/model/instantiate.ts +280 -0
  75. package/vendor/wajha/model/mirror.ts +43 -0
  76. package/vendor/wajha/model/resolve.ts +87 -0
  77. package/vendor/wajha/model/rules.ts +249 -0
  78. package/vendor/wajha/model/stub.ts +4 -0
  79. package/vendor/wajha/model/types.ts +456 -0
  80. package/vendor/wajha/model/typography.ts +28 -0
  81. package/vendor/wajha/model/validate.ts +917 -0
  82. package/vendor/wajha/templates/builtin/aurora.ts +163 -0
  83. package/vendor/wajha/templates/builtin/bubblegum.ts +132 -0
  84. package/vendor/wajha/templates/builtin/citrusPop.ts +124 -0
  85. package/vendor/wajha/templates/builtin/clayGarden.ts +114 -0
  86. package/vendor/wajha/templates/builtin/desertDune.ts +97 -0
  87. package/vendor/wajha/templates/builtin/glassPrism.ts +139 -0
  88. package/vendor/wajha/templates/builtin/launchpad.ts +140 -0
  89. package/vendor/wajha/templates/builtin/loudAndClear.ts +109 -0
  90. package/vendor/wajha/templates/builtin/midnightNeon.ts +108 -0
  91. package/vendor/wajha/templates/builtin/monoSwiss.ts +149 -0
  92. package/vendor/wajha/templates/builtin/noirGold.ts +149 -0
  93. package/vendor/wajha/templates/builtin/noor.ts +107 -0
  94. package/vendor/wajha/templates/builtin/obsidian.ts +137 -0
  95. package/vendor/wajha/templates/builtin/panoramaPeaks.ts +145 -0
  96. package/vendor/wajha/templates/builtin/panoramaRibbon.ts +138 -0
  97. package/vendor/wajha/templates/builtin/panoramaWaves.ts +138 -0
  98. package/vendor/wajha/templates/builtin/paperAndInk.ts +98 -0
  99. package/vendor/wajha/templates/builtin/sage.ts +94 -0
  100. package/vendor/wajha/templates/builtin/splitTone.ts +129 -0
  101. package/vendor/wajha/templates/builtin/sunsetBoulevard.ts +127 -0
  102. package/vendor/wajha/templates/index.ts +55 -0
  103. package/vendor/wajha/templates/kit.ts +761 -0
  104. package/vendor/wajha/ui/sortShots.ts +239 -0
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Decorative shapes: circle (an ellipse filling width x height), rect
3
+ * (corner radius = radius x min(w, h), clamped to half), blob (a smooth
4
+ * closed curve from `seed`) and ring (an ellipse outline `stroke` x u
5
+ * thick, its outer edge on the box). Any Fill works, laid out over the
6
+ * shape's own box.
7
+ *
8
+ * `blur > 0` turns the shape into a soft glow without ctx.filter (missing
9
+ * in WebKit): the shape is drawn far to the left of a scratch canvas with
10
+ * a shadow offset back into view, so only its Gaussian shadow lands. Non
11
+ * solid fills are painted into a second scratch and masked by that blurred
12
+ * silhouette. The scratch is built at reduced resolution (blur hides the
13
+ * detail) and cached, so a glow costs one drawImage per frame.
14
+ */
15
+ import type { Fill, ShapeElement } from '../model/types';
16
+ import type { Ctx2D, ImageLike } from './types';
17
+ import { cssColor, isPaintable, paintFill, simpleFillStyle, type PaintContext } from './paint';
18
+ import { clamp, ellipsePath, mulberry32, num, roundedRect, seedInt, TAU, withFastSampling, type Rect } from './geometry';
19
+ import { engineCache, objectId } from './cache';
20
+
21
+ const SHAPES = new Set(['circle', 'rect', 'blob', 'ring']);
22
+
23
+ /** Outline of a blob as cubic Béziers in a unit box centred on 0: [x0, y0, (c1x, c1y, c2x, c2y, x, y)*]. */
24
+ const blobs = engineCache<number, Float64Array>(128);
25
+ const glows = engineCache<string, ImageLike>(48, 16 * 1024 * 1024);
26
+
27
+ /** Blur is rendered at the resolution where its Gaussian sigma is about this many pixels. */
28
+ const GLOW_SIGMA = 3;
29
+ const GLOW_MAX_PIXELS = 600_000;
30
+
31
+ /** Pixel size of a shape element (never negative or NaN). */
32
+ export function shapeSize(el: ShapeElement, u: number): { w: number; h: number } {
33
+ return { w: Math.max(0, num(el.width, 0) * u), h: Math.max(0, num(el.height, 0) * u) };
34
+ }
35
+
36
+ /** Deterministic blob outline for a seed; the curve exactly fills the unit box. */
37
+ export function blobOutline(seed: number): Float64Array {
38
+ const key = seedInt(seed);
39
+ const hit = blobs.get(key);
40
+ if (hit) return hit;
41
+ const rand = mulberry32(key ^ 0x5bd1e995);
42
+ const n = 6 + Math.floor(rand() * 4);
43
+ const rot = rand() * TAU;
44
+ // Radii follow two low harmonics with seeded phases plus a little per-point jitter:
45
+ // independent random radii put neighbours at very different distances and leave kinks.
46
+ const a2 = 0.07 + rand() * 0.09;
47
+ const a3 = 0.04 + rand() * 0.07;
48
+ const p2 = rand() * TAU;
49
+ const p3 = rand() * TAU;
50
+ const px: number[] = [];
51
+ const py: number[] = [];
52
+ for (let i = 0; i < n; i++) {
53
+ // Angle jitter stays well under half a step, so points never swap order and the curve never loops.
54
+ const a = rot + ((i + (rand() - 0.5) * 0.3) * TAU) / n;
55
+ const r = 1 + a2 * Math.sin(2 * a + p2) + a3 * Math.sin(3 * a + p3) + (rand() - 0.5) * 0.08;
56
+ px.push(Math.cos(a) * r);
57
+ py.push(Math.sin(a) * r);
58
+ }
59
+ // Catmull-Rom through the points, as cubic Béziers. Standard tension (1/6) sags inside the
60
+ // circle and leaves flat facets; matching the handle length of a circular arc between
61
+ // neighbours (4/3 tan(θ/4) of the radius) keeps the outline full and round.
62
+ const step = TAU / n;
63
+ const k = ((4 / 3) * Math.tan(step / 4)) / (2 * Math.sin(step));
64
+ const out = new Float64Array(2 + n * 6);
65
+ out[0] = px[0];
66
+ out[1] = py[0];
67
+ for (let i = 0; i < n; i++) {
68
+ const i0 = (i - 1 + n) % n;
69
+ const i2 = (i + 1) % n;
70
+ const i3 = (i + 2) % n;
71
+ const o = 2 + i * 6;
72
+ out[o] = px[i] + (px[i2] - px[i0]) * k;
73
+ out[o + 1] = py[i] + (py[i2] - py[i0]) * k;
74
+ out[o + 2] = px[i2] - (px[i3] - px[i]) * k;
75
+ out[o + 3] = py[i2] - (py[i3] - py[i]) * k;
76
+ out[o + 4] = px[i2];
77
+ out[o + 5] = py[i2];
78
+ }
79
+ // Normalise the curve (not just its control points) to the unit box, so the drawn blob
80
+ // matches the element box the editor shows.
81
+ let minX = Infinity;
82
+ let minY = Infinity;
83
+ let maxX = -Infinity;
84
+ let maxY = -Infinity;
85
+ let x0 = out[0];
86
+ let y0 = out[1];
87
+ for (let i = 0; i < n; i++) {
88
+ const o = 2 + i * 6;
89
+ for (let k = 0; k <= 24; k++) {
90
+ const t = k / 24;
91
+ const mt = 1 - t;
92
+ const a = mt * mt * mt;
93
+ const b = 3 * mt * mt * t;
94
+ const c = 3 * mt * t * t;
95
+ const d = t * t * t;
96
+ const x = a * x0 + b * out[o] + c * out[o + 2] + d * out[o + 4];
97
+ const y = a * y0 + b * out[o + 1] + c * out[o + 3] + d * out[o + 5];
98
+ if (x < minX) minX = x;
99
+ if (x > maxX) maxX = x;
100
+ if (y < minY) minY = y;
101
+ if (y > maxY) maxY = y;
102
+ }
103
+ x0 = out[o + 4];
104
+ y0 = out[o + 5];
105
+ }
106
+ const sx = 1 / Math.max(1e-6, maxX - minX);
107
+ const sy = 1 / Math.max(1e-6, maxY - minY);
108
+ const cx = (minX + maxX) / 2;
109
+ const cy = (minY + maxY) / 2;
110
+ for (let i = 0; i < out.length; i += 2) {
111
+ out[i] = (out[i] - cx) * sx;
112
+ out[i + 1] = (out[i + 1] - cy) * sy;
113
+ }
114
+ blobs.set(key, out);
115
+ return out;
116
+ }
117
+
118
+ /**
119
+ * Adds the shape's outline centred on (ox, oy) at scale k. Returns false
120
+ * when there is nothing to draw (unknown kind, zero size, zero ring).
121
+ */
122
+ function tracePath(ctx: Ctx2D, el: ShapeElement, w: number, h: number, u: number, ox: number, oy: number, k: number): boolean {
123
+ const W = w * k;
124
+ const H = h * k;
125
+ switch (el.shape) {
126
+ case 'circle':
127
+ ellipsePath(ctx, ox, oy, W / 2, H / 2);
128
+ return true;
129
+ case 'rect':
130
+ roundedRect(ctx, ox - W / 2, oy - H / 2, W, H, clamp(num(el.radius, 0), 0, 0.5) * Math.min(W, H));
131
+ return true;
132
+ case 'blob': {
133
+ const b = blobOutline(el.seed);
134
+ ctx.moveTo(ox + b[0] * W, oy + b[1] * H);
135
+ for (let i = 2; i + 5 < b.length; i += 6) {
136
+ ctx.bezierCurveTo(ox + b[i] * W, oy + b[i + 1] * H, ox + b[i + 2] * W, oy + b[i + 3] * H, ox + b[i + 4] * W, oy + b[i + 5] * H);
137
+ }
138
+ ctx.closePath();
139
+ return true;
140
+ }
141
+ case 'ring': {
142
+ const sw = clamp(num(el.stroke, 0) * u, 0, Math.min(w, h) / 2) * k;
143
+ if (sw < 0.25 * k) return false;
144
+ ellipsePath(ctx, ox, oy, W / 2, H / 2);
145
+ // The hole winds the other way, so the default nonzero rule leaves a ring.
146
+ if (W / 2 - sw > 0 && H / 2 - sw > 0) ellipsePath(ctx, ox, oy, W / 2 - sw, H / 2 - sw, true);
147
+ return true;
148
+ }
149
+ default:
150
+ return false;
151
+ }
152
+ }
153
+
154
+ function drawSharp(ctx: Ctx2D, el: ShapeElement, w: number, h: number, u: number, pc: PaintContext): void {
155
+ ctx.beginPath();
156
+ if (!tracePath(ctx, el, w, h, u, 0, 0, 1)) return;
157
+ const box: Rect = { x: -w / 2, y: -h / 2, w, h };
158
+ const style = simpleFillStyle(ctx, el.fill, box);
159
+ if (style !== null) {
160
+ ctx.fillStyle = style;
161
+ ctx.fill();
162
+ return;
163
+ }
164
+ // Mesh and image fills need several paint calls: clip to the outline and paint the box.
165
+ ctx.save();
166
+ try {
167
+ ctx.clip();
168
+ paintFill(ctx, el.fill, box, pc);
169
+ } finally {
170
+ ctx.restore();
171
+ }
172
+ }
173
+
174
+ function fillKey(fill: Fill, pc: PaintContext): string {
175
+ if (fill.type !== 'image') return JSON.stringify(fill);
176
+ let img: ImageLike | null = null;
177
+ try {
178
+ img = typeof fill.assetId === 'string' ? pc.getImage(fill.assetId) : null;
179
+ } catch {
180
+ img = null;
181
+ }
182
+ return `${JSON.stringify(fill)}|${img ? objectId(img) : 0}`;
183
+ }
184
+
185
+ function drawGlow(ctx: Ctx2D, el: ShapeElement, w: number, h: number, u: number, blurPx: number, pc: PaintContext): void {
186
+ const sigma = blurPx / 2;
187
+ const margin = Math.ceil(blurPx * 1.5) + 2; // ~3 sigma: where the Gaussian has faded out
188
+ const fullW = w + 2 * margin;
189
+ const fullH = h + 2 * margin;
190
+ let s = Math.min(1, GLOW_SIGMA / sigma);
191
+ if (fullW * fullH * s * s > GLOW_MAX_PIXELS) s = Math.sqrt(GLOW_MAX_PIXELS / (fullW * fullH));
192
+ const sw = Math.max(1, Math.ceil(fullW * s));
193
+ const sh = Math.max(1, Math.ceil(fullH * s));
194
+ const fill = el.fill;
195
+ const key = [el.shape, w.toFixed(2), h.toFixed(2), u.toFixed(2), blurPx.toFixed(2), sw, sh, num(el.radius, 0), num(el.stroke, 0), seedInt(el.seed), fillKey(fill, pc)].join('|');
196
+ let glow = glows.get(key);
197
+ if (!glow) {
198
+ let made: ImageLike | null = null;
199
+ try {
200
+ made = renderGlow(el, w, h, u, blurPx, s, sw, sh, pc);
201
+ } catch {
202
+ made = null;
203
+ }
204
+ if (!made) return;
205
+ glow = made;
206
+ glows.set(key, glow, sw * sh * 4);
207
+ }
208
+ // Map scratch pixels back exactly (sw / s rather than fullW, which ceil() rounded).
209
+ const img = glow;
210
+ withFastSampling(ctx, () => ctx.drawImage(img, -sw / (2 * s), -sh / (2 * s), sw / s, sh / s));
211
+ }
212
+
213
+ function renderGlow(el: ShapeElement, w: number, h: number, u: number, blurPx: number, s: number, sw: number, sh: number, pc: PaintContext): ImageLike | null {
214
+ let mask: { canvas: ImageLike; ctx: Ctx2D };
215
+ try {
216
+ mask = pc.env.createCanvas(sw, sh);
217
+ } catch {
218
+ return null;
219
+ }
220
+ const fill = el.fill;
221
+ const solid = fill.type === 'solid' ? cssColor(fill.color) : null;
222
+ const g = mask.ctx;
223
+ // Far enough left that the shape itself never touches the scratch canvas.
224
+ const offset = sw + Math.ceil(blurPx * s * 2) + 16;
225
+ g.shadowColor = solid ?? '#000000';
226
+ g.shadowBlur = blurPx * s;
227
+ g.shadowOffsetX = offset;
228
+ g.shadowOffsetY = 0;
229
+ g.fillStyle = '#000000';
230
+ g.beginPath();
231
+ if (!tracePath(g, el, w, h, u, sw / 2 - offset, sh / 2, s)) return null;
232
+ g.fill();
233
+ if (solid) return mask.canvas;
234
+
235
+ // Other fills: paint the fill normally, then keep it only where the blurred silhouette is.
236
+ let painted: { canvas: ImageLike; ctx: Ctx2D };
237
+ try {
238
+ painted = pc.env.createCanvas(sw, sh);
239
+ } catch {
240
+ return null;
241
+ }
242
+ const p = painted.ctx;
243
+ const box: Rect = { x: sw / 2 - (w * s) / 2, y: sh / 2 - (h * s) / 2, w: w * s, h: h * s };
244
+ const all: Rect = { x: 0, y: 0, w: sw, h: sh };
245
+ // Images cover the whole glow so its soft margin is not empty.
246
+ paintFill(p, fill, fill.type === 'image' ? all : box, { ...pc, u: pc.u * s }, all);
247
+ p.globalCompositeOperation = 'destination-in';
248
+ p.drawImage(mask.canvas, 0, 0);
249
+ return painted.canvas;
250
+ }
251
+
252
+ /** Draws a shape element centred on the origin (the caller has translated and rotated). */
253
+ export function drawShape(ctx: Ctx2D, el: ShapeElement, u: number, pc: PaintContext): void {
254
+ if (!SHAPES.has(el.shape) || !isPaintable(el.fill)) return;
255
+ const { w, h } = shapeSize(el, u);
256
+ if (!(w >= 0.5) || !(h >= 0.5) || !Number.isFinite(w * h)) return;
257
+ const blurPx = Math.min(Math.max(0, num(el.blur, 0)) * u, 4 * Math.max(w, h) + 4 * u);
258
+ if (blurPx >= 0.5) drawGlow(ctx, el, w, h, u, blurPx, pc);
259
+ else drawSharp(ctx, el, w, h, u, pc);
260
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Node-only helpers for engine tests and visual QA renders, on @napi-rs/canvas.
3
+ * Never import this from app code.
4
+ */
5
+ import { createCanvas, GlobalFonts } from '@napi-rs/canvas';
6
+ import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
7
+ import { createRequire } from 'node:module';
8
+ import { dirname, join } from 'node:path';
9
+ import type { Ctx2D, ImageLike, RenderEnv } from '../types';
10
+ import { BUNDLED_FONTS } from '../../model/catalog';
11
+
12
+ const ROOT = join(import.meta.dirname, '..', '..', '..', '..');
13
+
14
+ /**
15
+ * Where the font packages live. npm may hoist them beside this package rather
16
+ * than into its own `node_modules`, so resolve the dependency instead of
17
+ * assuming a path under ROOT.
18
+ */
19
+ function fontBase(): string {
20
+ try {
21
+ const entry = createRequire(import.meta.url).resolve('@fontsource-variable/cairo');
22
+ return dirname(dirname(entry));
23
+ } catch {
24
+ return join(ROOT, 'node_modules', '@fontsource-variable');
25
+ }
26
+ }
27
+
28
+ export interface TestCanvas {
29
+ canvas: ImageLike & { toBuffer(mime: 'image/png'): Buffer };
30
+ ctx: Ctx2D;
31
+ }
32
+
33
+ /** A canvas whose context is typed as the DOM context the engine expects. */
34
+ export function testCanvas(width: number, height: number): TestCanvas {
35
+ const canvas = createCanvas(width, height);
36
+ const ctx = canvas.getContext('2d') as unknown as Ctx2D;
37
+ return { canvas: canvas as unknown as TestCanvas['canvas'], ctx };
38
+ }
39
+
40
+ export const nodeEnv: RenderEnv = {
41
+ createCanvas(width: number, height: number) {
42
+ const { canvas, ctx } = testCanvas(Math.max(1, Math.round(width)), Math.max(1, Math.round(height)));
43
+ return { canvas, ctx };
44
+ },
45
+ };
46
+
47
+ /** Registers every bundled variable font (latin, latin-ext, arabic, hebrew, cyrillic subsets). */
48
+ /**
49
+ * Registers every bundled variable font. @napi-rs/canvas only uses the first
50
+ * file registered under a family name, so this delegates to
51
+ * registerPrimaryFonts, which registers each family's main subset first
52
+ * (latin, or arabic for Arabic-first fonts).
53
+ */
54
+ export function registerBundledFonts(): void {
55
+ registerPrimaryFonts();
56
+ }
57
+
58
+ function readFileSafe(path: string): string {
59
+ try {
60
+ return readFileSync(path, 'utf8');
61
+ } catch {
62
+ return '';
63
+ }
64
+ }
65
+
66
+ /** Writes a PNG under <repo>/.qa/ (git-ignored) for visual inspection. Returns the absolute path. */
67
+ export function saveQa(canvas: TestCanvas['canvas'], name: string): string {
68
+ const out = join(ROOT, '.qa', name.endsWith('.png') ? name : `${name}.png`);
69
+ mkdirSync(dirname(out), { recursive: true });
70
+ writeFileSync(out, canvas.toBuffer('image/png'));
71
+ return out;
72
+ }
73
+
74
+ /** RGBA of one pixel. */
75
+ export function pixel(ctx: Ctx2D, x: number, y: number): [number, number, number, number] {
76
+ const d = ctx.getImageData(Math.round(x), Math.round(y), 1, 1).data;
77
+ return [d[0], d[1], d[2], d[3]];
78
+ }
79
+
80
+ /** A solid-colour test image, optionally with a contrasting marker square in the top-left corner. */
81
+ export function solidImage(width: number, height: number, color: string, marker?: string): ImageLike {
82
+ const { canvas, ctx } = testCanvas(width, height);
83
+ ctx.fillStyle = color;
84
+ ctx.fillRect(0, 0, width, height);
85
+ if (marker) {
86
+ ctx.fillStyle = marker;
87
+ ctx.fillRect(0, 0, Math.round(width / 5), Math.round(width / 5));
88
+ }
89
+ return canvas;
90
+ }
91
+
92
+ let primaryFontsRegistered = false;
93
+
94
+ /**
95
+ * Like registerBundledFonts, but in an order that makes each family render
96
+ * its main script with its own glyphs. @napi-rs/canvas only looks glyphs up
97
+ * in the first file registered under a family name (later files with the
98
+ * same name are ignored), and registerBundledFonts goes alphabetically, so
99
+ * Inter's cyrillic subset wins and Latin text falls back to a system serif.
100
+ * Here the Arabic families (catalog category "arabic") register their arabic
101
+ * subset first and every other family its latin subset first. Call it
102
+ * before anything else registers fonts in the test process.
103
+ */
104
+ export function registerPrimaryFonts(): void {
105
+ if (primaryFontsRegistered) return;
106
+ primaryFontsRegistered = true;
107
+ const arabicFamilies = new Set(BUNDLED_FONTS.filter((f) => f.category === 'arabic').map((f) => f.family));
108
+ const base = fontBase();
109
+ for (const pkg of readdirSync(base)) {
110
+ const css = readFileSafe(join(base, pkg, 'index.css'));
111
+ const family = /font-family:\s*'([^']+)'/.exec(css)?.[1];
112
+ if (!family) continue;
113
+ const filesDir = join(base, pkg, 'files');
114
+ const order = arabicFamilies.has(family) ? ['arabic', 'latin', 'latin-ext', 'hebrew', 'cyrillic'] : ['latin', 'latin-ext', 'cyrillic', 'hebrew', 'arabic'];
115
+ const files = readdirSync(filesDir)
116
+ .map((file) => ({ file, subset: /-(latin-ext|latin|arabic|hebrew|cyrillic)-wght-normal\.woff2$/.exec(file)?.[1] }))
117
+ .filter((f): f is { file: string; subset: string } => typeof f.subset === 'string')
118
+ .sort((a, b) => order.indexOf(a.subset) - order.indexOf(b.subset));
119
+ for (const { file } of files) GlobalFonts.registerFromPath(join(filesDir, file), family);
120
+ }
121
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Stand-ins for the device painter in engine tests (vi.mock('./devices')),
3
+ * so renderer tests do not depend on another package's artwork.
4
+ * - fakeDrawDevice: a plain rectangle widthPx x widthPx * screenAspect,
5
+ * filled with the screenshot, or a flat colour per placeholder kind.
6
+ * - qaDrawDevice: a softer phone silhouette for visual QA sheets.
7
+ */
8
+ import type { DeviceGeometry, DeviceGeometryFn, DrawDeviceFn } from '../types';
9
+
10
+ export const FAKE_MOCK_COLOR = '#2050e0';
11
+ export const FAKE_EMPTY_COLOR = '#808080';
12
+
13
+ export const fakeDeviceGeometry: DeviceGeometryFn = (_frame, widthPx, screenAspect) => ({
14
+ width: widthPx,
15
+ height: widthPx * screenAspect,
16
+ screen: { x: 0, y: 0, w: widthPx, h: widthPx * screenAspect, radius: 0 },
17
+ });
18
+
19
+ export const fakeDrawDevice: DrawDeviceFn = (ctx, opts) => {
20
+ const g = fakeDeviceGeometry(opts.el.frame, opts.widthPx, opts.screenAspect);
21
+ if (opts.screen) ctx.drawImage(opts.screen, -g.width / 2, -g.height / 2, g.width, g.height);
22
+ else {
23
+ ctx.fillStyle = opts.placeholder === 'mock' ? FAKE_MOCK_COLOR : FAKE_EMPTY_COLOR;
24
+ ctx.fillRect(-g.width / 2, -g.height / 2, g.width, g.height);
25
+ }
26
+ return g;
27
+ };
28
+
29
+ /** A phone-like silhouette with a shadow and a screen, for QA renders only. */
30
+ export const qaDrawDevice: DrawDeviceFn = (ctx, opts) => {
31
+ const bezel = opts.widthPx * 0.035;
32
+ const w = opts.widthPx;
33
+ const sw = w - 2 * bezel;
34
+ const sh = sw * opts.screenAspect;
35
+ const h = sh + 2 * bezel;
36
+ const geometry: DeviceGeometry = { width: w, height: h, screen: { x: bezel, y: bezel, w: sw, h: sh, radius: sw * 0.11 } };
37
+ const r = w * 0.14;
38
+ ctx.save();
39
+ const shadow = opts.el.shadow;
40
+ if (shadow) {
41
+ ctx.shadowColor = shadow.color;
42
+ ctx.shadowBlur = shadow.blur * opts.unit;
43
+ ctx.shadowOffsetX = shadow.x * opts.unit;
44
+ ctx.shadowOffsetY = shadow.y * opts.unit;
45
+ }
46
+ ctx.fillStyle = opts.el.style.color;
47
+ ctx.beginPath();
48
+ ctx.roundRect(-w / 2, -h / 2, w, h, r);
49
+ ctx.fill();
50
+ ctx.restore();
51
+ ctx.save();
52
+ ctx.beginPath();
53
+ ctx.roundRect(-sw / 2, -sh / 2, sw, sh, geometry.screen.radius);
54
+ ctx.clip();
55
+ if (opts.screen) {
56
+ const k = Math.max(sw / opts.screen.width, sh / opts.screen.height);
57
+ ctx.drawImage(opts.screen, -(opts.screen.width * k) / 2, -(opts.screen.height * k) / 2, opts.screen.width * k, opts.screen.height * k);
58
+ } else {
59
+ const g = ctx.createLinearGradient(0, -sh / 2, 0, sh / 2);
60
+ const accent = opts.mock.palette[0] ?? '#7c3aed';
61
+ g.addColorStop(0, opts.mock.dark ? '#161622' : '#f5f6fa');
62
+ g.addColorStop(1, opts.mock.dark ? '#22223a' : '#e6e8f2');
63
+ ctx.fillStyle = g;
64
+ ctx.fillRect(-sw / 2, -sh / 2, sw, sh);
65
+ if (opts.placeholder === 'mock') {
66
+ ctx.fillStyle = accent;
67
+ ctx.beginPath();
68
+ ctx.roundRect(-sw * 0.4, -sh * 0.38, sw * 0.8, sh * 0.22, sw * 0.06);
69
+ ctx.fill();
70
+ ctx.fillStyle = opts.mock.dark ? '#ffffff22' : '#00000014';
71
+ for (let i = 0; i < 4; i++) {
72
+ ctx.beginPath();
73
+ ctx.roundRect(-sw * 0.4, -sh * 0.1 + i * sh * 0.11, sw * 0.8, sh * 0.08, sw * 0.04);
74
+ ctx.fill();
75
+ }
76
+ }
77
+ }
78
+ // Dynamic Island
79
+ ctx.fillStyle = '#000000';
80
+ ctx.beginPath();
81
+ ctx.roundRect(-sw * 0.16, -sh / 2 + sh * 0.018, sw * 0.32, sw * 0.09, sw * 0.045);
82
+ ctx.fill();
83
+ ctx.restore();
84
+ return geometry;
85
+ };