embedded-react 0.2.3 → 0.4.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/aot/compile.mjs +2407 -697
- package/aot/screenshot-smoke.mjs +34 -17
- package/aot/style-map.mjs +156 -80
- package/assets/bake-font.mjs +45 -21
- package/assets/bake-image.mjs +7 -5
- package/assets/bake-svg.mjs +563 -0
- package/assets/build-builtin-font.mjs +25 -12
- package/assets/emit-c.mjs +52 -20
- package/assets/emit-container.mjs +5 -3
- package/assets/emit-pack.mjs +8 -2
- package/assets/index.mjs +25 -16
- package/assets/rasterize.mjs +45 -11
- package/assets/svg-loader.mjs +81 -0
- package/build.mjs +43 -20
- package/cli.mjs +258 -20
- package/pack-container.mjs +84 -35
- package/package.json +9 -3
- package/persist-transform.mjs +23 -9
- package/qjsc-wasm.mjs +83 -0
- package/sim/embedded-react.cjs +2 -0
- package/sim/embedded-react.js +1 -1
- package/sim/embedded-react.wasm +0 -0
- package/sim-server.mjs +160 -48
- package/src/embedded-react/Animated.js +51 -36
- package/src/embedded-react/AppRegistry.js +4 -4
- package/src/embedded-react/Easing.js +1 -1
- package/src/embedded-react/LayoutAnimation.js +13 -6
- package/src/embedded-react/StyleSheet.js +1 -1
- package/src/embedded-react/imperative.js +19 -7
- package/src/embedded-react/index.js +8 -8
- package/src/embedded-react/layout-anim-config.js +13 -9
- package/src/embedded-react/split-style.js +6 -6
- package/src/embedded-react/svg-ops.js +369 -41
- package/src/embedded-react/usePersistentState.js +3 -3
- package/src/host-config.js +137 -18
- package/src/native-ui.js +3 -1
- package/src/props.js +22 -10
- package/src/renderer.js +3 -3
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 Cory Lamming
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// Build-time SVG -> engine op-tape baker. Turns an .svg FILE into the same
|
|
18
|
+
// {ops, paints} op-tape an inline <Svg> compiles to, so `import logo from './logo.svg'` can render as a
|
|
19
|
+
// crisp on-device vector. Every transform (matrix/translate/rotate/scale/skew, nested) is BAKED into
|
|
20
|
+
// absolute path coordinates here, so the device needs no transform support — full SVG transform fidelity
|
|
21
|
+
// for free. svgson (the XML parser) is a build-time dependency only; it never reaches the device bundle.
|
|
22
|
+
//
|
|
23
|
+
// Supports <path> + <rect>/<circle>/<ellipse>/<line>/<polygon>/<polyline> + <g> nesting + viewBox + solid
|
|
24
|
+
// AND gradient fill/stroke (linear/radial/conic via url() refs; presentation attrs + inline style +
|
|
25
|
+
// inheritance). Features the op-tape can't represent (<text>, <mask>, <filter>, <use>, …) are recorded in
|
|
26
|
+
// `dropped` so the caller can rasterize the whole SVG as a fallback image instead. Primitives are emitted as
|
|
27
|
+
// line/cubic paths (no native arc op) so an arbitrary matrix bakes cleanly.
|
|
28
|
+
import {parse} from 'svgson';
|
|
29
|
+
import {mkdirSync, writeFileSync} from 'node:fs';
|
|
30
|
+
import {join} from 'node:path';
|
|
31
|
+
import {tmpdir} from 'node:os';
|
|
32
|
+
import {createHash} from 'node:crypto';
|
|
33
|
+
import {
|
|
34
|
+
parsePath,
|
|
35
|
+
parseColor,
|
|
36
|
+
VOP_SHAPE,
|
|
37
|
+
VOP_MOVE,
|
|
38
|
+
VOP_LINE,
|
|
39
|
+
VOP_QUAD,
|
|
40
|
+
VOP_CUBIC,
|
|
41
|
+
CAP,
|
|
42
|
+
JOIN,
|
|
43
|
+
PAINT_STRIDE,
|
|
44
|
+
GRAD_LINEAR,
|
|
45
|
+
GRAD_RADIAL,
|
|
46
|
+
GRAD_CONIC,
|
|
47
|
+
} from '../src/embedded-react/svg-ops.js';
|
|
48
|
+
|
|
49
|
+
// --- 2D affine matrix [a, b, c, d, e, f]: x' = a*x + c*y + e ; y' = b*x + d*y + f ------------------
|
|
50
|
+
const IDENT = [1, 0, 0, 1, 0, 0];
|
|
51
|
+
|
|
52
|
+
/** Compose m1 ∘ m2 (apply m2 first, then m1). */
|
|
53
|
+
function mul(m1, m2) {
|
|
54
|
+
const [a1, b1, c1, d1, e1, f1] = m1;
|
|
55
|
+
const [a2, b2, c2, d2, e2, f2] = m2;
|
|
56
|
+
return [
|
|
57
|
+
a1 * a2 + c1 * b2,
|
|
58
|
+
b1 * a2 + d1 * b2,
|
|
59
|
+
a1 * c2 + c1 * d2,
|
|
60
|
+
b1 * c2 + d1 * d2,
|
|
61
|
+
a1 * e2 + c1 * f2 + e1,
|
|
62
|
+
b1 * e2 + d1 * f2 + f1,
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Parses an SVG `transform` attribute (a chain of functions) into a single matrix. */
|
|
67
|
+
function parseTransform(str) {
|
|
68
|
+
let m = IDENT;
|
|
69
|
+
if (!str) return m;
|
|
70
|
+
const re = /(matrix|translate|scale|rotate|skewX|skewY)\s*\(([^)]*)\)/g;
|
|
71
|
+
let t;
|
|
72
|
+
while ((t = re.exec(str)) !== null) {
|
|
73
|
+
const fn = t[1];
|
|
74
|
+
const p = t[2]
|
|
75
|
+
.split(/[\s,]+/)
|
|
76
|
+
.map(Number)
|
|
77
|
+
.filter(n => !Number.isNaN(n));
|
|
78
|
+
let lm = IDENT;
|
|
79
|
+
if (fn === 'matrix')
|
|
80
|
+
lm = [p[0] || 0, p[1] || 0, p[2] || 0, p[3] || 0, p[4] || 0, p[5] || 0];
|
|
81
|
+
else if (fn === 'translate') lm = [1, 0, 0, 1, p[0] || 0, p[1] || 0];
|
|
82
|
+
else if (fn === 'scale')
|
|
83
|
+
lm = [p[0] ?? 1, 0, 0, p.length > 1 ? p[1] : (p[0] ?? 1), 0, 0];
|
|
84
|
+
else if (fn === 'rotate') {
|
|
85
|
+
const a = ((p[0] || 0) * Math.PI) / 180;
|
|
86
|
+
const cos = Math.cos(a);
|
|
87
|
+
const sin = Math.sin(a);
|
|
88
|
+
const rot = [cos, sin, -sin, cos, 0, 0];
|
|
89
|
+
// rotate(angle cx cy) rotates around a point.
|
|
90
|
+
lm =
|
|
91
|
+
p.length >= 3
|
|
92
|
+
? mul(mul([1, 0, 0, 1, p[1], p[2]], rot), [1, 0, 0, 1, -p[1], -p[2]])
|
|
93
|
+
: rot;
|
|
94
|
+
} else if (fn === 'skewX')
|
|
95
|
+
lm = [1, 0, Math.tan(((p[0] || 0) * Math.PI) / 180), 1, 0, 0];
|
|
96
|
+
else if (fn === 'skewY')
|
|
97
|
+
lm = [1, Math.tan(((p[0] || 0) * Math.PI) / 180), 0, 1, 0, 0];
|
|
98
|
+
m = mul(m, lm);
|
|
99
|
+
}
|
|
100
|
+
return m;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Applies matrix @p m to a point. */
|
|
104
|
+
function pt(m, x, y) {
|
|
105
|
+
return [m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Transforms a path op array (no leading SHAPE op) by matrix @p m. parsePath only emits MOVE/LINE/QUAD/
|
|
109
|
+
* CUBIC/CLOSE (it converts arcs to cubics), so every coordinate pair is a plain point — matrix-safe. */
|
|
110
|
+
function transformOps(ops, m) {
|
|
111
|
+
const out = [];
|
|
112
|
+
let i = 0;
|
|
113
|
+
while (i < ops.length) {
|
|
114
|
+
const op = ops[i++];
|
|
115
|
+
out.push(op);
|
|
116
|
+
if (op === VOP_MOVE || op === VOP_LINE) {
|
|
117
|
+
const [x, y] = pt(m, ops[i++], ops[i++]);
|
|
118
|
+
out.push(x, y);
|
|
119
|
+
} else if (op === VOP_QUAD) {
|
|
120
|
+
const [cx, cy] = pt(m, ops[i++], ops[i++]);
|
|
121
|
+
const [x, y] = pt(m, ops[i++], ops[i++]);
|
|
122
|
+
out.push(cx, cy, x, y);
|
|
123
|
+
} else if (op === VOP_CUBIC) {
|
|
124
|
+
const [c1x, c1y] = pt(m, ops[i++], ops[i++]);
|
|
125
|
+
const [c2x, c2y] = pt(m, ops[i++], ops[i++]);
|
|
126
|
+
const [x, y] = pt(m, ops[i++], ops[i++]);
|
|
127
|
+
out.push(c1x, c1y, c2x, c2y, x, y);
|
|
128
|
+
}
|
|
129
|
+
// VOP_CLOSE has no args.
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Uniform stroke-width scale factor for a matrix (geometric mean of the axis scales). */
|
|
135
|
+
function scaleOf(m) {
|
|
136
|
+
return Math.sqrt(Math.abs(m[0] * m[3] - m[1] * m[2])) || 1;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function num(v, dflt = 0) {
|
|
140
|
+
const n = parseFloat(v);
|
|
141
|
+
return Number.isNaN(n) ? dflt : n;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// --- Primitive shapes -> path `d` (emitted as lines/arcs; parsePath turns arcs into cubics) ----------
|
|
145
|
+
function rectPath(a) {
|
|
146
|
+
const x = num(a.x);
|
|
147
|
+
const y = num(a.y);
|
|
148
|
+
const w = num(a.width);
|
|
149
|
+
const h = num(a.height);
|
|
150
|
+
if (w <= 0 || h <= 0) return '';
|
|
151
|
+
return `M${x} ${y}L${x + w} ${y}L${x + w} ${y + h}L${x} ${y + h}Z`;
|
|
152
|
+
}
|
|
153
|
+
function circlePath(a) {
|
|
154
|
+
const cx = num(a.cx);
|
|
155
|
+
const cy = num(a.cy);
|
|
156
|
+
const r = num(a.r);
|
|
157
|
+
if (r <= 0) return '';
|
|
158
|
+
return `M${cx - r} ${cy}A${r} ${r} 0 1 0 ${cx + r} ${cy}A${r} ${r} 0 1 0 ${cx - r} ${cy}Z`;
|
|
159
|
+
}
|
|
160
|
+
function ellipsePath(a) {
|
|
161
|
+
const cx = num(a.cx);
|
|
162
|
+
const cy = num(a.cy);
|
|
163
|
+
const rx = num(a.rx);
|
|
164
|
+
const ry = num(a.ry);
|
|
165
|
+
if (rx <= 0 || ry <= 0) return '';
|
|
166
|
+
return `M${cx - rx} ${cy}A${rx} ${ry} 0 1 0 ${cx + rx} ${cy}A${rx} ${ry} 0 1 0 ${cx - rx} ${cy}Z`;
|
|
167
|
+
}
|
|
168
|
+
function linePath(a) {
|
|
169
|
+
return `M${num(a.x1)} ${num(a.y1)}L${num(a.x2)} ${num(a.y2)}`;
|
|
170
|
+
}
|
|
171
|
+
function polyPath(a, close) {
|
|
172
|
+
const v = String(a.points || '')
|
|
173
|
+
.trim()
|
|
174
|
+
.split(/[\s,]+/)
|
|
175
|
+
.map(Number)
|
|
176
|
+
.filter(n => !Number.isNaN(n));
|
|
177
|
+
if (v.length < 4) return '';
|
|
178
|
+
let d = `M${v[0]} ${v[1]}`;
|
|
179
|
+
for (let i = 2; i + 1 < v.length; i += 2) d += `L${v[i]} ${v[i + 1]}`;
|
|
180
|
+
return close ? `${d}Z` : d;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// --- Paint resolution (presentation attrs + inline style + inheritance) ------------------------------
|
|
184
|
+
const PAINT_KEYS = [
|
|
185
|
+
'fill',
|
|
186
|
+
'stroke',
|
|
187
|
+
'stroke-width',
|
|
188
|
+
'stroke-linecap',
|
|
189
|
+
'stroke-linejoin',
|
|
190
|
+
'stroke-miterlimit',
|
|
191
|
+
'fill-rule',
|
|
192
|
+
'opacity',
|
|
193
|
+
'fill-opacity',
|
|
194
|
+
'stroke-opacity',
|
|
195
|
+
'stroke-dasharray',
|
|
196
|
+
];
|
|
197
|
+
const DEFAULT_PAINT = {
|
|
198
|
+
fill: 'black',
|
|
199
|
+
stroke: 'none',
|
|
200
|
+
'stroke-width': '1',
|
|
201
|
+
'stroke-linecap': 'butt',
|
|
202
|
+
'stroke-linejoin': 'miter',
|
|
203
|
+
'stroke-miterlimit': '4',
|
|
204
|
+
'fill-rule': 'nonzero',
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
function parseStyle(s) {
|
|
208
|
+
const o = {};
|
|
209
|
+
if (!s) return o;
|
|
210
|
+
for (const decl of String(s).split(';')) {
|
|
211
|
+
const i = decl.indexOf(':');
|
|
212
|
+
if (i > 0) o[decl.slice(0, i).trim()] = decl.slice(i + 1).trim();
|
|
213
|
+
}
|
|
214
|
+
return o;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function resolvePaint(attrs, inherited) {
|
|
218
|
+
const style = parseStyle(attrs.style);
|
|
219
|
+
const out = {...inherited};
|
|
220
|
+
for (const k of PAINT_KEYS) {
|
|
221
|
+
const v = style[k] ?? attrs[k];
|
|
222
|
+
if (v != null) out[k] = v;
|
|
223
|
+
}
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Resolves a CSS color to a uint32 ARGB. url(...) paints (gradients/patterns) bake to nothing in v1. */
|
|
228
|
+
function colorOf(v) {
|
|
229
|
+
if (typeof v === 'string' && v.trim().startsWith('url(')) return 0;
|
|
230
|
+
return parseColor(v);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function clamp01(v) {
|
|
234
|
+
return v < 0 ? 0 : v > 1 ? 1 : v;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Multiplies an ARGB color's alpha by @p f (SVG opacity / fill-opacity / stroke-opacity, baked in). */
|
|
238
|
+
function applyAlpha(argb, f) {
|
|
239
|
+
if (f >= 1) return argb >>> 0;
|
|
240
|
+
const a = Math.round(((argb >>> 24) & 0xff) * clamp01(f));
|
|
241
|
+
return ((a << 24) | (argb & 0xffffff)) >>> 0;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// --- Gradients (<linearGradient> / <radialGradient>) -------------------------------------------------
|
|
245
|
+
|
|
246
|
+
/** Parses <stop>s into [{ color: uint32 (stop-opacity baked in), offset: 0..1 }], in authored order. */
|
|
247
|
+
function parseStops(node) {
|
|
248
|
+
const out = [];
|
|
249
|
+
for (const c of node.children || []) {
|
|
250
|
+
if (c.type !== 'element' || c.name !== 'stop') continue;
|
|
251
|
+
const a = c.attributes || {};
|
|
252
|
+
const style = parseStyle(a.style);
|
|
253
|
+
const col = style['stop-color'] ?? a['stop-color'] ?? 'black';
|
|
254
|
+
const so = clamp01(num(style['stop-opacity'] ?? a['stop-opacity'], 1));
|
|
255
|
+
let off = String(a.offset ?? '0').trim();
|
|
256
|
+
off = off.endsWith('%') ? parseFloat(off) / 100 : parseFloat(off);
|
|
257
|
+
out.push({
|
|
258
|
+
color: applyAlpha(parseColor(col), so),
|
|
259
|
+
offset: clamp01(Number.isNaN(off) ? 0 : off),
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Collects every <linearGradient>/<radialGradient> by id (searched anywhere, including <defs>). */
|
|
266
|
+
function collectGradients(root) {
|
|
267
|
+
const map = new Map();
|
|
268
|
+
const visit = node => {
|
|
269
|
+
for (const c of node.children || []) {
|
|
270
|
+
if (c.type !== 'element') continue;
|
|
271
|
+
if (
|
|
272
|
+
(c.name === 'linearGradient' ||
|
|
273
|
+
c.name === 'radialGradient' ||
|
|
274
|
+
c.name === 'conicGradient') &&
|
|
275
|
+
c.attributes &&
|
|
276
|
+
c.attributes.id
|
|
277
|
+
)
|
|
278
|
+
map.set(c.attributes.id, {
|
|
279
|
+
name: c.name,
|
|
280
|
+
attrs: c.attributes,
|
|
281
|
+
stops: parseStops(c),
|
|
282
|
+
});
|
|
283
|
+
visit(c);
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
visit(root);
|
|
287
|
+
return map;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** "url(#id)" -> "id" (else null). */
|
|
291
|
+
function urlRef(v) {
|
|
292
|
+
const m = typeof v === 'string' && v.trim().match(/^url\(\s*#([^)\s]+)\s*\)/);
|
|
293
|
+
return m ? m[1] : null;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** A gradient coordinate: "50%" -> 0.5; a plain number passes through. */
|
|
297
|
+
function gradCoord(v, dflt) {
|
|
298
|
+
if (v == null) return dflt;
|
|
299
|
+
const s = String(v).trim();
|
|
300
|
+
return s.endsWith('%') ? parseFloat(s) / 100 : parseFloat(s);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Local-space bounding box of a path-op array (no SHAPE header; MOVE/LINE/QUAD/CUBIC, CLOSE has no args). */
|
|
304
|
+
function opsBBox(ops) {
|
|
305
|
+
let minx = Infinity;
|
|
306
|
+
let miny = Infinity;
|
|
307
|
+
let maxx = -Infinity;
|
|
308
|
+
let maxy = -Infinity;
|
|
309
|
+
let i = 0;
|
|
310
|
+
while (i < ops.length) {
|
|
311
|
+
const op = ops[i++];
|
|
312
|
+
const n =
|
|
313
|
+
op === VOP_MOVE || op === VOP_LINE
|
|
314
|
+
? 2
|
|
315
|
+
: op === VOP_QUAD
|
|
316
|
+
? 4
|
|
317
|
+
: op === VOP_CUBIC
|
|
318
|
+
? 6
|
|
319
|
+
: 0;
|
|
320
|
+
for (let k = 0; k < n; k += 2) {
|
|
321
|
+
const x = ops[i + k];
|
|
322
|
+
const y = ops[i + k + 1];
|
|
323
|
+
if (x < minx) minx = x;
|
|
324
|
+
if (x > maxx) maxx = x;
|
|
325
|
+
if (y < miny) miny = y;
|
|
326
|
+
if (y > maxy) maxy = y;
|
|
327
|
+
}
|
|
328
|
+
i += n;
|
|
329
|
+
}
|
|
330
|
+
if (!Number.isFinite(minx)) return {x: 0, y: 0, w: 0, h: 0};
|
|
331
|
+
return {x: minx, y: miny, w: maxx - minx, h: maxy - miny};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Bakes a gradient definition into an engine gradient descriptor in the path's BAKED coordinate space.
|
|
336
|
+
* objectBoundingBox units (the SVG default) map [0,1] onto the shape's local bbox; userSpaceOnUse uses the
|
|
337
|
+
* coords as-is. Both the gradientTransform and the shape's cumulative matrix are applied. Linear gradients
|
|
338
|
+
* are exact under any affine transform (the endpoints map directly). The engine's radial is CIRCULAR, so an
|
|
339
|
+
* elliptical radial — from a non-square objectBoundingBox or a non-uniform/sheared transform — is reduced to
|
|
340
|
+
* its best-fit circle: the radius is the geometric mean of the two axis radii (radii multiply by sqrt(|det|)
|
|
341
|
+
* under a transform, and a non-square OBB contributes sqrt(w*h)). Conic likewise keeps only the rotation.
|
|
342
|
+
*/
|
|
343
|
+
function bakeGradient(def, cm, bbox) {
|
|
344
|
+
const a = def.attrs;
|
|
345
|
+
const obb = (a.gradientUnits || 'objectBoundingBox') !== 'userSpaceOnUse';
|
|
346
|
+
const gt = parseTransform(a.gradientTransform);
|
|
347
|
+
const toBaked = (gx, gy) => {
|
|
348
|
+
const ux = obb ? bbox.x + gx * bbox.w : gx;
|
|
349
|
+
const uy = obb ? bbox.y + gy * bbox.h : gy;
|
|
350
|
+
const [tx, ty] = pt(gt, ux, uy);
|
|
351
|
+
return pt(cm, tx, ty);
|
|
352
|
+
};
|
|
353
|
+
if (def.name === 'radialGradient') {
|
|
354
|
+
const [bcx, bcy] = toBaked(gradCoord(a.cx, 0.5), gradCoord(a.cy, 0.5));
|
|
355
|
+
// objectBoundingBox radius r=0.5 spans half the box; for a non-square box the geometric mean sqrt(w*h)
|
|
356
|
+
// is the best-fit circle (vs the diagonal, which oversizes a wide/tall box).
|
|
357
|
+
const rUser = obb
|
|
358
|
+
? gradCoord(a.r, 0.5) * Math.sqrt(bbox.w * bbox.h)
|
|
359
|
+
: gradCoord(a.r, 0.5);
|
|
360
|
+
return {
|
|
361
|
+
type: GRAD_RADIAL,
|
|
362
|
+
stops: def.stops,
|
|
363
|
+
ax: bcx,
|
|
364
|
+
ay: bcy,
|
|
365
|
+
bx: 0,
|
|
366
|
+
by: 0,
|
|
367
|
+
r: rUser * scaleOf(cm) * scaleOf(gt),
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
if (def.name === 'conicGradient') {
|
|
371
|
+
const [bcx, bcy] = toBaked(gradCoord(a.cx, 0.5), gradCoord(a.cy, 0.5));
|
|
372
|
+
// `from` (degrees, clockwise from the top, CSS conic-gradient convention) -> radians, plus the rotation
|
|
373
|
+
// baked into the gradient + path transforms so the sweep orients correctly in the final coordinate space.
|
|
374
|
+
const rot = Math.atan2(cm[1], cm[0]) + Math.atan2(gt[1], gt[0]);
|
|
375
|
+
return {
|
|
376
|
+
type: GRAD_CONIC,
|
|
377
|
+
stops: def.stops,
|
|
378
|
+
ax: bcx,
|
|
379
|
+
ay: bcy,
|
|
380
|
+
bx: 0,
|
|
381
|
+
by: 0,
|
|
382
|
+
r: (num(a.from, 0) * Math.PI) / 180 + rot,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
const [ax, ay] = toBaked(gradCoord(a.x1, 0), gradCoord(a.y1, 0));
|
|
386
|
+
const [bx, by] = toBaked(gradCoord(a.x2, 1), gradCoord(a.y2, 0));
|
|
387
|
+
return {type: GRAD_LINEAR, stops: def.stops, ax, ay, bx, by, r: 0};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Compiles an SVG document string into the engine vector op-tape.
|
|
392
|
+
*
|
|
393
|
+
* @param {string} svgString The full SVG file contents.
|
|
394
|
+
* @returns {Promise<{ops:number[], paints:number[], width:number, height:number}>} op-tape + intrinsic
|
|
395
|
+
* size (px). `ops`/`paints` are plain arrays ready to inline into the bundle; `width`/`height`
|
|
396
|
+
* are the SVG's rendered size for layout (a <Svg source> scales the tape to its box).
|
|
397
|
+
*/
|
|
398
|
+
export async function svgToVector(svgString) {
|
|
399
|
+
const root = await parse(String(svgString));
|
|
400
|
+
const a = root.attributes || {};
|
|
401
|
+
|
|
402
|
+
let vb = String(a.viewBox || '')
|
|
403
|
+
.trim()
|
|
404
|
+
.split(/[\s,]+/)
|
|
405
|
+
.map(Number);
|
|
406
|
+
if (vb.length !== 4 || vb.some(n => Number.isNaN(n)))
|
|
407
|
+
vb = [0, 0, num(a.width, 100), num(a.height, 100)];
|
|
408
|
+
const [vx, vy, vw, vh] = vb;
|
|
409
|
+
const width = num(a.width, vw);
|
|
410
|
+
const height = num(a.height, vh);
|
|
411
|
+
|
|
412
|
+
// Root transform: map the viewBox user-units onto the intrinsic px box (like the inline <Svg> path).
|
|
413
|
+
const sx = vw ? width / vw : 1;
|
|
414
|
+
const sy = vh ? height / vh : 1;
|
|
415
|
+
const rootM = mul(
|
|
416
|
+
[sx, 0, 0, sy, -vx * sx, -vy * sy],
|
|
417
|
+
parseTransform(a.transform),
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
const ops = [];
|
|
421
|
+
const paints = [];
|
|
422
|
+
const gradients = [];
|
|
423
|
+
const gradDefs = collectGradients(root);
|
|
424
|
+
|
|
425
|
+
// Elements we intentionally skip without losing visible content (gradient defs are collected separately,
|
|
426
|
+
// the rest are metadata). Anything else hitting the skip branch — text/image/use/mask/filter/pattern/… —
|
|
427
|
+
// is unsupported VISUAL content, recorded in `dropped` so the loader can warn + fall back to raster.
|
|
428
|
+
const SVG_IGNORABLE = new Set([
|
|
429
|
+
'defs',
|
|
430
|
+
'title',
|
|
431
|
+
'desc',
|
|
432
|
+
'metadata',
|
|
433
|
+
'linearGradient',
|
|
434
|
+
'radialGradient',
|
|
435
|
+
'conicGradient',
|
|
436
|
+
]);
|
|
437
|
+
const dropped = new Set();
|
|
438
|
+
|
|
439
|
+
const walk = (node, m, paint) => {
|
|
440
|
+
for (const child of node.children || []) {
|
|
441
|
+
if (child.type !== 'element') continue;
|
|
442
|
+
const attrs = child.attributes || {};
|
|
443
|
+
const cm = mul(m, parseTransform(attrs.transform));
|
|
444
|
+
const cp = resolvePaint(attrs, paint);
|
|
445
|
+
|
|
446
|
+
if (child.name === 'g' || child.name === 'svg') {
|
|
447
|
+
walk(child, cm, cp);
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
let d = '';
|
|
452
|
+
if (child.name === 'path') d = attrs.d || '';
|
|
453
|
+
else if (child.name === 'rect') d = rectPath(attrs);
|
|
454
|
+
else if (child.name === 'circle') d = circlePath(attrs);
|
|
455
|
+
else if (child.name === 'ellipse') d = ellipsePath(attrs);
|
|
456
|
+
else if (child.name === 'line') d = linePath(attrs);
|
|
457
|
+
else if (child.name === 'polygon') d = polyPath(attrs, true);
|
|
458
|
+
else if (child.name === 'polyline') d = polyPath(attrs, false);
|
|
459
|
+
else {
|
|
460
|
+
if (!SVG_IGNORABLE.has(child.name)) dropped.add(child.name); // unsupported visual element → raster
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
if (!d) continue;
|
|
464
|
+
|
|
465
|
+
// A filter/mask/clip applied to an otherwise-bakeable shape still loses fidelity as a vector → flag it.
|
|
466
|
+
for (const fk of ['filter', 'mask', 'clip-path']) {
|
|
467
|
+
if (attrs[fk] && attrs[fk] !== 'none') dropped.add(fk);
|
|
468
|
+
}
|
|
469
|
+
// A dashed stroke can't be represented in the op-tape (the stroke is baked solid). Flag it so the SVG
|
|
470
|
+
// falls back to raster (resvg renders the dashes) — but only when a visible stroke is actually dashed
|
|
471
|
+
// by a positive pattern, so a stray stroke-dasharray on an unstroked/zero-width shape isn't penalized.
|
|
472
|
+
const dash = cp['stroke-dasharray'];
|
|
473
|
+
const stroked =
|
|
474
|
+
cp.stroke && cp.stroke !== 'none' && num(cp['stroke-width'], 1) > 0;
|
|
475
|
+
if (stroked && dash && dash !== 'none' && /[1-9]/.test(dash))
|
|
476
|
+
dropped.add('stroke-dasharray');
|
|
477
|
+
|
|
478
|
+
const shapeOps = parsePath(d);
|
|
479
|
+
if (!shapeOps.length) continue;
|
|
480
|
+
|
|
481
|
+
const paintIndex = paints.length / PAINT_STRIDE;
|
|
482
|
+
const op = clamp01(num(cp.opacity, 1));
|
|
483
|
+
// A url(#id) fill/stroke referencing a parsed gradient bakes to a 1-based gradient-table index; the
|
|
484
|
+
// solid color stays 0 (colorOf returns 0 for url()). The shape's bbox is computed once, lazily.
|
|
485
|
+
let bbox = null;
|
|
486
|
+
const refGrad = ref => {
|
|
487
|
+
if (!ref) return 0;
|
|
488
|
+
const def = gradDefs.get(ref);
|
|
489
|
+
if (!def || def.stops.length < 2) return 0;
|
|
490
|
+
if (!bbox) bbox = opsBBox(shapeOps);
|
|
491
|
+
gradients.push(bakeGradient(def, cm, bbox));
|
|
492
|
+
return gradients.length; // 1-based
|
|
493
|
+
};
|
|
494
|
+
const fillGrad = refGrad(urlRef(cp.fill));
|
|
495
|
+
const strokeGrad = refGrad(urlRef(cp.stroke));
|
|
496
|
+
ops.push(VOP_SHAPE, paintIndex, ...transformOps(shapeOps, cm));
|
|
497
|
+
paints.push(
|
|
498
|
+
applyAlpha(
|
|
499
|
+
colorOf(cp.fill ?? 'black'),
|
|
500
|
+
op * clamp01(num(cp['fill-opacity'], 1)),
|
|
501
|
+
),
|
|
502
|
+
applyAlpha(
|
|
503
|
+
colorOf(cp.stroke ?? 'none'),
|
|
504
|
+
op * clamp01(num(cp['stroke-opacity'], 1)),
|
|
505
|
+
),
|
|
506
|
+
num(cp['stroke-width'], 1) * scaleOf(cm),
|
|
507
|
+
num(cp['stroke-miterlimit'], 4),
|
|
508
|
+
CAP[cp['stroke-linecap']] ?? 0,
|
|
509
|
+
JOIN[cp['stroke-linejoin']] ?? 0,
|
|
510
|
+
cp['fill-rule'] === 'evenodd' ? 1 : 0,
|
|
511
|
+
fillGrad,
|
|
512
|
+
strokeGrad,
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
walk(root, rootM, DEFAULT_PAINT);
|
|
518
|
+
return {ops, paints, gradients, width, height, dropped: [...dropped].sort()};
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Rasterizes an SVG to an RGBA PNG at its intrinsic size — the raster fallback for SVGs that use features
|
|
523
|
+
* the vector baker can't represent (text, masks, filters, patterns, …). resvg is
|
|
524
|
+
* imported lazily, so vector-only builds never load it. The PNG flows through the normal image pipeline
|
|
525
|
+
* (bake-image → asset pack), and a <Svg source> whose artifact is {kind:'raster'} renders it as an image.
|
|
526
|
+
*
|
|
527
|
+
* @param {string} svgString The SVG document.
|
|
528
|
+
* @returns {Promise<{width:number, height:number, png:Buffer}>} Rendered size and PNG bytes.
|
|
529
|
+
*/
|
|
530
|
+
export async function svgToRaster(svgString) {
|
|
531
|
+
const {Resvg} = await import('@resvg/resvg-js');
|
|
532
|
+
// resvg (usvg) requires the SVG namespace; svgson (the vector baker) is lenient and many hand-authored
|
|
533
|
+
// SVGs omit it, so inject it when absent or resvg rejects the document ("does not have a root node").
|
|
534
|
+
let s = String(svgString);
|
|
535
|
+
if (!/<svg\b[^>]*\sxmlns\s*=/i.test(s))
|
|
536
|
+
s = s.replace(/<svg\b/i, '<svg xmlns="http://www.w3.org/2000/svg"');
|
|
537
|
+
const r = new Resvg(s, {background: 'rgba(0,0,0,0)'}); // transparent so it composites
|
|
538
|
+
const img = r.render();
|
|
539
|
+
const png = img.asPng();
|
|
540
|
+
const {width, height} = img;
|
|
541
|
+
img.free?.();
|
|
542
|
+
return {width, height, png};
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Writes a rasterized-SVG PNG to a temp file and returns its path (content-hashed so rebuilds are stable).
|
|
547
|
+
* The image pipeline (bakeImage) reads PNGs by path, so the raster fallback writes one here and registers it
|
|
548
|
+
* as an asset. Shared by the Flow A loader and the Flow B (AOT) baker.
|
|
549
|
+
*
|
|
550
|
+
* @param {string} name Asset name (the SVG's basename) — used in the filename.
|
|
551
|
+
* @param {Buffer} png PNG bytes from svgToRaster.
|
|
552
|
+
* @returns {string} Absolute path to the written .png.
|
|
553
|
+
*/
|
|
554
|
+
export function writeRasterPng(name, png) {
|
|
555
|
+
const dir = join(tmpdir(), 'embedded-react-svg-raster');
|
|
556
|
+
mkdirSync(dir, {recursive: true});
|
|
557
|
+
const file = join(
|
|
558
|
+
dir,
|
|
559
|
+
`${name}-${createHash('sha1').update(png).digest('hex').slice(0, 8)}.png`,
|
|
560
|
+
);
|
|
561
|
+
writeFileSync(file, png);
|
|
562
|
+
return file;
|
|
563
|
+
}
|
|
@@ -23,11 +23,11 @@
|
|
|
23
23
|
//
|
|
24
24
|
// Regenerating changes glyph metrics slightly vs a prior rasterizer, so re-run the engine text tests
|
|
25
25
|
// (test_text, yoga_parity) and re-flash to eyeball on-device after changing it.
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
26
|
+
import {resolve, dirname} from 'node:path';
|
|
27
|
+
import {fileURLToPath} from 'node:url';
|
|
28
|
+
import {writeFileSync} from 'node:fs';
|
|
29
|
+
import {bakeFont} from './bake-font.mjs';
|
|
30
|
+
import {emitBuiltinFont} from './emit-c.mjs';
|
|
31
31
|
|
|
32
32
|
const here = dirname(fileURLToPath(import.meta.url)); // bridges/quickjs/js/assets
|
|
33
33
|
const repoRoot = resolve(here, '../../../..');
|
|
@@ -38,14 +38,27 @@ const OUT = resolve(repoRoot, 'engine/font/font_data.c');
|
|
|
38
38
|
// punctuation, etc.) the UI components rely on — kept stable across regenerations.
|
|
39
39
|
const SIZES = [10, 12, 16, 20, 24, 32, 48];
|
|
40
40
|
const EXTRAS = [
|
|
41
|
-
0x00a2, 0x00a3, 0x00a5, 0x00a7, 0x00a9, 0x00ae, 0x00b0, 0x00b1, 0x00b5,
|
|
42
|
-
0x2014, 0x2018, 0x2019, 0x201c, 0x201d, 0x2020,
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
0x00a2, 0x00a3, 0x00a5, 0x00a7, 0x00a9, 0x00ae, 0x00b0, 0x00b1, 0x00b5,
|
|
42
|
+
0x00d7, 0x00f7, 0x2013, 0x2014, 0x2018, 0x2019, 0x201c, 0x201d, 0x2020,
|
|
43
|
+
0x2021, 0x2022, 0x2026, 0x2030, 0x20ac, 0x2122, 0x2190, 0x2191, 0x2192,
|
|
44
|
+
0x2193, 0x2194, 0x21b5, 0x2202, 0x2206, 0x2211, 0x2212, 0x221a, 0x221e,
|
|
45
|
+
0x2248, 0x2260, 0x2264, 0x2265, 0x25a0, 0x25c6, 0x25cf, 0x2605, 0x2606,
|
|
46
|
+
0x2713, 0x2717,
|
|
45
47
|
];
|
|
46
48
|
|
|
47
|
-
const font = bakeFont({
|
|
48
|
-
|
|
49
|
+
const font = bakeFont({
|
|
50
|
+
path: FONT,
|
|
51
|
+
family: 'Inter',
|
|
52
|
+
sizes: SIZES,
|
|
53
|
+
bpp: 4,
|
|
54
|
+
glyphs: EXTRAS,
|
|
55
|
+
});
|
|
56
|
+
writeFileSync(
|
|
57
|
+
OUT,
|
|
58
|
+
emitBuiltinFont({font, symbol: 'inter', sourceName: 'Inter-Regular.ttf'}),
|
|
59
|
+
);
|
|
49
60
|
|
|
50
61
|
const bytes = font.sizes.reduce((n, s) => n + s.bitmap.length, 0);
|
|
51
|
-
console.log(
|
|
62
|
+
console.log(
|
|
63
|
+
`Regenerated ${OUT}\n ${SIZES.length} sizes [${SIZES.join(',')}], bpp 4, ${font.sizes[0].extras.length} extra glyphs, ${bytes} bitmap bytes`,
|
|
64
|
+
);
|