embedded-react 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,110 @@
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
+ // `npm run aot:smoke` — automated compile-and-screenshot smoke test for the Flow B (AOT) demos.
18
+ //
19
+ // For each demo it: (1) compiles App.jsx → dist/app.gen.{c,h} (`node aot/compile.mjs`), (2) rebuilds the
20
+ // linux-aot SDL host (which links the generated C), (3) runs it headless via ER_AOT_SHOT to render ONE
21
+ // frame to a BMP, and (4) checks the image actually has content (distinct colours above a floor) — i.e. it
22
+ // compiled, linked, and rendered something rather than crashing or drawing a blank screen. Exits non-zero
23
+ // if any demo fails, so it can gate CI.
24
+ //
25
+ // Prereq: the linux-aot CMake build must be configurable (SDL2 found). It reuses examples/linux-aot/build;
26
+ // if that isn't configured yet, set CMAKE_TOOLCHAIN_FILE (e.g. a vcpkg toolchain) and it will configure it.
27
+ // Needs a display (SDL video); on a headless box run under a virtual framebuffer.
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
+
33
+ const here = dirname(fileURLToPath(import.meta.url)); // bridges/quickjs/js/aot
34
+ const jsDir = resolve(here, '..'); // bridges/quickjs/js
35
+ const repoRoot = resolve(here, '../../../..');
36
+ const exampleDir = resolve(repoRoot, 'examples/linux-aot');
37
+ const buildDir = resolve(exampleDir, 'build');
38
+ const exe = resolve(buildDir, process.platform === 'win32' ? 'embedded-react-desktop-aot.exe' : 'embedded-react-desktop-aot');
39
+ const tmpDir = resolve(jsDir, 'dist', '.smoke');
40
+
41
+ // Demos to smoke-test. `screen` (optional) sets ER_AOT_SCREEN_W/H so the responsive thermostat folds to its
42
+ // compact (AOT-compilable) branch. `minColors` is the floor of distinct sampled colours for "rendered".
43
+ const DEMOS = [
44
+ { name: 'music-player', minColors: 40 },
45
+ { name: 'thermostat', screen: { w: 240, h: 320 }, minColors: 40 },
46
+ ];
47
+
48
+ /** Counts distinct colours in an uncompressed 24/32-bpp BMP (sampled) — a quick "is anything drawn?" signal. */
49
+ function bmpDistinctColors(path, step = 4) {
50
+ const b = readFileSync(path);
51
+ if (b[0] !== 0x42 || b[1] !== 0x4d) throw new Error(`not a BMP: ${path}`);
52
+ const off = b.readUInt32LE(10);
53
+ const w = b.readInt32LE(18);
54
+ const h = Math.abs(b.readInt32LE(22));
55
+ const bpp = b.readUInt16LE(28);
56
+ if (bpp !== 24 && bpp !== 32) throw new Error(`unexpected BMP bpp ${bpp}`);
57
+ const bytesPP = bpp / 8;
58
+ const rowSize = Math.floor((bpp * w + 31) / 32) * 4; // rows padded to 4 bytes
59
+ const colors = new Set();
60
+ for (let y = 0; y < h; y += step) {
61
+ for (let x = 0; x < w; x += step) {
62
+ const p = off + y * rowSize + x * bytesPP;
63
+ colors.add((b[p] << 16) | (b[p + 1] << 8) | b[p + 2]); // BGR triplet
64
+ }
65
+ }
66
+ return colors.size;
67
+ }
68
+
69
+ function run(cmd, args, opts = {}) {
70
+ execFileSync(cmd, args, { stdio: 'pipe', ...opts });
71
+ }
72
+
73
+ function ensureConfigured() {
74
+ if (existsSync(resolve(buildDir, 'CMakeCache.txt'))) return;
75
+ console.log('• configuring linux-aot build (first run)…');
76
+ const args = ['-S', exampleDir, '-B', buildDir];
77
+ if (process.env.CMAKE_TOOLCHAIN_FILE) args.push(`-DCMAKE_TOOLCHAIN_FILE=${process.env.CMAKE_TOOLCHAIN_FILE}`);
78
+ if (process.platform === 'win32') args.push('-G', 'MinGW Makefiles');
79
+ run('cmake', args);
80
+ }
81
+
82
+ let failures = 0;
83
+ mkdirSync(tmpDir, { recursive: true });
84
+ ensureConfigured();
85
+
86
+ for (const demo of DEMOS) {
87
+ const shot = resolve(tmpDir, `${demo.name}.bmp`);
88
+ try {
89
+ rmSync(shot, { force: true });
90
+ const genEnv = { ...process.env };
91
+ if (demo.screen) {
92
+ genEnv.ER_AOT_SCREEN_W = String(demo.screen.w);
93
+ genEnv.ER_AOT_SCREEN_H = String(demo.screen.h);
94
+ }
95
+ run('node', [resolve(here, 'compile.mjs'), demo.name], { cwd: jsDir, env: genEnv }); // → dist/app.gen.{c,h}
96
+ run('cmake', ['--build', buildDir]); // relink the generated C
97
+ run(exe, [], { cwd: buildDir, env: { ...process.env, ER_AOT_SHOT: shot } }); // render one frame → BMP
98
+
99
+ if (!existsSync(shot)) throw new Error('no screenshot written (host crashed before present?)');
100
+ const colors = bmpDistinctColors(shot);
101
+ if (colors < demo.minColors) throw new Error(`screenshot looks blank (${colors} distinct colours < ${demo.minColors})`);
102
+ console.log(`✓ ${demo.name}: rendered (${colors} distinct colours)`);
103
+ } catch (e) {
104
+ failures++;
105
+ console.error(`✗ ${demo.name}: ${e.message?.split('\n')[0] || e}`);
106
+ }
107
+ }
108
+
109
+ console.log(failures ? `\n${failures} demo(s) failed the smoke test.` : `\nAll ${DEMOS.length} demos compiled, built, and rendered.`);
110
+ process.exit(failures ? 1 : 0);
@@ -0,0 +1,248 @@
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
+ // style-map — lowers RN style keys to ERProps C-struct field assignments for the Flow B AOT compiler.
18
+ //
19
+ // This is the build-time mirror of what native_ui_bridge.c does at runtime in Flow A: it turns a
20
+ // flattened style object into ERProps field writes. The AOT compiler emits those writes as C source
21
+ // (e.g. `p.background_color = 0xFF0F172Au;`) so the generated program builds byte-identical prop bags
22
+ // to Flow A — same defaults (er_props_default), same field values → pixel-identical rendering.
23
+ //
24
+ // Scope: the minimal subset for the first vertical slice (the scaffold counter). Unsupported keys
25
+ // throw with a clear message so the compiler fails loudly instead of silently dropping a style — the
26
+ // supported set grows demo by demo.
27
+
28
+ /** A few CSS/RN named colors (extend as demos need them); everything else must be hex. */
29
+ const NAMED_COLORS = {
30
+ transparent: 0x00000000,
31
+ white: 0xffffffff,
32
+ black: 0xff000000,
33
+ red: 0xffff0000,
34
+ green: 0xff00ff00,
35
+ blue: 0xff0000ff,
36
+ };
37
+
38
+ /**
39
+ * Parses a color string to a packed ARGB8888 unsigned int (matching ERProps color fields).
40
+ *
41
+ * @param {string} s '#rgb' | '#rrggbb' | '#rrggbbaa' (RN order) | a named color.
42
+ * @returns {number} ARGB8888 as an unsigned 32-bit integer.
43
+ */
44
+ export function parseColorValue(s) {
45
+ if (typeof s !== 'string') throw new Error(`color must be a string, got ${typeof s}`);
46
+ const key = s.trim().toLowerCase();
47
+ if (key in NAMED_COLORS) return NAMED_COLORS[key] >>> 0;
48
+ const m = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec(key);
49
+ if (!m) throw new Error(`unsupported color "${s}" (use #rgb / #rrggbb / #rrggbbaa or a named color)`);
50
+ let hex = m[1];
51
+ if (hex.length === 3) hex = hex.split('').map((c) => c + c).join(''); // #rgb → #rrggbb
52
+ let r, g, b, a;
53
+ if (hex.length === 6) {
54
+ a = 0xff;
55
+ r = parseInt(hex.slice(0, 2), 16);
56
+ g = parseInt(hex.slice(2, 4), 16);
57
+ b = parseInt(hex.slice(4, 6), 16);
58
+ } else {
59
+ // RN 8-digit is #rrggbbaa (alpha last).
60
+ r = parseInt(hex.slice(0, 2), 16);
61
+ g = parseInt(hex.slice(2, 4), 16);
62
+ b = parseInt(hex.slice(4, 6), 16);
63
+ a = parseInt(hex.slice(6, 8), 16);
64
+ }
65
+ return (((a << 24) | (r << 16) | (g << 8) | b) >>> 0);
66
+ }
67
+
68
+ /** Formats an ARGB int as a C unsigned hex literal, e.g. 0xFF0F172Au. */
69
+ export function colorLiteral(s) {
70
+ return `0x${parseColorValue(s).toString(16).toUpperCase().padStart(8, '0')}u`;
71
+ }
72
+
73
+ const ALIGN = {
74
+ auto: 'ER_ALIGN_AUTO',
75
+ 'flex-start': 'ER_ALIGN_FLEX_START',
76
+ center: 'ER_ALIGN_CENTER',
77
+ 'flex-end': 'ER_ALIGN_FLEX_END',
78
+ stretch: 'ER_ALIGN_STRETCH',
79
+ };
80
+ const JUSTIFY = {
81
+ 'flex-start': 'ER_JUSTIFY_FLEX_START',
82
+ center: 'ER_JUSTIFY_CENTER',
83
+ 'flex-end': 'ER_JUSTIFY_FLEX_END',
84
+ 'space-between': 'ER_JUSTIFY_SPACE_BETWEEN',
85
+ 'space-around': 'ER_JUSTIFY_SPACE_AROUND',
86
+ 'space-evenly': 'ER_JUSTIFY_SPACE_EVENLY',
87
+ };
88
+ const FLEX_DIRECTION = {
89
+ column: 'ER_FLEX_COL',
90
+ row: 'ER_FLEX_ROW',
91
+ 'row-reverse': 'ER_FLEX_ROW_REVERSE',
92
+ 'column-reverse': 'ER_FLEX_COL_REVERSE',
93
+ };
94
+ const POSITION = { relative: 'ER_POS_RELATIVE', absolute: 'ER_POS_ABSOLUTE' };
95
+
96
+ const enumKey = (table, name) => (v) => {
97
+ const c = table[v];
98
+ if (!c) throw new Error(`${name}: unsupported value "${v}" (one of ${Object.keys(table).join(', ')})`);
99
+ return c;
100
+ };
101
+ const dim = (v) => {
102
+ if (typeof v !== 'number' || !Number.isFinite(v)) throw new Error(`expected a numeric dimension, got ${JSON.stringify(v)}`);
103
+ return String(Math.round(v));
104
+ };
105
+
106
+ /**
107
+ * A dimension that may be a percentage. `'50%'` → the engine's float `*_pct` field (% of the parent);
108
+ * a number → the pixel field. Used for width / height / flexBasis, which the engine resolves at layout.
109
+ *
110
+ * @param {string} pxField ERProps pixel field (e.g. 'width').
111
+ * @param {string} pctField ERProps percentage field (e.g. 'width_pct').
112
+ */
113
+ const pctOrPx = (pxField, pctField) => (v) => {
114
+ if (typeof v === 'string' && v.trim().endsWith('%')) {
115
+ const n = parseFloat(v);
116
+ if (!Number.isFinite(n)) throw new Error(`expected a percentage like '50%', got ${JSON.stringify(v)}`);
117
+ // A valid C float literal needs a decimal point — `50f` is a syntax error, `50.0f` is not.
118
+ const lit = Number.isInteger(n) ? `${n}.0f` : `${n}f`;
119
+ return [{ field: pctField, expr: lit }];
120
+ }
121
+ return [{ field: pxField, expr: dim(v) }];
122
+ };
123
+
124
+ /**
125
+ * Per-style-key lowering. Each entry maps a style value to one or more { field, expr } ERProps writes
126
+ * (field = ERProps C member, expr = C source for the value). `flex` expands to several fields.
127
+ */
128
+ const KEYS = {
129
+ // Layout
130
+ width: pctOrPx('width', 'width_pct'),
131
+ height: pctOrPx('height', 'height_pct'),
132
+ flexBasis: pctOrPx('flex_basis', 'flex_basis_pct'),
133
+ minWidth: (v) => [{ field: 'min_width', expr: dim(v) }],
134
+ maxWidth: (v) => [{ field: 'max_width', expr: dim(v) }],
135
+ minHeight: (v) => [{ field: 'min_height', expr: dim(v) }],
136
+ maxHeight: (v) => [{ field: 'max_height', expr: dim(v) }],
137
+ padding: (v) => [{ field: 'padding', expr: dim(v) }],
138
+ paddingHorizontal: (v) => [{ field: 'padding_horizontal', expr: dim(v) }],
139
+ paddingVertical: (v) => [{ field: 'padding_vertical', expr: dim(v) }],
140
+ paddingLeft: (v) => [{ field: 'padding_left', expr: dim(v) }],
141
+ paddingTop: (v) => [{ field: 'padding_top', expr: dim(v) }],
142
+ paddingRight: (v) => [{ field: 'padding_right', expr: dim(v) }],
143
+ paddingBottom: (v) => [{ field: 'padding_bottom', expr: dim(v) }],
144
+ margin: (v) => [{ field: 'margin', expr: dim(v) }],
145
+ marginHorizontal: (v) => [{ field: 'margin_horizontal', expr: dim(v) }],
146
+ marginVertical: (v) => [{ field: 'margin_vertical', expr: dim(v) }],
147
+ marginLeft: (v) => [{ field: 'margin_left', expr: dim(v) }],
148
+ marginTop: (v) => [{ field: 'margin_top', expr: dim(v) }],
149
+ marginRight: (v) => [{ field: 'margin_right', expr: dim(v) }],
150
+ marginBottom: (v) => [{ field: 'margin_bottom', expr: dim(v) }],
151
+ gap: (v) => [{ field: 'gap', expr: dim(v) }],
152
+ rowGap: (v) => [{ field: 'row_gap', expr: dim(v) }],
153
+ columnGap: (v) => [{ field: 'column_gap', expr: dim(v) }],
154
+ flexGrow: (v) => [{ field: 'flex_grow', expr: dim(v) }],
155
+ flexShrink: (v) => [{ field: 'flex_shrink', expr: dim(v) }],
156
+ flexDirection: (v) => [{ field: 'flex_direction', expr: enumKey(FLEX_DIRECTION, 'flexDirection')(v) }],
157
+ alignItems: (v) => [{ field: 'align_items', expr: enumKey(ALIGN, 'alignItems')(v) }],
158
+ alignSelf: (v) => [{ field: 'align_self', expr: enumKey(ALIGN, 'alignSelf')(v) }],
159
+ justifyContent: (v) => [{ field: 'justify_content', expr: enumKey(JUSTIFY, 'justifyContent')(v) }],
160
+ // Positioning: `position: 'absolute'` takes a node out of flow; left/top/right/bottom are its anchors.
161
+ position: (v) => [{ field: 'position', expr: enumKey(POSITION, 'position')(v) }],
162
+ left: (v) => [{ field: 'left', expr: dim(v) }],
163
+ top: (v) => [{ field: 'top', expr: dim(v) }],
164
+ right: (v) => [{ field: 'right', expr: dim(v) }],
165
+ bottom: (v) => [{ field: 'bottom', expr: dim(v) }],
166
+ // `flex: n` → grow=n, shrink=1, basis=0 (RN semantics; matches native_ui_bridge apply_flex).
167
+ flex: (v) => {
168
+ const n = Number(v);
169
+ if (n > 0) return [{ field: 'flex_grow', expr: dim(n) }, { field: 'flex_shrink', expr: '1' }, { field: 'flex_basis', expr: '0' }];
170
+ if (n === 0) return [{ field: 'flex_grow', expr: '0' }, { field: 'flex_shrink', expr: '0' }];
171
+ return [{ field: 'flex_grow', expr: '0' }, { field: 'flex_shrink', expr: '1' }];
172
+ },
173
+
174
+ // View visual
175
+ backgroundColor: (v) => [{ field: 'background_color', expr: colorLiteral(v) }],
176
+ borderRadius: (v) => [{ field: 'border_radius', expr: dim(v) }],
177
+ borderWidth: (v) => [{ field: 'border_width', expr: dim(v) }],
178
+ borderColor: (v) => [{ field: 'border_color', expr: colorLiteral(v) }],
179
+ opacity: (v) => [{ field: 'opacity', expr: String(Math.round(Math.max(0, Math.min(1, Number(v))) * 255)) }],
180
+ zIndex: (v) => [{ field: 'z_index', expr: dim(v) }],
181
+
182
+ // Text
183
+ color: (v) => [{ field: 'color', expr: colorLiteral(v) }],
184
+ fontSize: (v) => [{ field: 'font_size', expr: dim(v) }],
185
+ fontWeight: (v) => [{ field: 'font_weight', expr: v === 'bold' || Number(v) >= 600 ? '1' : '0' }],
186
+ lineHeight: (v) => [{ field: 'line_height', expr: dim(v) }],
187
+ letterSpacing: (v) => [{ field: 'letter_spacing', expr: dim(v) }],
188
+ };
189
+
190
+ /**
191
+ * Lowers one flattened style object to a list of ERProps field assignments.
192
+ *
193
+ * @param {object} style Flattened style (plain key→value; values already statically resolved).
194
+ * @returns {Array<{field:string, expr:string}>} ERProps writes in declaration order.
195
+ */
196
+ export function lowerStyle(style) {
197
+ const out = [];
198
+ for (const [key, value] of Object.entries(style)) {
199
+ if (value === undefined || value === null) continue;
200
+ const fn = KEYS[key];
201
+ if (!fn) throw new Error(`AOT: unsupported style key "${key}" (not yet lowered to ERProps)`);
202
+ out.push(...fn(value));
203
+ }
204
+ return out;
205
+ }
206
+
207
+ /** Maps a JSX component tag to its ERNodeType enum (the subset the slice supports). */
208
+ export const NODE_TYPES = {
209
+ View: 'ER_NODE_VIEW',
210
+ Text: 'ER_NODE_TEXT',
211
+ Pressable: 'ER_NODE_PRESSABLE',
212
+ Image: 'ER_NODE_IMAGE',
213
+ ScrollView: 'ER_NODE_SCROLL_VIEW',
214
+ };
215
+
216
+ // Style keys whose value can be DYNAMIC (driven by state). Each maps to its ERProps field + a kind the
217
+ // AOT compiler uses to lower a runtime expression: 'num' → assign a C numeric expression directly;
218
+ // 'opacity' → scale a 0–1 expression to 0–255; 'color' → a (ternary of) color literal(s). Keys absent
219
+ // here (enums like flexDirection, the `flex` shorthand) can only be static for now.
220
+ const NUM_FIELDS = {
221
+ width: 'width', height: 'height', minWidth: 'min_width', maxWidth: 'max_width', minHeight: 'min_height', maxHeight: 'max_height',
222
+ padding: 'padding', paddingHorizontal: 'padding_horizontal', paddingVertical: 'padding_vertical',
223
+ paddingLeft: 'padding_left', paddingTop: 'padding_top', paddingRight: 'padding_right', paddingBottom: 'padding_bottom',
224
+ margin: 'margin', marginHorizontal: 'margin_horizontal', marginVertical: 'margin_vertical',
225
+ marginLeft: 'margin_left', marginTop: 'margin_top', marginRight: 'margin_right', marginBottom: 'margin_bottom',
226
+ gap: 'gap', rowGap: 'row_gap', columnGap: 'column_gap', flexGrow: 'flex_grow', flexShrink: 'flex_shrink',
227
+ borderRadius: 'border_radius', borderWidth: 'border_width', zIndex: 'z_index',
228
+ fontSize: 'font_size', lineHeight: 'line_height', letterSpacing: 'letter_spacing',
229
+ };
230
+
231
+ // Enum style keys that can be state-driven: the value (a string literal or a ternary of them) lowers to the
232
+ // matching ER_* enum constant via its table. Changing one in app_update re-runs layout (props_hash covers it).
233
+ const ENUM_FIELDS = {
234
+ flexDirection: { field: 'flex_direction', table: FLEX_DIRECTION },
235
+ alignItems: { field: 'align_items', table: ALIGN },
236
+ alignSelf: { field: 'align_self', table: ALIGN },
237
+ justifyContent: { field: 'justify_content', table: JUSTIFY },
238
+ position: { field: 'position', table: POSITION },
239
+ };
240
+
241
+ export const DYN_FIELDS = {
242
+ ...Object.fromEntries(Object.entries(NUM_FIELDS).map(([k, f]) => [k, { field: f, kind: 'num' }])),
243
+ ...Object.fromEntries(Object.entries(ENUM_FIELDS).map(([k, m]) => [k, { field: m.field, kind: 'enum', table: m.table }])),
244
+ backgroundColor: { field: 'background_color', kind: 'color' },
245
+ color: { field: 'color', kind: 'color' },
246
+ borderColor: { field: 'border_color', kind: 'color' },
247
+ opacity: { field: 'opacity', kind: 'opacity' },
248
+ };
@@ -0,0 +1,190 @@
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 font baker: TrueType/OpenType → the engine's BitmapFont glyph data, at the exact pixel
18
+ // sizes the app uses. Pure JS (opentype.js + the local rasterizer) — no native deps, no Python.
19
+ //
20
+ // The output is consumed zero-copy by er_font_register() (the BitmapFont is a flash-resident const),
21
+ // so there is no runtime rasterizer and no font pool. Per-pixel coverage is packed to the requested
22
+ // bits-per-pixel exactly as engine/text/text_renderer.c decodes it:
23
+ // bpp 1 → 1 bit/px, MSB = leftmost, set when coverage >= 128
24
+ // bpp 2 → 4 px/byte, value = round(cov*3/255) (decoded as value*85)
25
+ // bpp 4 → 2 px/byte, high nibble = left, value = round(cov*15/255) (decoded as value*17)
26
+ // bpp 8 → 1 byte/px, raw coverage
27
+ import fs from 'node:fs';
28
+ import opentype from 'opentype.js';
29
+ import { rasterize } from './rasterize.mjs';
30
+
31
+ /** Default dense glyph range: printable ASCII (0x20..0x7E). */
32
+ export const ASCII_FIRST = 0x20;
33
+ export const ASCII_LAST = 0x7e;
34
+
35
+ // Named extra-symbol sets selectable from assets.config.js (glyphs: 'common' | 'minimal' | ...).
36
+ const SYMBOLS_MINIMAL = [
37
+ 0x00b0, 0x00b1, 0x00b5, 0x00d7, 0x00f7, 0x2013, 0x2014, 0x2022, 0x2026, 0x2190, 0x2191, 0x2192,
38
+ 0x2193, 0x2713, 0x2717,
39
+ ];
40
+ const SYMBOLS_COMMON = [
41
+ 0x00a2, 0x00a3, 0x00a5, 0x00a7, 0x00a9, 0x00ae, 0x00b0, 0x00b1, 0x00b5, 0x00d7, 0x00f7, 0x2013,
42
+ 0x2014, 0x2018, 0x2019, 0x201c, 0x201d, 0x2020, 0x2021, 0x2022, 0x2026, 0x2030, 0x20ac, 0x2122,
43
+ 0x2190, 0x2191, 0x2192, 0x2193, 0x2194, 0x21b5, 0x2202, 0x2206, 0x221a, 0x221e, 0x2211, 0x2212,
44
+ 0x2248, 0x2260, 0x2264, 0x2265, 0x25a0, 0x25cf, 0x25c6, 0x2605, 0x2606, 0x2713, 0x2717,
45
+ ];
46
+ const SYMBOLS_GREEK = [];
47
+ for (let c = 0x0391; c <= 0x03a9; c++) if (c !== 0x03a2) SYMBOLS_GREEK.push(c); // uppercase (skip gap)
48
+ for (let c = 0x03b1; c <= 0x03c9; c++) SYMBOLS_GREEK.push(c); // lowercase
49
+ const SYMBOL_SETS = {
50
+ none: [],
51
+ minimal: SYMBOLS_MINIMAL,
52
+ common: SYMBOLS_COMMON,
53
+ greek: SYMBOLS_GREEK,
54
+ 'common-greek': [...new Set([...SYMBOLS_COMMON, ...SYMBOLS_GREEK])].sort((a, b) => a - b),
55
+ };
56
+
57
+ /**
58
+ * Resolves a glyph-coverage spec into a sorted list of extra (sparse) codepoints.
59
+ *
60
+ * @param {string|Array<number>|undefined} glyphs 'ascii' (none), a named set, or explicit codepoints.
61
+ * @returns {number[]} Sorted extra codepoints outside the dense ASCII range.
62
+ */
63
+ export function resolveExtras(glyphs) {
64
+ if (!glyphs || glyphs === 'ascii') return [];
65
+ let cps;
66
+ if (Array.isArray(glyphs)) cps = glyphs.slice();
67
+ else if (SYMBOL_SETS[glyphs]) cps = SYMBOL_SETS[glyphs].slice();
68
+ else throw new Error(`unknown glyph set "${glyphs}" (use 'ascii', a named set, or a codepoint array)`);
69
+ return [...new Set(cps)].filter((c) => c < ASCII_FIRST || c > ASCII_LAST).sort((a, b) => a - b);
70
+ }
71
+
72
+ /**
73
+ * Packs an 8-bit coverage bitmap (one byte per pixel) into the engine's per-row bpp layout.
74
+ *
75
+ * @param {Uint8Array} cov Row-major coverage, width*height bytes.
76
+ * @param {number} w Glyph width in pixels.
77
+ * @param {number} h Glyph height in pixels.
78
+ * @param {number} bpp Bits per pixel (1, 2, 4, or 8).
79
+ * @returns {number[]} Packed bytes (row-major, each row padded to a whole byte).
80
+ */
81
+ function packCoverage(cov, w, h, bpp) {
82
+ const out = [];
83
+ if (bpp === 8) {
84
+ for (let i = 0; i < w * h; i++) out.push(cov[i]);
85
+ return out;
86
+ }
87
+ const maxVal = (1 << bpp) - 1;
88
+ const perByte = 8 / bpp;
89
+ const rowBytes = Math.ceil(w / perByte);
90
+ for (let row = 0; row < h; row++) {
91
+ for (let b = 0; b < rowBytes; b++) {
92
+ let byte = 0;
93
+ for (let s = 0; s < perByte; s++) {
94
+ const col = b * perByte + s;
95
+ if (col >= w) continue;
96
+ const c = cov[row * w + col];
97
+ const v = bpp === 1 ? (c >= 128 ? 1 : 0) : Math.round((c * maxVal) / 255);
98
+ byte |= v << (8 - bpp - s * bpp); // first pixel in the high bits
99
+ }
100
+ out.push(byte);
101
+ }
102
+ }
103
+ return out;
104
+ }
105
+
106
+ /**
107
+ * Rasterizes a single codepoint into a glyph record + appended bitmap bytes.
108
+ *
109
+ * @returns {{glyph:object, bytes:number[]}|null} null when the font has no glyph for the codepoint.
110
+ */
111
+ function bakeGlyph(font, cp, pixelSize, baseline, scale, bpp) {
112
+ if (font.charToGlyphIndex(String.fromCodePoint(cp)) === 0 && cp !== 0x20) return null;
113
+ const glyph = font.charToGlyph(String.fromCodePoint(cp));
114
+ const advance = Math.round(glyph.advanceWidth * scale);
115
+ const ss = bpp === 8 ? 8 : 4;
116
+ const steps = Math.max(6, Math.ceil(pixelSize / 3));
117
+ const r = rasterize(glyph.getPath(0, baseline, pixelSize), { ss, steps });
118
+
119
+ if (r.width > 255 || r.height > 255) {
120
+ throw new Error(`glyph U+${cp.toString(16)} is ${r.width}x${r.height}px — exceeds the uint8 glyph limit`);
121
+ }
122
+ const glyphRec = {
123
+ bitmapOffset: 0, // filled in by the caller once the running offset is known
124
+ width: r.width,
125
+ height: r.height,
126
+ xOffset: r.xOffset,
127
+ yOffset: r.yOffset,
128
+ advance: Math.min(advance, 255),
129
+ };
130
+ const bytes = r.width > 0 && r.height > 0 ? packCoverage(r.coverage, r.width, r.height, bpp) : [];
131
+ return { glyph: glyphRec, bytes };
132
+ }
133
+
134
+ /**
135
+ * Bakes one font into per-size BitmapFont data.
136
+ *
137
+ * @param {object} opts
138
+ * @param {string} opts.path Path to the .ttf/.otf file.
139
+ * @param {string} opts.family Family name to register under (used by fontFamily lookups).
140
+ * @param {number[]} opts.sizes Pixel sizes to bake.
141
+ * @param {number} [opts.bpp] Bits per pixel (default 4).
142
+ * @param {string|Array<number>} [opts.glyphs] Extra glyph coverage beyond ASCII (default 'ascii').
143
+ * @returns {{family:string, sizes:Array<object>}} Baked data ready for the C emitter.
144
+ */
145
+ export function bakeFont({ path, family, sizes, bpp = 4, glyphs = 'ascii' }) {
146
+ if (![1, 2, 4, 8].includes(bpp)) throw new Error(`bpp must be 1, 2, 4, or 8 (got ${bpp})`);
147
+ const font = opentype.parse(fs.readFileSync(path).buffer);
148
+ const extraCps = resolveExtras(glyphs);
149
+
150
+ const baked = [];
151
+ for (const pixelSize of sizes) {
152
+ const scale = pixelSize / font.unitsPerEm;
153
+ const baseline = Math.round(font.ascender * scale);
154
+ const lineHeight = baseline + Math.round(-font.descender * scale);
155
+
156
+ const bitmap = [];
157
+ const dense = [];
158
+ for (let cp = ASCII_FIRST; cp <= ASCII_LAST; cp++) {
159
+ const baked1 = bakeGlyph(font, cp, pixelSize, baseline, scale, bpp) || {
160
+ glyph: { bitmapOffset: 0, width: 0, height: 0, xOffset: 0, yOffset: 0, advance: Math.round(pixelSize / 2) },
161
+ bytes: [],
162
+ };
163
+ baked1.glyph.bitmapOffset = baked1.bytes.length > 0 ? bitmap.length : 0;
164
+ bitmap.push(...baked1.bytes);
165
+ dense.push(baked1.glyph);
166
+ }
167
+
168
+ const extras = [];
169
+ for (const cp of extraCps) {
170
+ const b = bakeGlyph(font, cp, pixelSize, baseline, scale, bpp);
171
+ if (!b) continue; // font has no such glyph
172
+ b.glyph.bitmapOffset = b.bytes.length > 0 ? bitmap.length : 0;
173
+ bitmap.push(...b.bytes);
174
+ extras.push({ codepoint: cp, info: b.glyph });
175
+ }
176
+
177
+ baked.push({
178
+ pixelSize,
179
+ lineHeight: Math.min(lineHeight, 255),
180
+ baseline: Math.min(baseline, 255),
181
+ first: ASCII_FIRST,
182
+ last: ASCII_LAST,
183
+ bpp,
184
+ dense,
185
+ extras,
186
+ bitmap,
187
+ });
188
+ }
189
+ return { family, sizes: baked };
190
+ }
@@ -0,0 +1,50 @@
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 image baker: PNG → premultiplied ARGB8888, the format the engine's image registry
18
+ // references by pointer (er_image_load). Pure JS (pngjs) — no native deps, no Python. The engine
19
+ // scales at render time, so bake at whatever source resolution you want to ship.
20
+ import fs from 'node:fs';
21
+ import { PNG } from 'pngjs';
22
+
23
+ /**
24
+ * Decodes an image and returns its premultiplied ARGB8888 pixels (row-major, 0xAARRGGBB).
25
+ *
26
+ * @param {object} opts
27
+ * @param {string} opts.path Path to the source image.
28
+ * @param {string} opts.name Asset name an <Image source>/imageName looks up.
29
+ * @returns {{name:string, width:number, height:number, pixels:Uint32Array}}
30
+ */
31
+ export function bakeImage({ path, name }) {
32
+ if (!/\.png$/i.test(path)) {
33
+ throw new Error(`image "${path}": only PNG is supported by the baker (convert to PNG, or extend bake-image.mjs)`);
34
+ }
35
+ const png = PNG.sync.read(fs.readFileSync(path));
36
+ const { width, height, data } = png; // data = RGBA, 8-bit, row-major
37
+ const pixels = new Uint32Array(width * height);
38
+ for (let i = 0; i < width * height; i++) {
39
+ const r = data[i * 4];
40
+ const g = data[i * 4 + 1];
41
+ const b = data[i * 4 + 2];
42
+ const a = data[i * 4 + 3];
43
+ // Premultiply the color channels by alpha (round-to-nearest) — the engine's image format.
44
+ const rp = Math.floor((r * a + 127) / 255);
45
+ const gp = Math.floor((g * a + 127) / 255);
46
+ const bp = Math.floor((b * a + 127) / 255);
47
+ pixels[i] = ((a << 24) | (rp << 16) | (gp << 8) | bp) >>> 0;
48
+ }
49
+ return { name, width, height, pixels };
50
+ }
@@ -0,0 +1,51 @@
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
+ // Regenerates the engine's built-in font (engine/font/font_data.c) from assets/fonts/Inter-Regular.ttf
18
+ // using the same JS bakers as app assets — this is the single source of truth for the built-in font
19
+ // (there is no Python step). font_registry.c falls back to g_inter_sizes[] for any text without a
20
+ // matching custom family.
21
+ //
22
+ // cd bridges/quickjs/js && npm run build:builtin-font
23
+ //
24
+ // Regenerating changes glyph metrics slightly vs a prior rasterizer, so re-run the engine text tests
25
+ // (test_text, yoga_parity) and re-flash to eyeball on-device after changing it.
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
+
32
+ const here = dirname(fileURLToPath(import.meta.url)); // bridges/quickjs/js/assets
33
+ const repoRoot = resolve(here, '../../../..');
34
+ const FONT = resolve(repoRoot, 'assets/fonts/Inter-Regular.ttf');
35
+ const OUT = resolve(repoRoot, 'engine/font/font_data.c');
36
+
37
+ // The built-in covers printable ASCII plus a fixed set of common symbols (degrees, arrows, math,
38
+ // punctuation, etc.) the UI components rely on — kept stable across regenerations.
39
+ const SIZES = [10, 12, 16, 20, 24, 32, 48];
40
+ const EXTRAS = [
41
+ 0x00a2, 0x00a3, 0x00a5, 0x00a7, 0x00a9, 0x00ae, 0x00b0, 0x00b1, 0x00b5, 0x00d7, 0x00f7, 0x2013,
42
+ 0x2014, 0x2018, 0x2019, 0x201c, 0x201d, 0x2020, 0x2021, 0x2022, 0x2026, 0x2030, 0x20ac, 0x2122,
43
+ 0x2190, 0x2191, 0x2192, 0x2193, 0x2194, 0x21b5, 0x2202, 0x2206, 0x2211, 0x2212, 0x221a, 0x221e,
44
+ 0x2248, 0x2260, 0x2264, 0x2265, 0x25a0, 0x25c6, 0x25cf, 0x2605, 0x2606, 0x2713, 0x2717,
45
+ ];
46
+
47
+ const font = bakeFont({ path: FONT, family: 'Inter', sizes: SIZES, bpp: 4, glyphs: EXTRAS });
48
+ writeFileSync(OUT, emitBuiltinFont({ font, symbol: 'inter', sourceName: 'Inter-Regular.ttf' }));
49
+
50
+ const bytes = font.sizes.reduce((n, s) => n + s.bitmap.length, 0);
51
+ console.log(`Regenerated ${OUT}\n ${SIZES.length} sizes [${SIZES.join(',')}], bpp 4, ${font.sizes[0].extras.length} extra glyphs, ${bytes} bitmap bytes`);