@geastack/core 0.1.23

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 (73) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +36 -0
  3. package/app_frame_perf.cpp +77 -0
  4. package/css.d.ts +1 -0
  5. package/events.cpp +67 -0
  6. package/gea_app_entry.cpp +317 -0
  7. package/gea_sources.mjs +126 -0
  8. package/gea_sources.sh +36 -0
  9. package/include/app.h +60 -0
  10. package/include/apps.h +46 -0
  11. package/include/audio.h +90 -0
  12. package/include/ble.h +212 -0
  13. package/include/bluetooth/hid_profile.h +92 -0
  14. package/include/camera.h +107 -0
  15. package/include/canvas.h +290 -0
  16. package/include/display.h +390 -0
  17. package/include/display_present.h +869 -0
  18. package/include/event.h +32 -0
  19. package/include/events.h +116 -0
  20. package/include/gea/embedded.h +13 -0
  21. package/include/gea/reactive_runtime.h +32 -0
  22. package/include/gea.h +14 -0
  23. package/include/gea_perf_config.h +96 -0
  24. package/include/geaos/abi.h +165 -0
  25. package/include/geolocation.h +126 -0
  26. package/include/gpio.h +61 -0
  27. package/include/graphics/font.h +122 -0
  28. package/include/i2c.h +20 -0
  29. package/include/image.h +156 -0
  30. package/include/imu.h +23 -0
  31. package/include/input.h +33 -0
  32. package/include/memory.h +52 -0
  33. package/include/pixel.h +907 -0
  34. package/include/platform/file_cache.h +18 -0
  35. package/include/power.h +11 -0
  36. package/include/runtime.h +83 -0
  37. package/include/services/app_runner.h +10 -0
  38. package/include/services/app_state.h +14 -0
  39. package/include/services/battery_service.h +12 -0
  40. package/include/services/bluetooth_service.h +13 -0
  41. package/include/services/diagnostics.h +76 -0
  42. package/include/services/diagnostics_platform.h +54 -0
  43. package/include/services/frame_scheduler.h +71 -0
  44. package/include/services/mirror.h +49 -0
  45. package/include/services/network_services.h +25 -0
  46. package/include/services/ota.h +32 -0
  47. package/include/services/runtime_log.h +11 -0
  48. package/include/services/storage_service.h +26 -0
  49. package/include/touch.h +38 -0
  50. package/include/ui/signal.h +149 -0
  51. package/include/wifi.h +67 -0
  52. package/index.d.ts +1940 -0
  53. package/jsx.d.ts +1 -0
  54. package/package.json +70 -0
  55. package/runtime/ble.ts +49 -0
  56. package/runtime/color.ts +21 -0
  57. package/runtime/compiler.ts +43 -0
  58. package/runtime/host.ts +1083 -0
  59. package/runtime/images.ts +129 -0
  60. package/runtime/primitives.ts +49 -0
  61. package/runtime-surface.ts +180 -0
  62. package/runtime.cpp +431 -0
  63. package/runtime.ts +34 -0
  64. package/scripts/build-gea-vite-geatsc.mjs +4514 -0
  65. package/scripts/dotenv-defines.mjs +98 -0
  66. package/scripts/gea-bundle-type-hints.mjs +546 -0
  67. package/scripts/gea-embedded-compat-staging.mjs +17 -0
  68. package/scripts/gea-embedded-compat-transform.mjs +427 -0
  69. package/scripts/gea-vite-module-graph-plugin.mjs +578 -0
  70. package/scripts/generate-gea-embedded-assets.mjs +296 -0
  71. package/scripts/generate-gea-embedded-fonts.mjs +1252 -0
  72. package/scripts/generate-installed-app-plan.mjs +55 -0
  73. package/scripts/native-object-cache.sh +244 -0
