@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.
- package/LICENSE +202 -0
- package/README.md +36 -0
- package/app_frame_perf.cpp +77 -0
- package/css.d.ts +1 -0
- package/events.cpp +67 -0
- package/gea_app_entry.cpp +317 -0
- package/gea_sources.mjs +126 -0
- package/gea_sources.sh +36 -0
- package/include/app.h +60 -0
- package/include/apps.h +46 -0
- package/include/audio.h +90 -0
- package/include/ble.h +212 -0
- package/include/bluetooth/hid_profile.h +92 -0
- package/include/camera.h +107 -0
- package/include/canvas.h +290 -0
- package/include/display.h +390 -0
- package/include/display_present.h +869 -0
- package/include/event.h +32 -0
- package/include/events.h +116 -0
- package/include/gea/embedded.h +13 -0
- package/include/gea/reactive_runtime.h +32 -0
- package/include/gea.h +14 -0
- package/include/gea_perf_config.h +96 -0
- package/include/geaos/abi.h +165 -0
- package/include/geolocation.h +126 -0
- package/include/gpio.h +61 -0
- package/include/graphics/font.h +122 -0
- package/include/i2c.h +20 -0
- package/include/image.h +156 -0
- package/include/imu.h +23 -0
- package/include/input.h +33 -0
- package/include/memory.h +52 -0
- package/include/pixel.h +907 -0
- package/include/platform/file_cache.h +18 -0
- package/include/power.h +11 -0
- package/include/runtime.h +83 -0
- package/include/services/app_runner.h +10 -0
- package/include/services/app_state.h +14 -0
- package/include/services/battery_service.h +12 -0
- package/include/services/bluetooth_service.h +13 -0
- package/include/services/diagnostics.h +76 -0
- package/include/services/diagnostics_platform.h +54 -0
- package/include/services/frame_scheduler.h +71 -0
- package/include/services/mirror.h +49 -0
- package/include/services/network_services.h +25 -0
- package/include/services/ota.h +32 -0
- package/include/services/runtime_log.h +11 -0
- package/include/services/storage_service.h +26 -0
- package/include/touch.h +38 -0
- package/include/ui/signal.h +149 -0
- package/include/wifi.h +67 -0
- package/index.d.ts +1940 -0
- package/jsx.d.ts +1 -0
- package/package.json +70 -0
- package/runtime/ble.ts +49 -0
- package/runtime/color.ts +21 -0
- package/runtime/compiler.ts +43 -0
- package/runtime/host.ts +1083 -0
- package/runtime/images.ts +129 -0
- package/runtime/primitives.ts +49 -0
- package/runtime-surface.ts +180 -0
- package/runtime.cpp +431 -0
- package/runtime.ts +34 -0
- package/scripts/build-gea-vite-geatsc.mjs +4514 -0
- package/scripts/dotenv-defines.mjs +98 -0
- package/scripts/gea-bundle-type-hints.mjs +546 -0
- package/scripts/gea-embedded-compat-staging.mjs +17 -0
- package/scripts/gea-embedded-compat-transform.mjs +427 -0
- package/scripts/gea-vite-module-graph-plugin.mjs +578 -0
- package/scripts/generate-gea-embedded-assets.mjs +296 -0
- package/scripts/generate-gea-embedded-fonts.mjs +1252 -0
- package/scripts/generate-installed-app-plan.mjs +55 -0
- 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()
|