embedded-react 0.3.0 → 0.4.1
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 +134 -52
- package/pack-container.mjs +84 -35
- package/package.json +8 -3
- package/persist-transform.mjs +23 -9
- package/qjsc-wasm.mjs +19 -8
- 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
package/aot/screenshot-smoke.mjs
CHANGED
|
@@ -25,24 +25,29 @@
|
|
|
25
25
|
// Prereq: the linux-aot CMake build must be configurable (SDL2 found). It reuses examples/linux-aot/build;
|
|
26
26
|
// if that isn't configured yet, set CMAKE_TOOLCHAIN_FILE (e.g. a vcpkg toolchain) and it will configure it.
|
|
27
27
|
// Needs a display (SDL video); on a headless box run under a virtual framebuffer.
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
28
|
+
import {execFileSync} from 'node:child_process';
|
|
29
|
+
import {existsSync, mkdirSync, readFileSync, rmSync} from 'node:fs';
|
|
30
|
+
import {resolve, dirname} from 'node:path';
|
|
31
|
+
import {fileURLToPath} from 'node:url';
|
|
32
32
|
|
|
33
33
|
const here = dirname(fileURLToPath(import.meta.url)); // bridges/quickjs/js/aot
|
|
34
34
|
const jsDir = resolve(here, '..'); // bridges/quickjs/js
|
|
35
35
|
const repoRoot = resolve(here, '../../../..');
|
|
36
36
|
const exampleDir = resolve(repoRoot, 'examples/linux-aot');
|
|
37
37
|
const buildDir = resolve(exampleDir, 'build');
|
|
38
|
-
const exe = resolve(
|
|
38
|
+
const exe = resolve(
|
|
39
|
+
buildDir,
|
|
40
|
+
process.platform === 'win32'
|
|
41
|
+
? 'embedded-react-desktop-aot.exe'
|
|
42
|
+
: 'embedded-react-desktop-aot',
|
|
43
|
+
);
|
|
39
44
|
const tmpDir = resolve(jsDir, 'dist', '.smoke');
|
|
40
45
|
|
|
41
46
|
// Demos to smoke-test. `screen` (optional) sets ER_AOT_SCREEN_W/H so the responsive thermostat folds to its
|
|
42
47
|
// compact (AOT-compilable) branch. `minColors` is the floor of distinct sampled colours for "rendered".
|
|
43
48
|
const DEMOS = [
|
|
44
|
-
{
|
|
45
|
-
{
|
|
49
|
+
{name: 'music-player', minColors: 40},
|
|
50
|
+
{name: 'thermostat', screen: {w: 240, h: 320}, minColors: 40},
|
|
46
51
|
];
|
|
47
52
|
|
|
48
53
|
/** Counts distinct colours in an uncompressed 24/32-bpp BMP (sampled) — a quick "is anything drawn?" signal. */
|
|
@@ -67,38 +72,46 @@ function bmpDistinctColors(path, step = 4) {
|
|
|
67
72
|
}
|
|
68
73
|
|
|
69
74
|
function run(cmd, args, opts = {}) {
|
|
70
|
-
execFileSync(cmd, args, {
|
|
75
|
+
execFileSync(cmd, args, {stdio: 'pipe', ...opts});
|
|
71
76
|
}
|
|
72
77
|
|
|
73
78
|
function ensureConfigured() {
|
|
74
79
|
if (existsSync(resolve(buildDir, 'CMakeCache.txt'))) return;
|
|
75
80
|
console.log('• configuring linux-aot build (first run)…');
|
|
76
81
|
const args = ['-S', exampleDir, '-B', buildDir];
|
|
77
|
-
if (process.env.CMAKE_TOOLCHAIN_FILE)
|
|
82
|
+
if (process.env.CMAKE_TOOLCHAIN_FILE)
|
|
83
|
+
args.push(`-DCMAKE_TOOLCHAIN_FILE=${process.env.CMAKE_TOOLCHAIN_FILE}`);
|
|
78
84
|
if (process.platform === 'win32') args.push('-G', 'MinGW Makefiles');
|
|
79
85
|
run('cmake', args);
|
|
80
86
|
}
|
|
81
87
|
|
|
82
88
|
let failures = 0;
|
|
83
|
-
mkdirSync(tmpDir, {
|
|
89
|
+
mkdirSync(tmpDir, {recursive: true});
|
|
84
90
|
ensureConfigured();
|
|
85
91
|
|
|
86
92
|
for (const demo of DEMOS) {
|
|
87
93
|
const shot = resolve(tmpDir, `${demo.name}.bmp`);
|
|
88
94
|
try {
|
|
89
|
-
rmSync(shot, {
|
|
90
|
-
const genEnv = {
|
|
95
|
+
rmSync(shot, {force: true});
|
|
96
|
+
const genEnv = {...process.env};
|
|
91
97
|
if (demo.screen) {
|
|
92
98
|
genEnv.ER_AOT_SCREEN_W = String(demo.screen.w);
|
|
93
99
|
genEnv.ER_AOT_SCREEN_H = String(demo.screen.h);
|
|
94
100
|
}
|
|
95
|
-
run('node', [resolve(here, 'compile.mjs'), demo.name], {
|
|
101
|
+
run('node', [resolve(here, 'compile.mjs'), demo.name], {
|
|
102
|
+
cwd: jsDir,
|
|
103
|
+
env: genEnv,
|
|
104
|
+
}); // → dist/app.gen.{c,h}
|
|
96
105
|
run('cmake', ['--build', buildDir]); // relink the generated C
|
|
97
|
-
run(exe, [], {
|
|
106
|
+
run(exe, [], {cwd: buildDir, env: {...process.env, ER_AOT_SHOT: shot}}); // render one frame → BMP
|
|
98
107
|
|
|
99
|
-
if (!existsSync(shot))
|
|
108
|
+
if (!existsSync(shot))
|
|
109
|
+
throw new Error('no screenshot written (host crashed before present?)');
|
|
100
110
|
const colors = bmpDistinctColors(shot);
|
|
101
|
-
if (colors < demo.minColors)
|
|
111
|
+
if (colors < demo.minColors)
|
|
112
|
+
throw new Error(
|
|
113
|
+
`screenshot looks blank (${colors} distinct colours < ${demo.minColors})`,
|
|
114
|
+
);
|
|
102
115
|
console.log(`✓ ${demo.name}: rendered (${colors} distinct colours)`);
|
|
103
116
|
} catch (e) {
|
|
104
117
|
failures++;
|
|
@@ -106,5 +119,9 @@ for (const demo of DEMOS) {
|
|
|
106
119
|
}
|
|
107
120
|
}
|
|
108
121
|
|
|
109
|
-
console.log(
|
|
122
|
+
console.log(
|
|
123
|
+
failures
|
|
124
|
+
? `\n${failures} demo(s) failed the smoke test.`
|
|
125
|
+
: `\nAll ${DEMOS.length} demos compiled, built, and rendered.`,
|
|
126
|
+
);
|
|
110
127
|
process.exit(failures ? 1 : 0);
|
package/aot/style-map.mjs
CHANGED
|
@@ -42,13 +42,21 @@ const NAMED_COLORS = {
|
|
|
42
42
|
* @returns {number} ARGB8888 as an unsigned 32-bit integer.
|
|
43
43
|
*/
|
|
44
44
|
export function parseColorValue(s) {
|
|
45
|
-
if (typeof s !== 'string')
|
|
45
|
+
if (typeof s !== 'string')
|
|
46
|
+
throw new Error(`color must be a string, got ${typeof s}`);
|
|
46
47
|
const key = s.trim().toLowerCase();
|
|
47
48
|
if (key in NAMED_COLORS) return NAMED_COLORS[key] >>> 0;
|
|
48
49
|
const m = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec(key);
|
|
49
|
-
if (!m)
|
|
50
|
+
if (!m)
|
|
51
|
+
throw new Error(
|
|
52
|
+
`unsupported color "${s}" (use #rgb / #rrggbb / #rrggbbaa or a named color)`,
|
|
53
|
+
);
|
|
50
54
|
let hex = m[1];
|
|
51
|
-
if (hex.length === 3)
|
|
55
|
+
if (hex.length === 3)
|
|
56
|
+
hex = hex
|
|
57
|
+
.split('')
|
|
58
|
+
.map(c => c + c)
|
|
59
|
+
.join(''); // #rgb → #rrggbb
|
|
52
60
|
let r, g, b, a;
|
|
53
61
|
if (hex.length === 6) {
|
|
54
62
|
a = 0xff;
|
|
@@ -62,7 +70,7 @@ export function parseColorValue(s) {
|
|
|
62
70
|
b = parseInt(hex.slice(4, 6), 16);
|
|
63
71
|
a = parseInt(hex.slice(6, 8), 16);
|
|
64
72
|
}
|
|
65
|
-
return ((
|
|
73
|
+
return ((a << 24) | (r << 16) | (g << 8) | b) >>> 0;
|
|
66
74
|
}
|
|
67
75
|
|
|
68
76
|
/** Formats an ARGB int as a C unsigned hex literal, e.g. 0xFF0F172Au. */
|
|
@@ -91,15 +99,19 @@ const FLEX_DIRECTION = {
|
|
|
91
99
|
'row-reverse': 'ER_FLEX_ROW_REVERSE',
|
|
92
100
|
'column-reverse': 'ER_FLEX_COL_REVERSE',
|
|
93
101
|
};
|
|
94
|
-
const POSITION = {
|
|
102
|
+
const POSITION = {relative: 'ER_POS_RELATIVE', absolute: 'ER_POS_ABSOLUTE'};
|
|
95
103
|
|
|
96
|
-
const enumKey = (table, name) =>
|
|
104
|
+
const enumKey = (table, name) => v => {
|
|
97
105
|
const c = table[v];
|
|
98
|
-
if (!c)
|
|
106
|
+
if (!c)
|
|
107
|
+
throw new Error(
|
|
108
|
+
`${name}: unsupported value "${v}" (one of ${Object.keys(table).join(', ')})`,
|
|
109
|
+
);
|
|
99
110
|
return c;
|
|
100
111
|
};
|
|
101
|
-
const dim =
|
|
102
|
-
if (typeof v !== 'number' || !Number.isFinite(v))
|
|
112
|
+
const dim = v => {
|
|
113
|
+
if (typeof v !== 'number' || !Number.isFinite(v))
|
|
114
|
+
throw new Error(`expected a numeric dimension, got ${JSON.stringify(v)}`);
|
|
103
115
|
return String(Math.round(v));
|
|
104
116
|
};
|
|
105
117
|
|
|
@@ -110,15 +122,18 @@ const dim = (v) => {
|
|
|
110
122
|
* @param {string} pxField ERProps pixel field (e.g. 'width').
|
|
111
123
|
* @param {string} pctField ERProps percentage field (e.g. 'width_pct').
|
|
112
124
|
*/
|
|
113
|
-
const pctOrPx = (pxField, pctField) =>
|
|
125
|
+
const pctOrPx = (pxField, pctField) => v => {
|
|
114
126
|
if (typeof v === 'string' && v.trim().endsWith('%')) {
|
|
115
127
|
const n = parseFloat(v);
|
|
116
|
-
if (!Number.isFinite(n))
|
|
128
|
+
if (!Number.isFinite(n))
|
|
129
|
+
throw new Error(
|
|
130
|
+
`expected a percentage like '50%', got ${JSON.stringify(v)}`,
|
|
131
|
+
);
|
|
117
132
|
// A valid C float literal needs a decimal point — `50f` is a syntax error, `50.0f` is not.
|
|
118
133
|
const lit = Number.isInteger(n) ? `${n}.0f` : `${n}f`;
|
|
119
|
-
return [{
|
|
134
|
+
return [{field: pctField, expr: lit}];
|
|
120
135
|
}
|
|
121
|
-
return [{
|
|
136
|
+
return [{field: pxField, expr: dim(v)}];
|
|
122
137
|
};
|
|
123
138
|
|
|
124
139
|
/**
|
|
@@ -130,61 +145,89 @@ const KEYS = {
|
|
|
130
145
|
width: pctOrPx('width', 'width_pct'),
|
|
131
146
|
height: pctOrPx('height', 'height_pct'),
|
|
132
147
|
flexBasis: pctOrPx('flex_basis', 'flex_basis_pct'),
|
|
133
|
-
minWidth:
|
|
134
|
-
maxWidth:
|
|
135
|
-
minHeight:
|
|
136
|
-
maxHeight:
|
|
137
|
-
padding:
|
|
138
|
-
paddingHorizontal:
|
|
139
|
-
paddingVertical:
|
|
140
|
-
paddingLeft:
|
|
141
|
-
paddingTop:
|
|
142
|
-
paddingRight:
|
|
143
|
-
paddingBottom:
|
|
144
|
-
margin:
|
|
145
|
-
marginHorizontal:
|
|
146
|
-
marginVertical:
|
|
147
|
-
marginLeft:
|
|
148
|
-
marginTop:
|
|
149
|
-
marginRight:
|
|
150
|
-
marginBottom:
|
|
151
|
-
gap:
|
|
152
|
-
rowGap:
|
|
153
|
-
columnGap:
|
|
154
|
-
flexGrow:
|
|
155
|
-
flexShrink:
|
|
156
|
-
flexDirection:
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
148
|
+
minWidth: v => [{field: 'min_width', expr: dim(v)}],
|
|
149
|
+
maxWidth: v => [{field: 'max_width', expr: dim(v)}],
|
|
150
|
+
minHeight: v => [{field: 'min_height', expr: dim(v)}],
|
|
151
|
+
maxHeight: v => [{field: 'max_height', expr: dim(v)}],
|
|
152
|
+
padding: v => [{field: 'padding', expr: dim(v)}],
|
|
153
|
+
paddingHorizontal: v => [{field: 'padding_horizontal', expr: dim(v)}],
|
|
154
|
+
paddingVertical: v => [{field: 'padding_vertical', expr: dim(v)}],
|
|
155
|
+
paddingLeft: v => [{field: 'padding_left', expr: dim(v)}],
|
|
156
|
+
paddingTop: v => [{field: 'padding_top', expr: dim(v)}],
|
|
157
|
+
paddingRight: v => [{field: 'padding_right', expr: dim(v)}],
|
|
158
|
+
paddingBottom: v => [{field: 'padding_bottom', expr: dim(v)}],
|
|
159
|
+
margin: v => [{field: 'margin', expr: dim(v)}],
|
|
160
|
+
marginHorizontal: v => [{field: 'margin_horizontal', expr: dim(v)}],
|
|
161
|
+
marginVertical: v => [{field: 'margin_vertical', expr: dim(v)}],
|
|
162
|
+
marginLeft: v => [{field: 'margin_left', expr: dim(v)}],
|
|
163
|
+
marginTop: v => [{field: 'margin_top', expr: dim(v)}],
|
|
164
|
+
marginRight: v => [{field: 'margin_right', expr: dim(v)}],
|
|
165
|
+
marginBottom: v => [{field: 'margin_bottom', expr: dim(v)}],
|
|
166
|
+
gap: v => [{field: 'gap', expr: dim(v)}],
|
|
167
|
+
rowGap: v => [{field: 'row_gap', expr: dim(v)}],
|
|
168
|
+
columnGap: v => [{field: 'column_gap', expr: dim(v)}],
|
|
169
|
+
flexGrow: v => [{field: 'flex_grow', expr: dim(v)}],
|
|
170
|
+
flexShrink: v => [{field: 'flex_shrink', expr: dim(v)}],
|
|
171
|
+
flexDirection: v => [
|
|
172
|
+
{
|
|
173
|
+
field: 'flex_direction',
|
|
174
|
+
expr: enumKey(FLEX_DIRECTION, 'flexDirection')(v),
|
|
175
|
+
},
|
|
176
|
+
],
|
|
177
|
+
alignItems: v => [
|
|
178
|
+
{field: 'align_items', expr: enumKey(ALIGN, 'alignItems')(v)},
|
|
179
|
+
],
|
|
180
|
+
alignSelf: v => [{field: 'align_self', expr: enumKey(ALIGN, 'alignSelf')(v)}],
|
|
181
|
+
justifyContent: v => [
|
|
182
|
+
{field: 'justify_content', expr: enumKey(JUSTIFY, 'justifyContent')(v)},
|
|
183
|
+
],
|
|
160
184
|
// Positioning: `position: 'absolute'` takes a node out of flow; left/top/right/bottom are its anchors.
|
|
161
|
-
position:
|
|
162
|
-
left:
|
|
163
|
-
top:
|
|
164
|
-
right:
|
|
165
|
-
bottom:
|
|
185
|
+
position: v => [{field: 'position', expr: enumKey(POSITION, 'position')(v)}],
|
|
186
|
+
left: v => [{field: 'left', expr: dim(v)}],
|
|
187
|
+
top: v => [{field: 'top', expr: dim(v)}],
|
|
188
|
+
right: v => [{field: 'right', expr: dim(v)}],
|
|
189
|
+
bottom: v => [{field: 'bottom', expr: dim(v)}],
|
|
166
190
|
// `flex: n` → grow=n, shrink=1, basis=0 (RN semantics; matches native_ui_bridge apply_flex).
|
|
167
|
-
flex:
|
|
191
|
+
flex: v => {
|
|
168
192
|
const n = Number(v);
|
|
169
|
-
if (n > 0)
|
|
170
|
-
|
|
171
|
-
|
|
193
|
+
if (n > 0)
|
|
194
|
+
return [
|
|
195
|
+
{field: 'flex_grow', expr: dim(n)},
|
|
196
|
+
{field: 'flex_shrink', expr: '1'},
|
|
197
|
+
{field: 'flex_basis', expr: '0'},
|
|
198
|
+
];
|
|
199
|
+
if (n === 0)
|
|
200
|
+
return [
|
|
201
|
+
{field: 'flex_grow', expr: '0'},
|
|
202
|
+
{field: 'flex_shrink', expr: '0'},
|
|
203
|
+
];
|
|
204
|
+
return [
|
|
205
|
+
{field: 'flex_grow', expr: '0'},
|
|
206
|
+
{field: 'flex_shrink', expr: '1'},
|
|
207
|
+
];
|
|
172
208
|
},
|
|
173
209
|
|
|
174
210
|
// View visual
|
|
175
|
-
backgroundColor:
|
|
176
|
-
borderRadius:
|
|
177
|
-
borderWidth:
|
|
178
|
-
borderColor:
|
|
179
|
-
opacity:
|
|
180
|
-
|
|
211
|
+
backgroundColor: v => [{field: 'background_color', expr: colorLiteral(v)}],
|
|
212
|
+
borderRadius: v => [{field: 'border_radius', expr: dim(v)}],
|
|
213
|
+
borderWidth: v => [{field: 'border_width', expr: dim(v)}],
|
|
214
|
+
borderColor: v => [{field: 'border_color', expr: colorLiteral(v)}],
|
|
215
|
+
opacity: v => [
|
|
216
|
+
{
|
|
217
|
+
field: 'opacity',
|
|
218
|
+
expr: String(Math.round(Math.max(0, Math.min(1, Number(v))) * 255)),
|
|
219
|
+
},
|
|
220
|
+
],
|
|
221
|
+
zIndex: v => [{field: 'z_index', expr: dim(v)}],
|
|
181
222
|
|
|
182
223
|
// Text
|
|
183
|
-
color:
|
|
184
|
-
fontSize:
|
|
185
|
-
fontWeight:
|
|
186
|
-
|
|
187
|
-
|
|
224
|
+
color: v => [{field: 'color', expr: colorLiteral(v)}],
|
|
225
|
+
fontSize: v => [{field: 'font_size', expr: dim(v)}],
|
|
226
|
+
fontWeight: v => [
|
|
227
|
+
{field: 'font_weight', expr: v === 'bold' || Number(v) >= 600 ? '1' : '0'},
|
|
228
|
+
],
|
|
229
|
+
lineHeight: v => [{field: 'line_height', expr: dim(v)}],
|
|
230
|
+
letterSpacing: v => [{field: 'letter_spacing', expr: dim(v)}],
|
|
188
231
|
};
|
|
189
232
|
|
|
190
233
|
/**
|
|
@@ -198,7 +241,10 @@ export function lowerStyle(style) {
|
|
|
198
241
|
for (const [key, value] of Object.entries(style)) {
|
|
199
242
|
if (value === undefined || value === null) continue;
|
|
200
243
|
const fn = KEYS[key];
|
|
201
|
-
if (!fn)
|
|
244
|
+
if (!fn)
|
|
245
|
+
throw new Error(
|
|
246
|
+
`AOT: unsupported style key "${key}" (not yet lowered to ERProps)`,
|
|
247
|
+
);
|
|
202
248
|
out.push(...fn(value));
|
|
203
249
|
}
|
|
204
250
|
return out;
|
|
@@ -218,31 +264,61 @@ export const NODE_TYPES = {
|
|
|
218
264
|
// 'opacity' → scale a 0–1 expression to 0–255; 'color' → a (ternary of) color literal(s). Keys absent
|
|
219
265
|
// here (enums like flexDirection, the `flex` shorthand) can only be static for now.
|
|
220
266
|
const NUM_FIELDS = {
|
|
221
|
-
width: 'width',
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
267
|
+
width: 'width',
|
|
268
|
+
height: 'height',
|
|
269
|
+
minWidth: 'min_width',
|
|
270
|
+
maxWidth: 'max_width',
|
|
271
|
+
minHeight: 'min_height',
|
|
272
|
+
maxHeight: 'max_height',
|
|
273
|
+
padding: 'padding',
|
|
274
|
+
paddingHorizontal: 'padding_horizontal',
|
|
275
|
+
paddingVertical: 'padding_vertical',
|
|
276
|
+
paddingLeft: 'padding_left',
|
|
277
|
+
paddingTop: 'padding_top',
|
|
278
|
+
paddingRight: 'padding_right',
|
|
279
|
+
paddingBottom: 'padding_bottom',
|
|
280
|
+
margin: 'margin',
|
|
281
|
+
marginHorizontal: 'margin_horizontal',
|
|
282
|
+
marginVertical: 'margin_vertical',
|
|
283
|
+
marginLeft: 'margin_left',
|
|
284
|
+
marginTop: 'margin_top',
|
|
285
|
+
marginRight: 'margin_right',
|
|
286
|
+
marginBottom: 'margin_bottom',
|
|
287
|
+
gap: 'gap',
|
|
288
|
+
rowGap: 'row_gap',
|
|
289
|
+
columnGap: 'column_gap',
|
|
290
|
+
flexGrow: 'flex_grow',
|
|
291
|
+
flexShrink: 'flex_shrink',
|
|
292
|
+
borderRadius: 'border_radius',
|
|
293
|
+
borderWidth: 'border_width',
|
|
294
|
+
zIndex: 'z_index',
|
|
295
|
+
fontSize: 'font_size',
|
|
296
|
+
lineHeight: 'line_height',
|
|
297
|
+
letterSpacing: 'letter_spacing',
|
|
229
298
|
};
|
|
230
299
|
|
|
231
300
|
// Enum style keys that can be state-driven: the value (a string literal or a ternary of them) lowers to the
|
|
232
301
|
// matching ER_* enum constant via its table. Changing one in app_update re-runs layout (props_hash covers it).
|
|
233
302
|
const ENUM_FIELDS = {
|
|
234
|
-
flexDirection: {
|
|
235
|
-
alignItems: {
|
|
236
|
-
alignSelf: {
|
|
237
|
-
justifyContent: {
|
|
238
|
-
position: {
|
|
303
|
+
flexDirection: {field: 'flex_direction', table: FLEX_DIRECTION},
|
|
304
|
+
alignItems: {field: 'align_items', table: ALIGN},
|
|
305
|
+
alignSelf: {field: 'align_self', table: ALIGN},
|
|
306
|
+
justifyContent: {field: 'justify_content', table: JUSTIFY},
|
|
307
|
+
position: {field: 'position', table: POSITION},
|
|
239
308
|
};
|
|
240
309
|
|
|
241
310
|
export const DYN_FIELDS = {
|
|
242
|
-
...Object.fromEntries(
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
311
|
+
...Object.fromEntries(
|
|
312
|
+
Object.entries(NUM_FIELDS).map(([k, f]) => [k, {field: f, kind: 'num'}]),
|
|
313
|
+
),
|
|
314
|
+
...Object.fromEntries(
|
|
315
|
+
Object.entries(ENUM_FIELDS).map(([k, m]) => [
|
|
316
|
+
k,
|
|
317
|
+
{field: m.field, kind: 'enum', table: m.table},
|
|
318
|
+
]),
|
|
319
|
+
),
|
|
320
|
+
backgroundColor: {field: 'background_color', kind: 'color'},
|
|
321
|
+
color: {field: 'color', kind: 'color'},
|
|
322
|
+
borderColor: {field: 'border_color', kind: 'color'},
|
|
323
|
+
opacity: {field: 'opacity', kind: 'opacity'},
|
|
248
324
|
};
|
package/assets/bake-font.mjs
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
// bpp 8 → 1 byte/px, raw coverage
|
|
27
27
|
import fs from 'node:fs';
|
|
28
28
|
import opentype from 'opentype.js';
|
|
29
|
-
import {
|
|
29
|
+
import {rasterize} from './rasterize.mjs';
|
|
30
30
|
|
|
31
31
|
/** Default dense glyph range: printable ASCII (0x20..0x7E). */
|
|
32
32
|
export const ASCII_FIRST = 0x20;
|
|
@@ -34,14 +34,16 @@ export const ASCII_LAST = 0x7e;
|
|
|
34
34
|
|
|
35
35
|
// Named extra-symbol sets selectable from assets.config.js (glyphs: 'common' | 'minimal' | ...).
|
|
36
36
|
const SYMBOLS_MINIMAL = [
|
|
37
|
-
0x00b0, 0x00b1, 0x00b5, 0x00d7, 0x00f7, 0x2013, 0x2014, 0x2022, 0x2026,
|
|
38
|
-
0x2193, 0x2713, 0x2717,
|
|
37
|
+
0x00b0, 0x00b1, 0x00b5, 0x00d7, 0x00f7, 0x2013, 0x2014, 0x2022, 0x2026,
|
|
38
|
+
0x2190, 0x2191, 0x2192, 0x2193, 0x2713, 0x2717,
|
|
39
39
|
];
|
|
40
40
|
const SYMBOLS_COMMON = [
|
|
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, 0x221a, 0x221e, 0x2211, 0x2212,
|
|
45
|
+
0x2248, 0x2260, 0x2264, 0x2265, 0x25a0, 0x25cf, 0x25c6, 0x2605, 0x2606,
|
|
46
|
+
0x2713, 0x2717,
|
|
45
47
|
];
|
|
46
48
|
const SYMBOLS_GREEK = [];
|
|
47
49
|
for (let c = 0x0391; c <= 0x03a9; c++) if (c !== 0x03a2) SYMBOLS_GREEK.push(c); // uppercase (skip gap)
|
|
@@ -51,7 +53,9 @@ const SYMBOL_SETS = {
|
|
|
51
53
|
minimal: SYMBOLS_MINIMAL,
|
|
52
54
|
common: SYMBOLS_COMMON,
|
|
53
55
|
greek: SYMBOLS_GREEK,
|
|
54
|
-
'common-greek': [...new Set([...SYMBOLS_COMMON, ...SYMBOLS_GREEK])].sort(
|
|
56
|
+
'common-greek': [...new Set([...SYMBOLS_COMMON, ...SYMBOLS_GREEK])].sort(
|
|
57
|
+
(a, b) => a - b,
|
|
58
|
+
),
|
|
55
59
|
};
|
|
56
60
|
|
|
57
61
|
/**
|
|
@@ -65,8 +69,13 @@ export function resolveExtras(glyphs) {
|
|
|
65
69
|
let cps;
|
|
66
70
|
if (Array.isArray(glyphs)) cps = glyphs.slice();
|
|
67
71
|
else if (SYMBOL_SETS[glyphs]) cps = SYMBOL_SETS[glyphs].slice();
|
|
68
|
-
else
|
|
69
|
-
|
|
72
|
+
else
|
|
73
|
+
throw new Error(
|
|
74
|
+
`unknown glyph set "${glyphs}" (use 'ascii', a named set, or a codepoint array)`,
|
|
75
|
+
);
|
|
76
|
+
return [...new Set(cps)]
|
|
77
|
+
.filter(c => c < ASCII_FIRST || c > ASCII_LAST)
|
|
78
|
+
.sort((a, b) => a - b);
|
|
70
79
|
}
|
|
71
80
|
|
|
72
81
|
/**
|
|
@@ -94,7 +103,8 @@ function packCoverage(cov, w, h, bpp) {
|
|
|
94
103
|
const col = b * perByte + s;
|
|
95
104
|
if (col >= w) continue;
|
|
96
105
|
const c = cov[row * w + col];
|
|
97
|
-
const v =
|
|
106
|
+
const v =
|
|
107
|
+
bpp === 1 ? (c >= 128 ? 1 : 0) : Math.round((c * maxVal) / 255);
|
|
98
108
|
byte |= v << (8 - bpp - s * bpp); // first pixel in the high bits
|
|
99
109
|
}
|
|
100
110
|
out.push(byte);
|
|
@@ -109,15 +119,18 @@ function packCoverage(cov, w, h, bpp) {
|
|
|
109
119
|
* @returns {{glyph:object, bytes:number[]}|null} null when the font has no glyph for the codepoint.
|
|
110
120
|
*/
|
|
111
121
|
function bakeGlyph(font, cp, pixelSize, baseline, scale, bpp) {
|
|
112
|
-
if (font.charToGlyphIndex(String.fromCodePoint(cp)) === 0 && cp !== 0x20)
|
|
122
|
+
if (font.charToGlyphIndex(String.fromCodePoint(cp)) === 0 && cp !== 0x20)
|
|
123
|
+
return null;
|
|
113
124
|
const glyph = font.charToGlyph(String.fromCodePoint(cp));
|
|
114
125
|
const advance = Math.round(glyph.advanceWidth * scale);
|
|
115
126
|
const ss = bpp === 8 ? 8 : 4;
|
|
116
127
|
const steps = Math.max(6, Math.ceil(pixelSize / 3));
|
|
117
|
-
const r = rasterize(glyph.getPath(0, baseline, pixelSize), {
|
|
128
|
+
const r = rasterize(glyph.getPath(0, baseline, pixelSize), {ss, steps});
|
|
118
129
|
|
|
119
130
|
if (r.width > 255 || r.height > 255) {
|
|
120
|
-
throw new Error(
|
|
131
|
+
throw new Error(
|
|
132
|
+
`glyph U+${cp.toString(16)} is ${r.width}x${r.height}px — exceeds the uint8 glyph limit`,
|
|
133
|
+
);
|
|
121
134
|
}
|
|
122
135
|
const glyphRec = {
|
|
123
136
|
bitmapOffset: 0, // filled in by the caller once the running offset is known
|
|
@@ -127,8 +140,11 @@ function bakeGlyph(font, cp, pixelSize, baseline, scale, bpp) {
|
|
|
127
140
|
yOffset: r.yOffset,
|
|
128
141
|
advance: Math.min(advance, 255),
|
|
129
142
|
};
|
|
130
|
-
const bytes =
|
|
131
|
-
|
|
143
|
+
const bytes =
|
|
144
|
+
r.width > 0 && r.height > 0
|
|
145
|
+
? packCoverage(r.coverage, r.width, r.height, bpp)
|
|
146
|
+
: [];
|
|
147
|
+
return {glyph: glyphRec, bytes};
|
|
132
148
|
}
|
|
133
149
|
|
|
134
150
|
/**
|
|
@@ -142,8 +158,9 @@ function bakeGlyph(font, cp, pixelSize, baseline, scale, bpp) {
|
|
|
142
158
|
* @param {string|Array<number>} [opts.glyphs] Extra glyph coverage beyond ASCII (default 'ascii').
|
|
143
159
|
* @returns {{family:string, sizes:Array<object>}} Baked data ready for the C emitter.
|
|
144
160
|
*/
|
|
145
|
-
export function bakeFont({
|
|
146
|
-
if (![1, 2, 4, 8].includes(bpp))
|
|
161
|
+
export function bakeFont({path, family, sizes, bpp = 4, glyphs = 'ascii'}) {
|
|
162
|
+
if (![1, 2, 4, 8].includes(bpp))
|
|
163
|
+
throw new Error(`bpp must be 1, 2, 4, or 8 (got ${bpp})`);
|
|
147
164
|
const font = opentype.parse(fs.readFileSync(path).buffer);
|
|
148
165
|
const extraCps = resolveExtras(glyphs);
|
|
149
166
|
|
|
@@ -157,7 +174,14 @@ export function bakeFont({ path, family, sizes, bpp = 4, glyphs = 'ascii' }) {
|
|
|
157
174
|
const dense = [];
|
|
158
175
|
for (let cp = ASCII_FIRST; cp <= ASCII_LAST; cp++) {
|
|
159
176
|
const baked1 = bakeGlyph(font, cp, pixelSize, baseline, scale, bpp) || {
|
|
160
|
-
glyph: {
|
|
177
|
+
glyph: {
|
|
178
|
+
bitmapOffset: 0,
|
|
179
|
+
width: 0,
|
|
180
|
+
height: 0,
|
|
181
|
+
xOffset: 0,
|
|
182
|
+
yOffset: 0,
|
|
183
|
+
advance: Math.round(pixelSize / 2),
|
|
184
|
+
},
|
|
161
185
|
bytes: [],
|
|
162
186
|
};
|
|
163
187
|
baked1.glyph.bitmapOffset = baked1.bytes.length > 0 ? bitmap.length : 0;
|
|
@@ -171,7 +195,7 @@ export function bakeFont({ path, family, sizes, bpp = 4, glyphs = 'ascii' }) {
|
|
|
171
195
|
if (!b) continue; // font has no such glyph
|
|
172
196
|
b.glyph.bitmapOffset = b.bytes.length > 0 ? bitmap.length : 0;
|
|
173
197
|
bitmap.push(...b.bytes);
|
|
174
|
-
extras.push({
|
|
198
|
+
extras.push({codepoint: cp, info: b.glyph});
|
|
175
199
|
}
|
|
176
200
|
|
|
177
201
|
baked.push({
|
|
@@ -186,5 +210,5 @@ export function bakeFont({ path, family, sizes, bpp = 4, glyphs = 'ascii' }) {
|
|
|
186
210
|
bitmap,
|
|
187
211
|
});
|
|
188
212
|
}
|
|
189
|
-
return {
|
|
213
|
+
return {family, sizes: baked};
|
|
190
214
|
}
|
package/assets/bake-image.mjs
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// references by pointer (er_image_load). Pure JS (pngjs) — no native deps, no Python. The engine
|
|
19
19
|
// scales at render time, so bake at whatever source resolution you want to ship.
|
|
20
20
|
import fs from 'node:fs';
|
|
21
|
-
import {
|
|
21
|
+
import {PNG} from 'pngjs';
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* Decodes an image and returns its premultiplied ARGB8888 pixels (row-major, 0xAARRGGBB).
|
|
@@ -28,12 +28,14 @@ import { PNG } from 'pngjs';
|
|
|
28
28
|
* @param {string} opts.name Asset name an <Image source>/imageName looks up.
|
|
29
29
|
* @returns {{name:string, width:number, height:number, pixels:Uint32Array}}
|
|
30
30
|
*/
|
|
31
|
-
export function bakeImage({
|
|
31
|
+
export function bakeImage({path, name}) {
|
|
32
32
|
if (!/\.png$/i.test(path)) {
|
|
33
|
-
throw new Error(
|
|
33
|
+
throw new Error(
|
|
34
|
+
`image "${path}": only PNG is supported by the baker (convert to PNG, or extend bake-image.mjs)`,
|
|
35
|
+
);
|
|
34
36
|
}
|
|
35
37
|
const png = PNG.sync.read(fs.readFileSync(path));
|
|
36
|
-
const {
|
|
38
|
+
const {width, height, data} = png; // data = RGBA, 8-bit, row-major
|
|
37
39
|
const pixels = new Uint32Array(width * height);
|
|
38
40
|
for (let i = 0; i < width * height; i++) {
|
|
39
41
|
const r = data[i * 4];
|
|
@@ -46,5 +48,5 @@ export function bakeImage({ path, name }) {
|
|
|
46
48
|
const bp = Math.floor((b * a + 127) / 255);
|
|
47
49
|
pixels[i] = ((a << 24) | (rp << 16) | (gp << 8) | bp) >>> 0;
|
|
48
50
|
}
|
|
49
|
-
return {
|
|
51
|
+
return {name, width, height, pixels};
|
|
50
52
|
}
|