@@ -0,0 +1,1252 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import process from 'node:process'
5
+ import zlib from 'node:zlib'
6
+ import { execFileSync } from 'node:child_process'
7
+ import { createHash } from 'node:crypto'
8
+ import { createRequire } from 'node:module'
9
+ import { fileURLToPath } from 'node:url'
10
+
11
+ const args = process.argv.slice(2)
12
+
13
+ function readOption(name) {
14
+ const index = args.indexOf(name)
15
+ return index >= 0 ? args[index + 1] : undefined
16
+ }
17
+
18
+ function hasFlag(name) {
19
+ return args.includes(name)
20
+ }
21
+
22
+ function readAllOptions(name) {
23
+ const values = []
24
+ for (let i = 0; i < args.length; i++) {
25
+ if (args[i] === name && i + 1 < args.length) values.push(args[i + 1])
26
+ }
27
+ return values
28
+ }
29
+
30
+ function fail(message) {
31
+ process.stderr.write(`${message}\n`)
32
+ process.exit(1)
33
+ }
34
+
35
+ const appDir = path.resolve(readOption('--app-dir') ?? fail('missing --app-dir <dir>'))
36
+ const cssDir = path.resolve(readOption('--css-dir') ?? fail('missing --css-dir <dir>'))
37
+ const outCpp = path.resolve(readOption('--out-cpp') ?? fail('missing --out-cpp <file>'))
38
+ const outH = path.resolve(readOption('--out-h') ?? fail('missing --out-h <file>'))
39
+ const symbolPrefix = readOption('--symbol-prefix')
40
+ const viewportWidths = parseViewportWidths(readAllOptions('--viewport-width'))
41
+ const viewportHeights = parseViewportWidths(readAllOptions('--viewport-height'))
42
+ const devicePixelRatios = parseDevicePixelRatios(readAllOptions('--device-pixel-ratio'))
43
+ const runtimeTtfFonts = hasFlag('--runtime-ttf-fonts')
44
+ // Glyph rasterization supersampling. 4 (default) = antialiased coverage for
45
+ // grayscale/color panels. 1 = sample at 1:1 = NO antialiasing = crisp binary
46
+ // glyphs, which read better on 1-bit B/W e-paper than thresholded AA (every
47
+ // glyph pixel is fully on/off, so the panel's mono threshold is a no-op).
48
+ // Set per board by build-gea-vite-geatsc.mjs.
49
+ const glyphSupersample = Math.max(1, Math.round(Number(readOption('--supersample') ?? '4')) || 4)
50
+ // Atlas pixel depth. 8 (default) = one coverage byte per pixel. 2 = four pixels
51
+ // per byte, quantized offline to the four levels a 4-gray e-paper panel can
52
+ // actually show — the atlas costs a quarter of the flash bandwidth to read for
53
+ // no visible loss on such a panel. Set per board by build-gea-vite-geatsc.mjs.
54
+ const atlasBits = Number(readOption('--atlas-bits') ?? '8')
55
+ if (atlasBits !== 2 && atlasBits !== 8) fail(`unsupported --atlas-bits ${atlasBits} (expected 2 or 8)`)
56
+
57
+ const genericFamilies = new Set(['serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui'])
58
+
59
+ // Baseline glyph coverage for prose and UI chrome. EPUB text routinely uses
60
+ // typographic quotes, dashes and ellipses even when the language is otherwise
61
+ // plain ASCII; navigation controls also use arrows and single guillemets. If
62
+ // these codepoints are absent from the baked atlas the renderer falls back to
63
+ // `?`, despite the source TTF containing the glyph.
64
+ const embeddedFontExtraCodepoints = [
65
+ 0x00a0, // no-break space
66
+ 0x00a9, // copyright
67
+ 0x00ab, // left double guillemet
68
+ 0x00b0, // degree
69
+ 0x00b7, // middle dot (common UI separator; only appears in dynamic strings)
70
+ 0x00bb, // right double guillemet
71
+ 0x2010, // hyphen
72
+ 0x2011, // non-breaking hyphen
73
+ 0x2013, // en dash
74
+ 0x2014, // em dash
75
+ 0x2018, // left single quotation mark
76
+ 0x2019, // right single quotation mark
77
+ 0x201c, // left double quotation mark
78
+ 0x201d, // right double quotation mark
79
+ 0x2022, // bullet
80
+ 0x2026, // ellipsis
81
+ 0x2212, // minus sign (UI −/+ controls read better than ASCII hyphen)
82
+ 0x2032, // prime
83
+ 0x2033, // double prime
84
+ 0x2039, // left single guillemet
85
+ 0x203a, // right single guillemet
86
+ 0x20ac, // euro
87
+ 0x2122, // trademark
88
+ 0x2190, // left arrow
89
+ 0x2191, // up arrow
90
+ 0x2192, // right arrow
91
+ 0x2193, // down arrow
92
+ 0x21bb, // clockwise open circle arrow
93
+ ]
94
+
95
+ function baselineCodepoints() {
96
+ const codepoints = []
97
+ for (let cp = 0x20; cp <= 0x7e; cp += 1) codepoints.push(cp)
98
+ codepoints.push(...embeddedFontExtraCodepoints)
99
+ return codepoints
100
+ }
101
+
102
+ // Every atlas carries that whole baseline, at every size the app asks for. A
103
+ // glyph's area grows with the square of the size, so one display size can cost
104
+ // more than all the body sizes together -- and its text is usually a closed set:
105
+ // a tuner's note names, a clock's digits. An app narrows such an atlas by naming
106
+ // the characters it can ever render, on the rule that sets the size:
107
+ //
108
+ // .tuner-note { font-size: 52px; --gea-font-charset: 'ABCDEFG#0123456789-' }
109
+ //
110
+ // The charset is a list of characters, so it cannot contain a `;`, and it
111
+ // replaces the baseline rather than intersecting it -- an app may name a
112
+ // codepoint the baseline omits, as long as the font has the glyph. CSS escapes
113
+ // (`\2014` for an em dash) are honoured, so a stylesheet never has to carry a
114
+ // literal non-ASCII character to name one.
115
+ function parseFontCharset(value) {
116
+ const text = unquoteCss(value.trim())
117
+ if (text === '') return null
118
+ // Space is a glyph like any other, and `?` is what the renderer substitutes
119
+ // for one it cannot find, so neither is ever worth leaving out.
120
+ const codepoints = new Set([0x20, 0x3f])
121
+ // A CSS escape is a backslash, up to six hex digits, and one optional space
122
+ // that ends the digits rather than being a character in its own right.
123
+ const escapeRe = /\\([0-9a-fA-F]{1,6})[ ]?|\\(.)|([\s\S])/gu
124
+ let escape
125
+ while ((escape = escapeRe.exec(text)) !== null) {
126
+ if (escape[1] !== undefined) codepoints.add(Number.parseInt(escape[1], 16))
127
+ else codepoints.add((escape[2] ?? escape[3]).codePointAt(0))
128
+ }
129
+ return codepoints
130
+ }
131
+
132
+ // null is "unrestricted", and it wins: a size is one atlas however many rules
133
+ // ask for it, so a single rule naming that size without a charset has to take
134
+ // the atlas back to the baseline. Narrowing on the strength of the rules that
135
+ // opted in would render the other rules' text as `?`.
136
+ function mergeCharsets(a, b) {
137
+ if (a === null || b === null) return null
138
+ return new Set([...a, ...b])
139
+ }
140
+
141
+ function findCssFiles(dir) {
142
+ const files = []
143
+ const stack = [dir]
144
+ while (stack.length > 0) {
145
+ const current = stack.pop()
146
+ if (!current || !fs.existsSync(current)) continue
147
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
148
+ const full = path.join(current, entry.name)
149
+ if (entry.isDirectory()) stack.push(full)
150
+ else if (entry.isFile() && entry.name.endsWith('.css')) files.push(full)
151
+ }
152
+ }
153
+ files.sort()
154
+ return files
155
+ }
156
+
157
+ function stripCssComments(css) {
158
+ return css.replace(/\/\*[\s\S]*?\*\//g, '')
159
+ }
160
+
161
+ function unquoteCss(value) {
162
+ const text = value.trim()
163
+ if (text.length >= 2 && ((text[0] === '"' && text[text.length - 1] === '"') || (text[0] === "'" && text[text.length - 1] === "'"))) {
164
+ return text.slice(1, -1)
165
+ }
166
+ return text
167
+ }
168
+
169
+ function firstFontFamily(value) {
170
+ let text = value.trim()
171
+ if (!text) return null
172
+ if (text[0] === '"' || text[0] === "'") {
173
+ const quote = text[0]
174
+ const end = text.indexOf(quote, 1)
175
+ return end > 0 ? text.slice(1, end) : null
176
+ }
177
+ const comma = text.indexOf(',')
178
+ if (comma >= 0) text = text.slice(0, comma)
179
+ const family = unquoteCss(text.trim())
180
+ return family && !genericFamilies.has(family.toLowerCase()) ? family : null
181
+ }
182
+
183
+ function parseViewportWidths(values) {
184
+ const widths = []
185
+ for (const value of values) {
186
+ for (const part of String(value).split(',')) {
187
+ const width = Math.round(Number(part.trim()))
188
+ if (width > 0 && !widths.includes(width)) widths.push(width)
189
+ }
190
+ }
191
+ return widths
192
+ }
193
+
194
+ function parseDevicePixelRatios(values) {
195
+ const ratios = []
196
+ for (const value of values) {
197
+ for (const part of String(value).split(',')) {
198
+ const ratio = Number(part.trim())
199
+ if (ratio > 0 && !ratios.includes(ratio)) ratios.push(ratio)
200
+ }
201
+ }
202
+ return ratios.length > 0 ? ratios : [1]
203
+ }
204
+
205
+ function splitTopLevel(value, delimiter) {
206
+ const out = []
207
+ let start = 0
208
+ let depth = 0
209
+ for (let i = 0; i < value.length; i++) {
210
+ const char = value[i]
211
+ if (char === '(') depth++
212
+ else if (char === ')' && depth > 0) depth--
213
+ else if (char === delimiter && depth === 0) {
214
+ out.push(value.slice(start, i).trim())
215
+ start = i + 1
216
+ }
217
+ }
218
+ out.push(value.slice(start).trim())
219
+ return out.filter(Boolean)
220
+ }
221
+
222
+ function functionInner(value, name) {
223
+ const text = value.trim()
224
+ const prefix = `${name}(`
225
+ if (!text.toLowerCase().startsWith(prefix) || !text.endsWith(')')) return null
226
+ return text.slice(prefix.length, -1)
227
+ }
228
+
229
+ function uniqueSizes(sizes) {
230
+ return sizes
231
+ .map((size) => Math.round(size))
232
+ .filter((size, index, list) => size > 0 && list.indexOf(size) === index)
233
+ }
234
+
235
+ function viewportCombos() {
236
+ if (viewportWidths.length === 0 && viewportHeights.length === 0) return [{ width: 0, height: 0 }]
237
+ if (viewportWidths.length === 0) return viewportHeights.map((height) => ({ width: 0, height }))
238
+ if (viewportHeights.length === 0) return viewportWidths.map((width) => ({ width, height: 0 }))
239
+ if (viewportWidths.length === viewportHeights.length) {
240
+ return viewportWidths.map((width, index) => ({ width, height: viewportHeights[index] }))
241
+ }
242
+ const combos = []
243
+ for (const width of viewportWidths) {
244
+ for (const height of viewportHeights) combos.push({ width, height })
245
+ }
246
+ return combos
247
+ }
248
+
249
+ function defaultFontSizes() {
250
+ return uniqueSizes(devicePixelRatios.map((dpr) => 16 * dpr))
251
+ }
252
+
253
+ // Resolve a single CSS length token to ONE physical-pixel size for a specific
254
+ // (viewportWidth, viewportHeight, devicePixelRatio) combination, mirroring the
255
+ // runtime's parseLengthForNode. Returns a float (caller rounds) or null when the
256
+ // token isn't a recognized length.
257
+ //
258
+ // Crucially, clamp()/min()/max() resolve to their actual CSS *value* for the
259
+ // combination — `clamp(a, b, c)` = `max(a, min(b, c))` — NOT the union of their
260
+ // literal arguments. The old union behavior baked an atlas for every literal,
261
+ // so `clamp(46px, 17vh, 220px)` on a 200x200 board baked a 330px (220px x 1.5
262
+ // DPR) glyph atlas (~3.4 MB) that can NEVER render there (the clamp resolves to
263
+ // ~69px). Resolving correctly bakes only the size the device actually requests,
264
+ // which is also an exact atlas hit (the board's physical viewport divided by DPR
265
+ // equals its logical viewport, so vh-no-DPR here == logical-vh x DPR at runtime).
266
+ function resolveLengthForCombo(value, width, height, dpr) {
267
+ const text = value.trim()
268
+ const absoluteMatch = text.match(/^(\d+(?:\.\d+)?)(px)?$/)
269
+ if (absoluteMatch) {
270
+ const cssPx = Number(absoluteMatch[1])
271
+ return absoluteMatch[2] ? cssPx * dpr : cssPx
272
+ }
273
+ const vwMatch = text.match(/^(\d+(?:\.\d+)?)vw$/)
274
+ if (vwMatch) return (Number(vwMatch[1]) * width) / 100
275
+ const vhMatch = text.match(/^(\d+(?:\.\d+)?)vh$/)
276
+ if (vhMatch) return (Number(vhMatch[1]) * height) / 100
277
+ const vminMatch = text.match(/^(\d+(?:\.\d+)?)vmin$/)
278
+ if (vminMatch) return (Number(vminMatch[1]) * Math.min(width, height)) / 100
279
+ const vmaxMatch = text.match(/^(\d+(?:\.\d+)?)vmax$/)
280
+ if (vmaxMatch) return (Number(vmaxMatch[1]) * Math.max(width, height)) / 100
281
+ const clampInner = functionInner(text, 'clamp')
282
+ if (clampInner !== null) {
283
+ const parts = splitTopLevel(clampInner, ',')
284
+ if (parts.length !== 3) return null
285
+ const resolved = parts.map((part) => resolveLengthForCombo(part, width, height, dpr))
286
+ if (resolved.some((size) => size === null)) return null
287
+ const [lo, preferred, hi] = resolved
288
+ return Math.max(lo, Math.min(preferred, hi))
289
+ }
290
+ const minInner = functionInner(text, 'min')
291
+ if (minInner !== null) {
292
+ const resolved = splitTopLevel(minInner, ',').map((part) => resolveLengthForCombo(part, width, height, dpr))
293
+ if (resolved.length === 0 || resolved.some((size) => size === null)) return null
294
+ return Math.min(...resolved)
295
+ }
296
+ const maxInner = functionInner(text, 'max')
297
+ if (maxInner !== null) {
298
+ const resolved = splitTopLevel(maxInner, ',').map((part) => resolveLengthForCombo(part, width, height, dpr))
299
+ if (resolved.length === 0 || resolved.some((size) => size === null)) return null
300
+ return Math.max(...resolved)
301
+ }
302
+ return null
303
+ }
304
+
305
+ // Bake one atlas size per distinct resolved value across every configured
306
+ // (viewport, DPR) combination. Each (width, height) pair is one board; absolute
307
+ // px is viewport-independent (width/height fall back to 0 so it still resolves).
308
+ // uniqueSizes drops non-positive (viewport-relative tokens with no viewport) and
309
+ // duplicate sizes. This mirrors the runtime so each requested px hits an atlas.
310
+ function parseFontSizes(value) {
311
+ const combos = viewportCombos()
312
+ const sizes = []
313
+ for (const dpr of devicePixelRatios) {
314
+ for (const { width, height } of combos) {
315
+ const size = resolveLengthForCombo(value, width, height, dpr)
316
+ if (size !== null) sizes.push(size)
317
+ }
318
+ }
319
+ return uniqueSizes(sizes)
320
+ }
321
+
322
+ function simpleCssSelectors(selectorText) {
323
+ return selectorText
324
+ .split(',')
325
+ .map((selector) => selector.trim())
326
+ .filter((selector) => /^\.?[A-Za-z][A-Za-z0-9_-]*$/.test(selector))
327
+ }
328
+
329
+ function rememberFontTuple(tuples, fontFaces, family, sizePx, charset) {
330
+ if (!family || !fontFaces.has(family)) return
331
+ const resolvedSize = sizePx ?? defaultFontSizes()[0] ?? 16
332
+ const key = `${family}\0${resolvedSize}`
333
+ const existing = tuples.get(key)
334
+ const merged = existing ? mergeCharsets(existing.charset, charset ?? null) : (charset ?? null)
335
+ tuples.set(key, { family, sizePx: resolvedSize, charset: merged })
336
+ }
337
+
338
+ function rememberFontTuples(tuples, fontFaces, family, sizePxs, charset) {
339
+ if (!sizePxs || sizePxs.length === 0) {
340
+ for (const sizePx of defaultFontSizes()) rememberFontTuple(tuples, fontFaces, family, sizePx, null)
341
+ return
342
+ }
343
+ for (const sizePx of sizePxs) rememberFontTuple(tuples, fontFaces, family, sizePx, charset)
344
+ }
345
+
346
+ function parseFontFaces(css, cssFile) {
347
+ const faces = []
348
+ const cssDirName = path.dirname(cssFile)
349
+ const re = /@font-face\s*\{([^}]+)\}/gi
350
+ let match
351
+ while ((match = re.exec(css)) !== null) {
352
+ const block = match[1]
353
+ const familyMatch = block.match(/font-family\s*:\s*(?:"([^"]+)"|'([^']+)'|([^;]+))/i)
354
+ const srcMatch = block.match(/src\s*:\s*url\(\s*(?:"([^"]+)"|'([^']+)'|([^)"']+))\s*\)/i)
355
+ const family = familyMatch ? unquoteCss((familyMatch[1] ?? familyMatch[2] ?? familyMatch[3]).trim()) : null
356
+ const rawSrc = srcMatch ? (srcMatch[1] ?? srcMatch[2] ?? srcMatch[3]).trim() : null
357
+ if (!family || !rawSrc || /^https?:\/\//i.test(rawSrc) || rawSrc.startsWith('data:')) continue
358
+ const cleanSrc = rawSrc.split(/[?#]/, 1)[0]
359
+ const resolved = path.resolve(cssDirName, cleanSrc)
360
+ if (fs.existsSync(resolved)) faces.push({ family, src: resolved })
361
+ }
362
+ return faces
363
+ }
364
+
365
+ function collectSourceFontFaces() {
366
+ // The app, then whatever the project shares between its apps. The project
367
+ // is the directory this ran in -- it used to arrive as --repo-root, which
368
+ // defaulted to this package's own parent and found nobody's components.
369
+ const sourceDirs = [appDir, path.join(process.cwd(), 'components')]
370
+ const fontFaces = new Map()
371
+ for (const dir of sourceDirs) {
372
+ for (const file of findCssFiles(dir)) {
373
+ const css = stripCssComments(fs.readFileSync(file, 'utf8'))
374
+ for (const face of parseFontFaces(css, file)) {
375
+ if (!fontFaces.has(face.family)) fontFaces.set(face.family, face.src)
376
+ }
377
+ }
378
+ }
379
+ return fontFaces
380
+ }
381
+
382
+ function collectUsedFontTuples(fontFaces) {
383
+ const tuples = new Map()
384
+ const usedFamilies = new Set()
385
+ const inheritedSizes = new Map()
386
+ for (const file of findCssFiles(cssDir)) {
387
+ const css = stripCssComments(fs.readFileSync(file, 'utf8'))
388
+ const selectorStates = new Map()
389
+ const ruleRe = /([^{}]+)\{([^{}]*)\}/g
390
+ let match
391
+ while ((match = ruleRe.exec(css)) !== null) {
392
+ const selector = match[1].trim()
393
+ if (selector.startsWith('@')) continue
394
+ const selectors = simpleCssSelectors(selector)
395
+ if (selectors.length === 0) continue
396
+ let family = null
397
+ let sizePxs = null
398
+ let charset = null
399
+ for (const declaration of match[2].split(';')) {
400
+ const colon = declaration.indexOf(':')
401
+ if (colon <= 0) continue
402
+ const name = declaration.slice(0, colon).trim().toLowerCase()
403
+ const value = declaration.slice(colon + 1).trim()
404
+ if (name === 'font-family') family = firstFontFamily(value)
405
+ else if (name === 'font-size') sizePxs = parseFontSizes(value)
406
+ else if (name === '--gea-font-charset') charset = parseFontCharset(value)
407
+ }
408
+ // Accumulate every family/size a selector is ever assigned: repeated
409
+ // rules are usually media-query branches (the block regex sees through
410
+ // `@media { ... }` wrappers), and the generator can't evaluate media
411
+ // conditions, so every branch needs an atlas.
412
+ for (const selector of selectors) {
413
+ const state = selectorStates.get(selector) ?? { families: new Set(), sizePxs: new Set(), charset: undefined }
414
+ if (family) state.families.add(family)
415
+ // Only a rule that names a size has an opinion about that size's atlas.
416
+ // A rule on the same selector that just sets a colour must not widen it.
417
+ if (sizePxs) {
418
+ for (const sizePx of sizePxs) state.sizePxs.add(sizePx)
419
+ state.charset = state.charset === undefined ? charset : mergeCharsets(state.charset, charset)
420
+ }
421
+ selectorStates.set(selector, state)
422
+ }
423
+ }
424
+ for (const state of selectorStates.values()) {
425
+ const charset = state.charset ?? null
426
+ for (const family of state.families) {
427
+ if (fontFaces.has(family)) usedFamilies.add(family)
428
+ rememberFontTuples(tuples, fontFaces, family, [...state.sizePxs], charset)
429
+ }
430
+ if (state.families.size === 0) {
431
+ for (const sizePx of state.sizePxs) {
432
+ const merged = inheritedSizes.has(sizePx) ? mergeCharsets(inheritedSizes.get(sizePx), charset) : charset
433
+ inheritedSizes.set(sizePx, merged)
434
+ }
435
+ }
436
+ }
437
+ }
438
+ // A font-size on a selector that never declares font-family applies to text
439
+ // whose family is inherited from an ancestor element. Which ancestor is
440
+ // unknowable from CSS alone, so bake that size for every family the app
441
+ // uses — otherwise the runtime's nearest-size fallback silently renders
442
+ // such text from whatever atlas exists (e.g. the family's default 16px).
443
+ // Sizes declared alongside a family stay exact pairs: spreading them across
444
+ // families would bake atlases no element resolves to (a display family's
445
+ // huge numeral size costs hundreds of KB at the body family).
446
+ if (inheritedSizes.size > 0) {
447
+ for (const family of usedFamilies) {
448
+ for (const [sizePx, charset] of inheritedSizes) {
449
+ rememberFontTuples(tuples, fontFaces, family, [sizePx], charset)
450
+ }
451
+ }
452
+ }
453
+ return [...tuples.values()].sort((a, b) => a.family.localeCompare(b.family) || a.sizePx - b.sizePx)
454
+ }
455
+
456
+ function loadOpentype() {
457
+ const packageJsons = [
458
+ path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json'),
459
+ path.join(appDir, 'package.json'),
460
+ path.join(process.cwd(), 'package.json'),
461
+ ]
462
+ for (const packageJson of packageJsons) {
463
+ if (!fs.existsSync(packageJson)) continue
464
+ try {
465
+ return createRequire(packageJson)('opentype.js')
466
+ } catch {
467
+ // Try the next package root.
468
+ }
469
+ }
470
+ fail('missing opentype.js from the @geastack/core installation')
471
+ }
472
+
473
+ function rasterizeFont(opentype, fontPath, family, familyId, sizePx, fontId, charset) {
474
+ const fontBuffer = fs.readFileSync(fontPath)
475
+ const arrayBuffer = fontBuffer.buffer.slice(fontBuffer.byteOffset, fontBuffer.byteOffset + fontBuffer.byteLength)
476
+ const font = opentype.parse(arrayBuffer)
477
+
478
+ const scale = sizePx / font.unitsPerEm
479
+ const ascender = Math.ceil(font.ascender * scale)
480
+ const descender = Math.ceil(Math.abs(font.descender * scale))
481
+ const lineHeight = ascender + descender
482
+ const glyphs = []
483
+ const glyphBitmaps = []
484
+
485
+ const codepoints = charset ? [...charset].sort((a, b) => a - b) : baselineCodepoints()
486
+
487
+ for (const cp of codepoints) {
488
+ const glyph = font.charToGlyph(String.fromCodePoint(cp))
489
+ const advance = Math.round((glyph.advanceWidth ?? 0) * scale)
490
+ const bounds = glyph.getBoundingBox()
491
+ const x0 = Math.floor(bounds.x1 * scale)
492
+ const y0 = Math.floor(-bounds.y2 * scale)
493
+ const x1 = Math.ceil(bounds.x2 * scale)
494
+ const y1 = Math.ceil(-bounds.y1 * scale)
495
+ const gw = Math.max(x1 - x0, 0)
496
+ const gh = Math.max(y1 - y0, 0)
497
+ const bitmap = new Uint8Array(gw * gh)
498
+
499
+ if (gw > 0 && gh > 0) {
500
+ rasterizeGlyph(glyph.getPath(0, 0, sizePx).commands, bitmap, gw, gh, x0, y0)
501
+ }
502
+
503
+ glyphs.push({ codepoint: cp, sourceX: 0, sourceY: 0, width: gw, height: gh, advance, bearingX: x0, bearingY: -y0 })
504
+ glyphBitmaps.push({ data: bitmap, width: gw, height: gh })
505
+ }
506
+
507
+ const { atlasWidth, atlasHeight } = packGlyphAtlas(glyphs)
508
+ if (atlasWidth === 0 || atlasHeight === 0) {
509
+ return {
510
+ id: fontId,
511
+ family,
512
+ familyId,
513
+ sizePx,
514
+ lineHeight,
515
+ ascender,
516
+ descender,
517
+ glyphs,
518
+ atlasWidth: 1,
519
+ atlasHeight: 1,
520
+ atlasBits,
521
+ atlasData: new Uint8Array(1),
522
+ }
523
+ }
524
+
525
+ const atlasData = new Uint8Array(atlasWidth * atlasHeight)
526
+ for (let i = 0; i < glyphs.length; i += 1) {
527
+ const glyph = glyphs[i]
528
+ const bitmap = glyphBitmaps[i]
529
+ // Quantize per glyph, not across the packed atlas: the neighbourhood rules
530
+ // below must not see a neighbouring glyph's ink through the 1px padding.
531
+ const source = atlasBits === 2 ? quantizeCoverageToLevels(bitmap.data, bitmap.width, bitmap.height) : bitmap.data
532
+ for (let row = 0; row < bitmap.height; row += 1) {
533
+ for (let col = 0; col < bitmap.width; col += 1) {
534
+ atlasData[(glyph.sourceY + row) * atlasWidth + glyph.sourceX + col] = source[row * bitmap.width + col]
535
+ }
536
+ }
537
+ }
538
+
539
+ return {
540
+ id: fontId,
541
+ family,
542
+ familyId,
543
+ sizePx,
544
+ lineHeight,
545
+ ascender,
546
+ descender,
547
+ glyphs,
548
+ atlasWidth,
549
+ atlasHeight,
550
+ atlasBits,
551
+ atlasData: atlasBits === 2 ? packLevelsTo2Bit(atlasData, atlasWidth, atlasHeight) : atlasData,
552
+ }
553
+ }
554
+
555
+ // Offline quantization of 8-bit supersampled coverage to the four levels of a
556
+ // 4-gray panel. Level 3 = full ink, level 0 = bare paper; the runtime maps a
557
+ // level back out as level * 85, so 3 -> 255 and 0 -> 0 exactly.
558
+ //
559
+ // The thresholds are deliberately NON-linear — 208/128/48, so bands of width
560
+ // 48/80/80/48 rather than an even quartering of the range at 64/128/192. They
561
+ // are the mirror image (in the coverage domain, coverage = 255 - luma) of the
562
+ // panel-side curve documented in targets/esp32-s3-epaper-1.54/main/display.cpp
563
+ // `grayLevelFromLuma`, and exist for the same reason: glyph rim pixels cluster
564
+ // near the extremes, so quarter-width outer bands snap almost every antialiased
565
+ // edge to solid ink or to nothing and small text reads as if it were 1-bit.
566
+ // Narrow outer bands and wide middle ones pull those rim pixels into the grey
567
+ // levels, which is the whole point of having them. It also lands close to
568
+ // round-to-nearest of the four output values (boundaries 42.5/127.5/212.5), so
569
+ // there is no systematic darkening or lightening across a stroke.
570
+ //
571
+ // Consequences worth stating explicitly:
572
+ // - A fully covered interior pixel (255) is always level 3. Stems stay solid,
573
+ // and are never dithered — there is no dithering here at all, ordered or
574
+ // diffused. Dithering a glyph rim scatters every letter into speckle on a
575
+ // 4-level panel (same reasoning as `kDitherToPanel` in that display.cpp).
576
+ // - Levels 1 and 2 are therefore reachable only from partial coverage, i.e.
577
+ // they are reserved for edge/rim pixels by construction.
578
+ // - The 48 floor drops coverage below ~19% instead of promoting it to level 1
579
+ // (85/255 = 33%), so faint spill outside a stroke does not fatten it.
580
+ function quantizeCoverageToLevels(coverage, width, height) {
581
+ const levels = new Uint8Array(width * height)
582
+ for (let i = 0; i < coverage.length; i += 1) {
583
+ const value = coverage[i]
584
+ levels[i] = value >= 208 ? 3 : value >= 128 ? 2 : value >= 48 ? 1 : 0
585
+ }
586
+
587
+ // Neighbourhood fixups, read from the pass above and written to a copy so the
588
+ // result does not depend on scan order.
589
+ const result = Uint8Array.from(levels)
590
+ const levelAt = (x, y) => (x < 0 || y < 0 || x >= width || y >= height ? 0 : levels[y * width + x])
591
+ const coverageAt = (x, y) => (x < 0 || y < 0 || x >= width || y >= height ? 0 : coverage[y * width + x])
592
+ for (let y = 0; y < height; y += 1) {
593
+ for (let x = 0; x < width; x += 1) {
594
+ const index = y * width + x
595
+ // Stem solidity: a pixel enclosed by fully-inked neighbours is interior,
596
+ // whatever rounding did to its own coverage. Forcing it to level 3 stops
597
+ // rasterizer noise from punching grey pinholes down the middle of a stem.
598
+ if (
599
+ levels[index] >= 2 &&
600
+ coverageAt(x - 1, y) >= 208 &&
601
+ coverageAt(x + 1, y) >= 208 &&
602
+ coverageAt(x, y - 1) >= 208 &&
603
+ coverageAt(x, y + 1) >= 208
604
+ ) {
605
+ result[index] = 3
606
+ continue
607
+ }
608
+ // Speckle removal: a faint pixel with no ink at all around it is a stray
609
+ // dot off a curve tip, not a stroke. On four levels it reads as dirt.
610
+ if (levels[index] !== 1) continue
611
+ let neighbours = 0
612
+ for (let dy = -1; dy <= 1; dy += 1) {
613
+ for (let dx = -1; dx <= 1; dx += 1) {
614
+ if (dx !== 0 || dy !== 0) neighbours += levelAt(x + dx, y + dy)
615
+ }
616
+ }
617
+ if (neighbours === 0) result[index] = 0
618
+ }
619
+ }
620
+ return result
621
+ }
622
+
623
+ // Pack 0..3 levels four to a byte, rows byte-aligned (stride = ceil(width / 4)).
624
+ // The leftmost pixel of a byte occupies the most significant bit pair, so a hex
625
+ // dump of a row reads left to right like the glyph does. Kept in sync with
626
+ // `RasterizedFont::coverage` in packages/engine/rasterized_font.cpp.
627
+ function packLevelsTo2Bit(levels, width, height) {
628
+ const stride = (width + 3) >> 2
629
+ const packed = new Uint8Array(stride * height)
630
+ for (let y = 0; y < height; y += 1) {
631
+ for (let x = 0; x < width; x += 1) {
632
+ const level = levels[y * width + x] & 0x3
633
+ packed[y * stride + (x >> 2)] |= level << ((3 - (x & 3)) * 2)
634
+ }
635
+ }
636
+ return packed
637
+ }
638
+
639
+ function rasterizeGlyph(cmds, bitmap, gw, gh, x0, y0) {
640
+ const ss = glyphSupersample
641
+ const ssWidth = gw * ss
642
+ const ssHeight = gh * ss
643
+ const hires = new Uint8Array(ssWidth * ssHeight)
644
+ const segments = pathCommandsToSegments(cmds, x0, y0)
645
+ const maxEdgesPerRow = 128
646
+ const scanlines = new Float32Array(ssHeight * maxEdgesPerRow)
647
+ const scanCounts = new Int32Array(ssHeight)
648
+
649
+ for (const [sx0, sy0, sx1, sy1] of segments) {
650
+ const hsy0 = sy0 * ss
651
+ const hsy1 = sy1 * ss
652
+ const hsx0 = sx0 * ss
653
+ const hsx1 = sx1 * ss
654
+ const minRow = Math.max(0, Math.floor(Math.min(hsy0, hsy1)))
655
+ const maxRow = Math.min(ssHeight - 1, Math.ceil(Math.max(hsy0, hsy1)))
656
+ for (let row = minRow; row <= maxRow; row += 1) {
657
+ const y = row + 0.5
658
+ if ((hsy0 <= y && hsy1 > y) || (hsy1 <= y && hsy0 > y)) {
659
+ const pos = hsx0 + ((y - hsy0) / (hsy1 - hsy0)) * (hsx1 - hsx0)
660
+ const count = scanCounts[row]
661
+ if (count < maxEdgesPerRow) scanlines[row * maxEdgesPerRow + scanCounts[row]++] = pos
662
+ }
663
+ }
664
+ }
665
+
666
+ for (let row = 0; row < ssHeight; row += 1) {
667
+ const count = scanCounts[row]
668
+ if (count < 2) continue
669
+ const edges = Array.from(scanlines.slice(row * maxEdgesPerRow, row * maxEdgesPerRow + count)).sort((a, b) => a - b)
670
+ for (let i = 0; i < edges.length - 1; i += 2) {
671
+ // At 1:1 (no supersampling) ink a pixel only when its CENTER falls inside
672
+ // the span — the standard 50% coverage rule — so glyphs keep their true
673
+ // weight. Rounding the span outward (floor/ceil) fattens every stroke by
674
+ // up to a pixel per side and reads as bold. Supersampled paths keep the
675
+ // outward rounding: it's averaged away on downsample and avoids dropping
676
+ // thin sub-pixel coverage.
677
+ const left = ss === 1
678
+ ? Math.max(0, Math.ceil(edges[i] - 0.5))
679
+ : Math.max(0, Math.floor(edges[i]))
680
+ const right = ss === 1
681
+ ? Math.min(ssWidth - 1, Math.floor(edges[i + 1] - 0.5))
682
+ : Math.min(ssWidth - 1, Math.ceil(edges[i + 1]))
683
+ for (let col = left; col <= right; col += 1) hires[row * ssWidth + col] = 1
684
+ }
685
+ }
686
+
687
+ for (let py = 0; py < gh; py += 1) {
688
+ for (let px = 0; px < gw; px += 1) {
689
+ let sum = 0
690
+ for (let sy = 0; sy < ss; sy += 1) {
691
+ for (let sx = 0; sx < ss; sx += 1) sum += hires[(py * ss + sy) * ssWidth + (px * ss + sx)]
692
+ }
693
+ bitmap[py * gw + px] = Math.round((sum / (ss * ss)) * 255)
694
+ }
695
+ }
696
+ }
697
+
698
+ function pathCommandsToSegments(cmds, x0, y0) {
699
+ const segments = []
700
+ let curX = 0
701
+ let curY = 0
702
+ let startX = 0
703
+ let startY = 0
704
+
705
+ for (const cmd of cmds) {
706
+ if (cmd.type === 'M') {
707
+ curX = cmd.x - x0
708
+ curY = cmd.y - y0
709
+ startX = curX
710
+ startY = curY
711
+ } else if (cmd.type === 'L') {
712
+ const endX = cmd.x - x0
713
+ const endY = cmd.y - y0
714
+ segments.push([curX, curY, endX, endY])
715
+ curX = endX
716
+ curY = endY
717
+ } else if (cmd.type === 'C') {
718
+ for (let s = 1; s <= 12; s += 1) {
719
+ const t = s / 12
720
+ const mt = 1 - t
721
+ const endX = mt ** 3 * curX + 3 * mt * mt * t * (cmd.x1 - x0) + 3 * mt * t * t * (cmd.x2 - x0) + t ** 3 * (cmd.x - x0)
722
+ const endY = mt ** 3 * curY + 3 * mt * mt * t * (cmd.y1 - y0) + 3 * mt * t * t * (cmd.y2 - y0) + t ** 3 * (cmd.y - y0)
723
+ segments.push([curX, curY, endX, endY])
724
+ curX = endX
725
+ curY = endY
726
+ }
727
+ } else if (cmd.type === 'Q') {
728
+ for (let s = 1; s <= 8; s += 1) {
729
+ const t = s / 8
730
+ const mt = 1 - t
731
+ const endX = mt * mt * curX + 2 * mt * t * (cmd.x1 - x0) + t * t * (cmd.x - x0)
732
+ const endY = mt * mt * curY + 2 * mt * t * (cmd.y1 - y0) + t * t * (cmd.y - y0)
733
+ segments.push([curX, curY, endX, endY])
734
+ curX = endX
735
+ curY = endY
736
+ }
737
+ } else if (cmd.type === 'Z') {
738
+ if (curX !== startX || curY !== startY) segments.push([curX, curY, startX, startY])
739
+ curX = startX
740
+ curY = startY
741
+ }
742
+ }
743
+
744
+ return segments
745
+ }
746
+
747
+ function packGlyphAtlas(glyphs) {
748
+ const padding = 1
749
+ const maxAtlasWidth = 512
750
+ let atlasWidth = 0
751
+ let curX = 0
752
+ let curY = 0
753
+ let maxRowHeight = 0
754
+
755
+ for (const glyph of glyphs) {
756
+ if (curX + glyph.width + padding > maxAtlasWidth) {
757
+ curX = 0
758
+ curY += maxRowHeight + padding
759
+ maxRowHeight = 0
760
+ }
761
+ glyph.sourceX = curX
762
+ glyph.sourceY = curY
763
+ if (curX + glyph.width > atlasWidth) atlasWidth = curX + glyph.width
764
+ if (glyph.height > maxRowHeight) maxRowHeight = glyph.height
765
+ curX += glyph.width + padding
766
+ }
767
+
768
+ return { atlasWidth, atlasHeight: curY + maxRowHeight }
769
+ }
770
+
771
+ function cppString(value) {
772
+ return JSON.stringify(value)
773
+ }
774
+
775
+ function generatedHeader(fontCount, familyCount, options = {}) {
776
+ const lines = [
777
+ '#pragma once',
778
+ '#define GEA_EMBEDDED_HAS_GENERATED_FONTS 1',
779
+ '#include "graphics/font.h"',
780
+ `#define GEA_EMBEDDED_FONT_COUNT ${fontCount}`,
781
+ `#define GEA_EMBEDDED_FONT_FAMILY_COUNT ${familyCount}`,
782
+ ]
783
+ if (options.runtimeTtfFonts) lines.push('#define GEA_EMBEDDED_HAS_RUNTIME_TTF_FONTS 1')
784
+ lines.push('')
785
+ return lines.join('\n')
786
+ }
787
+
788
+ function fontFunctionNames() {
789
+ if (!symbolPrefix) {
790
+ return {
791
+ namespaceOpen: 'namespace gea::framework::graphics::generated {',
792
+ namespaceClose: '} // namespace gea::framework::graphics::generated',
793
+ ensureLinked: 'ensureLinked',
794
+ lookupFont: 'lookupFont',
795
+ lookupFontForFamily: 'lookupFontForFamily',
796
+ lookupFontFamily: 'lookupFontFamily',
797
+ lookupFontFamilyName: 'lookupFontFamilyName',
798
+ lookupRuntimeTtfFontForFamily: 'lookupRuntimeTtfFontForFamily',
799
+ }
800
+ }
801
+ return {
802
+ namespaceOpen: '',
803
+ namespaceClose: '',
804
+ ensureLinked: `${symbolPrefix}_ensure_linked_fonts`,
805
+ lookupFont: `${symbolPrefix}_lookup_font`,
806
+ lookupFontForFamily: `${symbolPrefix}_lookup_font_for_family`,
807
+ lookupFontFamily: `${symbolPrefix}_lookup_font_family`,
808
+ lookupFontFamilyName: `${symbolPrefix}_lookup_font_family_name`,
809
+ lookupRuntimeTtfFontForFamily: `${symbolPrefix}_lookup_runtime_ttf_font_for_family`,
810
+ }
811
+ }
812
+
813
+ function emitEmptyFontLookup(lines, names) {
814
+ lines.push(`void ${names.ensureLinked}()`)
815
+ lines.push('{')
816
+ lines.push('}')
817
+ lines.push('')
818
+ lines.push(`const gea::framework::graphics::RasterizedFontData *${names.lookupFont}(int fontId)`)
819
+ lines.push('{')
820
+ lines.push(' (void)fontId;')
821
+ lines.push(' return nullptr;')
822
+ lines.push('}')
823
+ lines.push('')
824
+ lines.push(`const gea::framework::graphics::RasterizedFontData *${names.lookupFontForFamily}(int familyId, int sizePx)`)
825
+ lines.push('{')
826
+ lines.push(' (void)familyId;')
827
+ lines.push(' (void)sizePx;')
828
+ lines.push(' return nullptr;')
829
+ lines.push('}')
830
+ lines.push('')
831
+ lines.push(`int ${names.lookupFontFamily}(const char *family)`)
832
+ lines.push('{')
833
+ lines.push(' (void)family;')
834
+ lines.push(' return -1;')
835
+ lines.push('}')
836
+ lines.push('')
837
+ lines.push(`const char *${names.lookupFontFamilyName}(int familyId)`)
838
+ lines.push('{')
839
+ lines.push(' (void)familyId;')
840
+ lines.push(' return nullptr;')
841
+ lines.push('}')
842
+ lines.push('')
843
+ }
844
+
845
+ function generatedCpp(fonts, families) {
846
+ const names = fontFunctionNames()
847
+ const lines = [
848
+ '#include "gea_embedded_font_generated.h"',
849
+ '#include <cstring>',
850
+ '',
851
+ ]
852
+ if (names.namespaceOpen) {
853
+ lines.push(names.namespaceOpen)
854
+ lines.push('')
855
+ } else {
856
+ lines.push('using gea::framework::graphics::Glyph;')
857
+ lines.push('using gea::framework::graphics::RasterizedFontData;')
858
+ lines.push('')
859
+ }
860
+
861
+ if (fonts.length === 0) {
862
+ emitEmptyFontLookup(lines, names)
863
+ if (names.namespaceClose) lines.push(names.namespaceClose)
864
+ lines.push('')
865
+ return lines.join('\n')
866
+ }
867
+
868
+ // Atlas bitmaps are the bulk of the font footprint and are frequently
869
+ // byte-identical across isolated programs (same family + px size +
870
+ // DPR). Naming each by a content hash and giving it weak (external) linkage —
871
+ // instead of a per-app `static` symbol — lets the linker fold identical
872
+ // copies into one, deduping the bundle with no runtime change. We dedup within
873
+ // this TU too (a weak symbol can't be defined twice in one TU); `font_data`
874
+ // points at the shared symbol either way.
875
+ const atlasSymbols = new Map()
876
+ for (const font of fonts) {
877
+ const hash = createHash('sha1').update(Buffer.from(font.atlasData)).digest('hex').slice(0, 16)
878
+ const symbol = `gea_font_atlas_${hash}`
879
+ font.atlasSymbol = symbol
880
+ if (atlasSymbols.has(hash)) continue
881
+ atlasSymbols.set(hash, symbol)
882
+ // `extern` + initializer: a namespace-scope `const` is internal by default,
883
+ // and `weak` requires external linkage ("weak declaration must be public").
884
+ lines.push(`extern __attribute__((weak)) const std::uint8_t ${symbol}[${font.atlasData.length}] = {`)
885
+ for (let i = 0; i < font.atlasData.length; i += 32) {
886
+ const chunk = Array.from(font.atlasData.slice(i, Math.min(i + 32, font.atlasData.length)))
887
+ lines.push(` ${chunk.map((byte) => `0x${byte.toString(16).padStart(2, '0')}`).join(', ')},`)
888
+ }
889
+ lines.push('};')
890
+ lines.push('')
891
+ }
892
+
893
+ for (const font of fonts) {
894
+ // `atlasBits` defaults to 8 in RasterizedFontData, so an 8-bit atlas leaves
895
+ // the trailing initializer off entirely and keeps this TU byte-identical to
896
+ // what every board generated before 2-bit atlases existed.
897
+ const atlasBitsInit = font.atlasBits === 8 ? '' : `, ${font.atlasBits}`
898
+ lines.push(`static const Glyph font_glyphs_${font.id}[${font.glyphs.length}] = {`)
899
+ for (const glyph of font.glyphs) {
900
+ lines.push(
901
+ ` { ${glyph.codepoint}, ${glyph.sourceX}, ${glyph.sourceY}, ${glyph.width}, ${glyph.height}, ${glyph.advance}, ${glyph.bearingX}, ${glyph.bearingY} },`,
902
+ )
903
+ }
904
+ lines.push('};')
905
+ lines.push('')
906
+
907
+ lines.push(
908
+ `static const RasterizedFontData font_data_${font.id} = { ${font.id}, ${font.sizePx}, ${font.lineHeight}, ${font.ascender}, ${font.descender}, ${font.glyphs.length}, font_glyphs_${font.id}, ${font.atlasWidth}, ${font.atlasHeight}, ${font.atlasSymbol}${atlasBitsInit} };`,
909
+ )
910
+ lines.push('')
911
+ }
912
+
913
+ lines.push('struct GeneratedFontEntry {')
914
+ lines.push(' int familyId;')
915
+ lines.push(' const RasterizedFontData *font;')
916
+ lines.push('};')
917
+ lines.push('')
918
+ lines.push('static const GeneratedFontEntry generated_fonts[] = {')
919
+ for (const font of fonts) lines.push(` { ${font.familyId}, &font_data_${font.id} },`)
920
+ lines.push('};')
921
+ lines.push('')
922
+ lines.push('struct GeneratedFontFamily {')
923
+ lines.push(' int id;')
924
+ lines.push(' const char *name;')
925
+ lines.push('};')
926
+ lines.push('')
927
+ lines.push('static const GeneratedFontFamily generated_families[] = {')
928
+ for (const family of families) lines.push(` { ${family.id}, ${cppString(family.name)} },`)
929
+ lines.push('};')
930
+ lines.push('')
931
+ lines.push(`void ${names.ensureLinked}()`)
932
+ lines.push('{')
933
+ lines.push('}')
934
+ lines.push('')
935
+ lines.push(`const gea::framework::graphics::RasterizedFontData *${names.lookupFont}(int fontId)`)
936
+ lines.push('{')
937
+ lines.push(' for (const auto &entry : generated_fonts) {')
938
+ lines.push(' if (entry.font && entry.font->id == fontId) return entry.font;')
939
+ lines.push(' }')
940
+ lines.push(' return nullptr;')
941
+ lines.push('}')
942
+ lines.push('')
943
+ lines.push(`const gea::framework::graphics::RasterizedFontData *${names.lookupFontForFamily}(int familyId, int sizePx)`)
944
+ lines.push('{')
945
+ lines.push(' const RasterizedFontData *best = nullptr;')
946
+ lines.push(' int bestDiff = 2147483647;')
947
+ lines.push(' for (const auto &entry : generated_fonts) {')
948
+ lines.push(' if (entry.familyId != familyId || !entry.font) continue;')
949
+ lines.push(' int diff = sizePx > 0 ? entry.font->sizePx - sizePx : 0;')
950
+ lines.push(' if (diff < 0) diff = -diff;')
951
+ lines.push(' if (diff < bestDiff) {')
952
+ lines.push(' best = entry.font;')
953
+ lines.push(' bestDiff = diff;')
954
+ lines.push(' }')
955
+ lines.push(' }')
956
+ lines.push(' return best;')
957
+ lines.push('}')
958
+ lines.push('')
959
+ lines.push(`int ${names.lookupFontFamily}(const char *family)`)
960
+ lines.push('{')
961
+ lines.push(' if (!family) return -1;')
962
+ lines.push(' for (const auto &entry : generated_families) {')
963
+ lines.push(' if (std::strcmp(entry.name, family) == 0) return entry.id;')
964
+ lines.push(' }')
965
+ lines.push(' return -1;')
966
+ lines.push('}')
967
+ lines.push('')
968
+ lines.push(`const char *${names.lookupFontFamilyName}(int familyId)`)
969
+ lines.push('{')
970
+ lines.push(' for (const auto &entry : generated_families) {')
971
+ lines.push(' if (entry.id == familyId) return entry.name;')
972
+ lines.push(' }')
973
+ lines.push(' return nullptr;')
974
+ lines.push('}')
975
+ lines.push('')
976
+ if (names.namespaceClose) lines.push(names.namespaceClose)
977
+ lines.push('')
978
+ return lines.join('\n')
979
+ }
980
+
981
+ function generatedRuntimeTtfCpp(runtimeFonts, families) {
982
+ const names = fontFunctionNames()
983
+ const lines = [
984
+ '#include "gea_embedded_font_generated.h"',
985
+ '#include "memory.h"',
986
+ '#include <cstddef>',
987
+ '#include <cstring>',
988
+ '',
989
+ 'extern "C" int stbi_zlib_decode_buffer(char *obuffer, int olen, const char *ibuffer, int ilen);',
990
+ '',
991
+ ]
992
+ if (names.namespaceOpen) {
993
+ lines.push(names.namespaceOpen)
994
+ lines.push('')
995
+ } else {
996
+ lines.push('using gea::framework::graphics::RasterizedFontData;')
997
+ lines.push('')
998
+ }
999
+
1000
+ for (const font of runtimeFonts) {
1001
+ const symbol = `gea_runtime_ttf_${font.id}`
1002
+ const compressed = zlib.deflateSync(font.data, { level: 9 })
1003
+ font.byteSymbol = `${symbol}_deflated`
1004
+ font.byteLengthSymbol = `${symbol}_deflated_len`
1005
+ font.uncompressedLength = font.data.length
1006
+ lines.push(`extern __attribute__((weak)) const std::uint8_t ${font.byteSymbol}[${compressed.length}] = {`)
1007
+ for (let i = 0; i < compressed.length; i += 32) {
1008
+ const chunk = Array.from(compressed.slice(i, Math.min(i + 32, compressed.length)))
1009
+ lines.push(` ${chunk.map((byte) => `0x${byte.toString(16).padStart(2, '0')}`).join(', ')},`)
1010
+ }
1011
+ lines.push('};')
1012
+ lines.push(`extern __attribute__((weak)) const unsigned long ${font.byteLengthSymbol} = ${compressed.length}UL;`)
1013
+ lines.push(`extern __attribute__((weak)) const unsigned long ${symbol}_len = ${font.data.length}UL;`)
1014
+ lines.push('')
1015
+ }
1016
+
1017
+ lines.push('struct GeneratedFontFamily {')
1018
+ lines.push(' int id;')
1019
+ lines.push(' const char *name;')
1020
+ lines.push('};')
1021
+ lines.push('')
1022
+ lines.push('static const GeneratedFontFamily generated_families[] = {')
1023
+ for (const family of families) lines.push(` { ${family.id}, ${cppString(family.name)} },`)
1024
+ lines.push('};')
1025
+ lines.push('')
1026
+ lines.push('struct RuntimeTtfFontEntry {')
1027
+ lines.push(' int familyId;')
1028
+ lines.push(' const std::uint8_t *compressedBytes;')
1029
+ lines.push(' const unsigned long *compressedLength;')
1030
+ lines.push(' unsigned long uncompressedLength;')
1031
+ lines.push(' std::uint8_t *decodedBytes;')
1032
+ lines.push('};')
1033
+ lines.push('')
1034
+ lines.push('static RuntimeTtfFontEntry runtime_ttf_fonts[] = {')
1035
+ for (const font of runtimeFonts) {
1036
+ lines.push(` { ${font.familyId}, ${font.byteSymbol}, &${font.byteLengthSymbol}, ${font.uncompressedLength}UL, nullptr },`)
1037
+ }
1038
+ lines.push('};')
1039
+ lines.push('')
1040
+ lines.push('std::uint8_t *decodeRuntimeTtfFont(RuntimeTtfFontEntry &entry)')
1041
+ lines.push('{')
1042
+ lines.push(' if (entry.decodedBytes) return entry.decodedBytes;')
1043
+ lines.push(' if (!entry.compressedBytes || !entry.compressedLength || *entry.compressedLength == 0UL || entry.uncompressedLength == 0UL) return nullptr;')
1044
+ lines.push(' auto *decoded = static_cast<std::uint8_t *>(gea::framework::memory::Allocator::allocatePreferSpiram(entry.uncompressedLength, alignof(std::max_align_t)));')
1045
+ lines.push(' if (!decoded) return nullptr;')
1046
+ lines.push(' const int written = stbi_zlib_decode_buffer(reinterpret_cast<char *>(decoded),')
1047
+ lines.push(' static_cast<int>(entry.uncompressedLength),')
1048
+ lines.push(' reinterpret_cast<const char *>(entry.compressedBytes),')
1049
+ lines.push(' static_cast<int>(*entry.compressedLength));')
1050
+ lines.push(' if (written != static_cast<int>(entry.uncompressedLength)) {')
1051
+ lines.push(' gea::framework::memory::Allocator::free(decoded);')
1052
+ lines.push(' return nullptr;')
1053
+ lines.push(' }')
1054
+ lines.push(' entry.decodedBytes = decoded;')
1055
+ lines.push(' return entry.decodedBytes;')
1056
+ lines.push('}')
1057
+ lines.push('')
1058
+ lines.push(`void ${names.ensureLinked}()`)
1059
+ lines.push('{')
1060
+ lines.push('}')
1061
+ lines.push('')
1062
+ lines.push(`const gea::framework::graphics::RasterizedFontData *${names.lookupFont}(int fontId)`)
1063
+ lines.push('{')
1064
+ lines.push(' (void)fontId;')
1065
+ lines.push(' return nullptr;')
1066
+ lines.push('}')
1067
+ lines.push('')
1068
+ lines.push(`const gea::framework::graphics::RasterizedFontData *${names.lookupFontForFamily}(int familyId, int sizePx)`)
1069
+ lines.push('{')
1070
+ lines.push(' (void)familyId;')
1071
+ lines.push(' (void)sizePx;')
1072
+ lines.push(' return nullptr;')
1073
+ lines.push('}')
1074
+ lines.push('')
1075
+ lines.push(`int ${names.lookupFontFamily}(const char *family)`)
1076
+ lines.push('{')
1077
+ lines.push(' if (!family) return -1;')
1078
+ lines.push(' for (const auto &entry : generated_families) {')
1079
+ lines.push(' if (std::strcmp(entry.name, family) == 0) return entry.id;')
1080
+ lines.push(' }')
1081
+ lines.push(' return -1;')
1082
+ lines.push('}')
1083
+ lines.push('')
1084
+ lines.push(`const char *${names.lookupFontFamilyName}(int familyId)`)
1085
+ lines.push('{')
1086
+ lines.push(' for (const auto &entry : generated_families) {')
1087
+ lines.push(' if (entry.id == familyId) return entry.name;')
1088
+ lines.push(' }')
1089
+ lines.push(' return nullptr;')
1090
+ lines.push('}')
1091
+ lines.push('')
1092
+ lines.push(`const std::uint8_t *${names.lookupRuntimeTtfFontForFamily}(int familyId, unsigned long *length)`)
1093
+ lines.push('{')
1094
+ lines.push(' for (auto &entry : runtime_ttf_fonts) {')
1095
+ lines.push(' if (entry.familyId != familyId) continue;')
1096
+ lines.push(' if (length) *length = entry.uncompressedLength;')
1097
+ lines.push(' return decodeRuntimeTtfFont(entry);')
1098
+ lines.push(' }')
1099
+ lines.push(' if (length) *length = 0UL;')
1100
+ lines.push(' return nullptr;')
1101
+ lines.push('}')
1102
+ lines.push('')
1103
+ if (names.namespaceClose) lines.push(names.namespaceClose)
1104
+ lines.push('')
1105
+ return lines.join('\n')
1106
+ }
1107
+
1108
+ // These TUs are megabytes of generated byte arrays that no human reads, and
1109
+ // clang-format is quadratic enough on them to cost minutes of CPU on a first
1110
+ // build (15 CPU-minutes on a 2.8 MB asset TU, on every fresh clone and every
1111
+ // CI runner, because the stamp below only helps a machine that already paid
1112
+ // it once). Formatting is therefore opt-in: set GEATSC_FORMAT_CPP=1 when the
1113
+ // generated output is being read by a person.
1114
+ const CPP_FORMAT_EXTENSIONS = /\.(?:c|cc|cpp|cxx|m|mm|h|hh|hpp|hxx)$/i
1115
+ const CPP_FORMAT_STYLE = '{BasedOnStyle: LLVM, SortIncludes: false, ColumnLimit: 160}'
1116
+ let clangFormatUnavailable = false
1117
+
1118
+ function formatCppSourceIfNeeded(filePath, contents) {
1119
+ if (!CPP_FORMAT_EXTENSIONS.test(filePath)) return contents
1120
+ if (process.env.GEATSC_FORMAT_CPP !== '1') return contents
1121
+ if (clangFormatUnavailable) return contents
1122
+ const clangFormat = process.env.CLANG_FORMAT || 'clang-format'
1123
+ try {
1124
+ return execFileSync(clangFormat, [`--style=${CPP_FORMAT_STYLE}`, `--assume-filename=${filePath}`], {
1125
+ input: contents,
1126
+ encoding: 'utf8',
1127
+ maxBuffer: 512 * 1024 * 1024
1128
+ })
1129
+ } catch (error) {
1130
+ if (error?.code === 'ENOENT') {
1131
+ clangFormatUnavailable = true
1132
+ process.stderr.write('generate-gea-embedded-fonts: clang-format not found; generated C++ will be written unformatted\n')
1133
+ return contents
1134
+ }
1135
+ throw error
1136
+ }
1137
+ }
1138
+
1139
+ // clang-format on a multi-megabyte generated font TU costs seconds, and the
1140
+ // result is thrown away on every build where the font set did not change (the
1141
+ // common case: the .cpp is regenerated byte-identically). Formatting BEFORE
1142
+ // comparing paid that cost unconditionally.
1143
+ //
1144
+ // The invariant "what lands on disk is formatted" is preserved by proving the
1145
+ // formatter cannot produce anything new, not by skipping it: a sidecar stamp
1146
+ // records the hash of the UNFORMATTED input, the hash of the formatted bytes
1147
+ // that input produced, and the formatter identity (binary + version + style).
1148
+ // When the current input hashes to the recorded raw hash, the formatter is the
1149
+ // same, and the file on disk still hashes to the recorded formatted hash, then
1150
+ // re-running clang-format would reproduce exactly the bytes already there.
1151
+ // Anything else (missing or stale stamp, edited file, different clang-format)
1152
+ // falls back to the original format-then-compare path.
1153
+ const FORMAT_STAMP_VERSION = 1
1154
+ let clangFormatIdentity
1155
+
1156
+ function sha256(value) {
1157
+ return createHash('sha256').update(value, typeof value === 'string' ? 'utf8' : undefined).digest('hex')
1158
+ }
1159
+
1160
+ function formatterIdentity() {
1161
+ if (clangFormatIdentity !== undefined) return clangFormatIdentity
1162
+ const clangFormat = process.env.CLANG_FORMAT || 'clang-format'
1163
+ try {
1164
+ const version = execFileSync(clangFormat, ['--version'], { encoding: 'utf8' }).trim()
1165
+ clangFormatIdentity = `${clangFormat} ${version} ${CPP_FORMAT_STYLE}`
1166
+ } catch {
1167
+ clangFormatIdentity = ''
1168
+ }
1169
+ return clangFormatIdentity
1170
+ }
1171
+
1172
+ function formatStampPath(filePath) {
1173
+ return path.join(path.dirname(filePath), `.${path.basename(filePath)}.fmtstamp`)
1174
+ }
1175
+
1176
+ function writeFileIfChanged(filePath, contents) {
1177
+ const formats = CPP_FORMAT_EXTENSIONS.test(filePath) && process.env.GEATSC_FORMAT_CPP === '1' && !clangFormatUnavailable
1178
+ if (!formats) {
1179
+ if (fs.existsSync(filePath) && fs.readFileSync(filePath, 'utf8') === contents) return false
1180
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
1181
+ fs.writeFileSync(filePath, contents)
1182
+ return true
1183
+ }
1184
+
1185
+ const stampPath = formatStampPath(filePath)
1186
+ const rawHash = sha256(contents)
1187
+ const identity = formatterIdentity()
1188
+ if (identity) {
1189
+ let stamp
1190
+ try {
1191
+ stamp = JSON.parse(fs.readFileSync(stampPath, 'utf8'))
1192
+ } catch {
1193
+ stamp = undefined
1194
+ }
1195
+ if (stamp?.version === FORMAT_STAMP_VERSION && stamp.raw === rawHash && stamp.identity === identity) {
1196
+ try {
1197
+ if (sha256(fs.readFileSync(filePath)) === stamp.formatted) return false
1198
+ } catch {
1199
+ // fall through and format
1200
+ }
1201
+ }
1202
+ }
1203
+
1204
+ const formattedContents = formatCppSourceIfNeeded(filePath, contents)
1205
+ const changed = !fs.existsSync(filePath) || fs.readFileSync(filePath, 'utf8') !== formattedContents
1206
+ if (changed) {
1207
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
1208
+ fs.writeFileSync(filePath, formattedContents)
1209
+ }
1210
+ if (identity) {
1211
+ try {
1212
+ fs.writeFileSync(
1213
+ stampPath,
1214
+ JSON.stringify({ version: FORMAT_STAMP_VERSION, raw: rawHash, formatted: sha256(formattedContents), identity })
1215
+ )
1216
+ } catch {
1217
+ // a stamp we cannot write only costs the next build the formatting pass
1218
+ }
1219
+ }
1220
+ return changed
1221
+ }
1222
+
1223
+ function writeGeneratedFonts() {
1224
+ const fontFaces = collectSourceFontFaces()
1225
+ const tuples = collectUsedFontTuples(fontFaces)
1226
+ const familyNames = [...new Set(tuples.map((tuple) => tuple.family))].sort()
1227
+ const families = familyNames.map((name, id) => ({ id, name }))
1228
+ const familyIds = new Map(families.map((family) => [family.name, family.id]))
1229
+
1230
+ let fonts = []
1231
+ let runtimeFonts = []
1232
+ if (runtimeTtfFonts) {
1233
+ runtimeFonts = families.map((family) => {
1234
+ const fontPath = fontFaces.get(family.name)
1235
+ if (!fontPath) fail(`missing @font-face src for font family ${family.name}`)
1236
+ return { id: family.id, familyId: family.id, family: family.name, data: fs.readFileSync(fontPath) }
1237
+ })
1238
+ } else if (tuples.length > 0) {
1239
+ const opentype = loadOpentype()
1240
+ let fontId = 0
1241
+ fonts = tuples.map((tuple) => {
1242
+ const fontPath = fontFaces.get(tuple.family)
1243
+ if (!fontPath) fail(`missing @font-face src for font family ${tuple.family}`)
1244
+ return rasterizeFont(opentype, fontPath, tuple.family, familyIds.get(tuple.family), tuple.sizePx, fontId++, tuple.charset)
1245
+ })
1246
+ }
1247
+
1248
+ writeFileIfChanged(outH, generatedHeader(fonts.length, families.length, { runtimeTtfFonts }))
1249
+ writeFileIfChanged(outCpp, runtimeTtfFonts ? generatedRuntimeTtfCpp(runtimeFonts, families) : generatedCpp(fonts, families))
1250
+ }
1251
+
1252
+ writeGeneratedFonts()