@typecad/ui 1.0.0-alpha.12 → 1.0.0-alpha.13

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 (113) hide show
  1. package/assets/fonts/dejavu/DejaVuSans-Bold.ttf +0 -0
  2. package/assets/fonts/dejavu/DejaVuSans-ExtraLight.ttf +0 -0
  3. package/assets/fonts/dejavu/DejaVuSans-Oblique.ttf +0 -0
  4. package/assets/fonts/dejavu/DejaVuSans.ttf +0 -0
  5. package/assets/fonts/dejavu/DejaVuSansMono-Bold.ttf +0 -0
  6. package/assets/fonts/dejavu/DejaVuSansMono.ttf +0 -0
  7. package/assets/fonts/dejavu/LICENSE +187 -0
  8. package/assets/fonts/dejavu/README.md +49 -0
  9. package/dist/engine-index.js +2 -0
  10. package/dist/preview/build-program.d.ts +7 -1
  11. package/dist/preview/build-program.js +94 -15
  12. package/dist/preview/host-gfx.d.ts +3 -0
  13. package/dist/preview/host-gfx.js +30 -3
  14. package/dist/preview/host-ui-runtime.d.ts +115 -0
  15. package/dist/preview/host-ui-runtime.js +928 -46
  16. package/dist/ui-engine/block-layout.js +25 -11
  17. package/dist/ui-engine/color.d.ts +6 -0
  18. package/dist/ui-engine/color.js +80 -2
  19. package/dist/ui-engine/compat-report.d.ts +8 -0
  20. package/dist/ui-engine/compat-report.js +124 -0
  21. package/dist/ui-engine/css-imports.d.ts +4 -0
  22. package/dist/ui-engine/css-imports.js +70 -0
  23. package/dist/ui-engine/css-parser.js +58 -14
  24. package/dist/ui-engine/default-font.d.ts +25 -0
  25. package/dist/ui-engine/default-font.js +80 -0
  26. package/dist/ui-engine/font-assets.d.ts +2 -2
  27. package/dist/ui-engine/font-assets.js +22 -5
  28. package/dist/ui-engine/html-parser.d.ts +4 -0
  29. package/dist/ui-engine/html-parser.js +241 -26
  30. package/dist/ui-engine/image-assets.d.ts +8 -1
  31. package/dist/ui-engine/image-assets.js +35 -2
  32. package/dist/ui-engine/image-decode.d.ts +22 -0
  33. package/dist/ui-engine/image-decode.js +194 -0
  34. package/dist/ui-engine/inline-parser.js +1 -1
  35. package/dist/ui-engine/layout-engine.d.ts +19 -0
  36. package/dist/ui-engine/layout-engine.js +59 -3
  37. package/dist/ui-engine/model.d.ts +18 -0
  38. package/dist/ui-engine/model.js +75 -7
  39. package/dist/ui-engine/runtime-header/blend-bodies.js +29 -9
  40. package/dist/ui-engine/runtime-header/canvas-helpers.js +3 -0
  41. package/dist/ui-engine/runtime-header/canvas-scrollbar.js +115 -111
  42. package/dist/ui-engine/runtime-header/cuttlefish-gfx.js +21 -0
  43. package/dist/ui-engine/runtime-header/dirty-scroll-mutators.js +182 -179
  44. package/dist/ui-engine/runtime-header/forward-decls.js +215 -167
  45. package/dist/ui-engine/runtime-header/init-press-input.js +138 -135
  46. package/dist/ui-engine/runtime-header/keyboard.js +682 -380
  47. package/dist/ui-engine/runtime-header/node-draw-body.js +1656 -1386
  48. package/dist/ui-engine/runtime-header/paint-order-coords.js +259 -214
  49. package/dist/ui-engine/runtime-header/paint-rects-repair.js +736 -568
  50. package/dist/ui-engine/runtime-header/scroll-physics.js +25 -1
  51. package/dist/ui-engine/runtime-header/state-bindings-nav.js +9 -0
  52. package/dist/ui-engine/runtime-header/structs.js +228 -216
  53. package/dist/ui-engine/runtime-header/text-rendering.js +888 -770
  54. package/dist/ui-engine/runtime-header/tick/bindings-phase.js +118 -116
  55. package/dist/ui-engine/runtime-header/tick/dirty-draw-phase.js +897 -633
  56. package/dist/ui-engine/runtime-header/tick/scroll-canvas-phase.js +24 -25
  57. package/dist/ui-engine/runtime-header/touch-keyboard-fwd.js +78 -23
  58. package/dist/ui-engine/runtime-header/types-defines.js +110 -93
  59. package/dist/ui-engine/shadcn-kit.d.ts +1 -0
  60. package/dist/ui-engine/shadcn-kit.js +502 -0
  61. package/dist/ui-engine/style-resolver.d.ts +4 -0
  62. package/dist/ui-engine/style-resolver.js +58 -6
  63. package/dist/ui-engine/transpile-ui.js +12 -5
  64. package/dist/ui-engine/ua-stylesheet.d.ts +22 -1
  65. package/dist/ui-engine/ua-stylesheet.js +119 -32
  66. package/dist/ui-engine/ui-lowering.js +37 -3
  67. package/dist/ui-engine/ui-registry.js +32 -4
  68. package/dist/ui-engine/yoga-layout.js +57 -52
  69. package/package.json +9 -3
  70. package/src/engine-index.ts +53 -51
  71. package/src/preview/build-program.ts +810 -734
  72. package/src/preview/host-gfx.ts +30 -3
  73. package/src/preview/host-ui-runtime.ts +4133 -3289
  74. package/src/ui-engine/block-layout.ts +24 -11
  75. package/src/ui-engine/color.ts +77 -2
  76. package/src/ui-engine/compat-report.ts +139 -0
  77. package/src/ui-engine/css-imports.ts +69 -0
  78. package/src/ui-engine/css-parser.ts +64 -15
  79. package/src/ui-engine/default-font.ts +95 -0
  80. package/src/ui-engine/font-assets.ts +545 -525
  81. package/src/ui-engine/html-parser.ts +615 -397
  82. package/src/ui-engine/image-assets.ts +37 -2
  83. package/src/ui-engine/image-decode.ts +244 -0
  84. package/src/ui-engine/inline-parser.ts +1 -1
  85. package/src/ui-engine/layout-engine.ts +61 -3
  86. package/src/ui-engine/model.ts +1317 -1230
  87. package/src/ui-engine/runtime-header/blend-bodies.ts +29 -9
  88. package/src/ui-engine/runtime-header/canvas-helpers.ts +3 -0
  89. package/src/ui-engine/runtime-header/canvas-scrollbar.ts +121 -117
  90. package/src/ui-engine/runtime-header/cuttlefish-gfx.ts +21 -0
  91. package/src/ui-engine/runtime-header/dirty-scroll-mutators.ts +188 -185
  92. package/src/ui-engine/runtime-header/forward-decls.ts +227 -179
  93. package/src/ui-engine/runtime-header/init-press-input.ts +144 -141
  94. package/src/ui-engine/runtime-header/keyboard.ts +689 -386
  95. package/src/ui-engine/runtime-header/node-draw-body.ts +1681 -1411
  96. package/src/ui-engine/runtime-header/paint-order-coords.ts +265 -220
  97. package/src/ui-engine/runtime-header/paint-rects-repair.ts +742 -574
  98. package/src/ui-engine/runtime-header/scroll-physics.ts +25 -1
  99. package/src/ui-engine/runtime-header/state-bindings-nav.ts +9 -0
  100. package/src/ui-engine/runtime-header/structs.ts +234 -222
  101. package/src/ui-engine/runtime-header/text-rendering.ts +894 -776
  102. package/src/ui-engine/runtime-header/tick/bindings-phase.ts +124 -122
  103. package/src/ui-engine/runtime-header/tick/dirty-draw-phase.ts +902 -638
  104. package/src/ui-engine/runtime-header/tick/scroll-canvas-phase.ts +30 -31
  105. package/src/ui-engine/runtime-header/touch-keyboard-fwd.ts +78 -23
  106. package/src/ui-engine/runtime-header/types-defines.ts +116 -99
  107. package/src/ui-engine/shadcn-kit.ts +503 -0
  108. package/src/ui-engine/style-resolver.ts +471 -416
  109. package/src/ui-engine/transpile-ui.ts +12 -5
  110. package/src/ui-engine/ua-stylesheet.ts +137 -31
  111. package/src/ui-engine/ui-lowering.ts +486 -452
  112. package/src/ui-engine/ui-registry.ts +556 -524
  113. package/src/ui-engine/yoga-layout.ts +307 -309
@@ -1,525 +1,545 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import opentype from "opentype.js";
4
- import type { CSSFontFace, CSSProperty } from "./css-parser.js";
5
- import type { StyledNode } from "./style-resolver.js";
6
- import { getDisplayProfile } from "@typecad/cuttlefish/stores/display-profile-store";
7
-
8
- export interface UIFontGlyphModel {
9
- codepoint: number;
10
- xOffset: number;
11
- yOffset: number;
12
- width: number;
13
- height: number;
14
- advance: number;
15
- /** Offset into the packed alpha stream, measured in 4-bit pixels. */
16
- dataOffset: number;
17
- }
18
-
19
- export type UIFontSubsetMode = "exact" | "fallback";
20
-
21
- export interface UIFontAssetModel {
22
- id: number;
23
- family: string;
24
- sourcePath: string;
25
- px: number;
26
- fontWeight: string;
27
- fontStyle: string;
28
- subset: UIFontSubsetMode;
29
- lineHeight: number;
30
- baseline: number;
31
- glyphs: UIFontGlyphModel[];
32
- alpha: number[];
33
- }
34
-
35
- interface FontAssetRequest {
36
- family: string;
37
- sourcePath: string;
38
- px: number;
39
- fontWeight: string;
40
- fontStyle: string;
41
- subset: UIFontSubsetMode;
42
- chars: Set<string>;
43
- }
44
-
45
- export interface UIFontAssetPlan {
46
- family: string;
47
- sourcePath: string;
48
- px: number;
49
- fontWeight: string;
50
- fontStyle: string;
51
- subset: UIFontSubsetMode;
52
- chars: string[];
53
- }
54
-
55
- interface OpenTypePathCommand {
56
- type: "M" | "L" | "C" | "Q" | "Z";
57
- x?: number;
58
- y?: number;
59
- x1?: number;
60
- y1?: number;
61
- x2?: number;
62
- y2?: number;
63
- }
64
-
65
- interface Point {
66
- x: number;
67
- y: number;
68
- }
69
-
70
- const FALLBACK_CHARS = " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz.,:;!?+-*/=%()[]{}<>_#@&";
71
- const SUPERSAMPLE = 4;
72
-
73
- export function normalizeFontFamily(value: string | undefined): string | undefined {
74
- if (!value) return undefined;
75
- const first = value.split(",")[0]?.trim();
76
- if (!first) return undefined;
77
- if ((first.startsWith('"') && first.endsWith('"')) || (first.startsWith("'") && first.endsWith("'"))) {
78
- return first.slice(1, -1);
79
- }
80
- return first;
81
- }
82
-
83
- export function fontPxOf(style: CSSProperty): number {
84
- if (!style.fontSize) return 16;
85
- const px = parseInt(style.fontSize, 10);
86
- return Number.isFinite(px) && px > 0 ? px : 16;
87
- }
88
-
89
- export function normalizeFontWeight(value: string | undefined): string {
90
- const normalized = value?.trim().toLowerCase();
91
- if (!normalized || normalized === "normal") return "400";
92
- if (normalized === "bold" || normalized === "bolder") return "700";
93
- if (normalized === "lighter") return "300";
94
- const numeric = /^(\d{1,4})/.exec(normalized);
95
- if (!numeric) return "400";
96
- const n = Math.max(1, Math.min(1000, Number(numeric[1])));
97
- return Number.isFinite(n) ? String(n) : "400";
98
- }
99
-
100
- export function normalizeFontStyle(value: string | undefined): string {
101
- const normalized = value?.trim().toLowerCase();
102
- if (!normalized || normalized === "normal") return "normal";
103
- if (normalized.includes("italic")) return "italic";
104
- if (normalized.includes("oblique")) return "oblique";
105
- return "normal";
106
- }
107
-
108
- export function fontSubsetOf(style: CSSProperty): UIFontSubsetMode {
109
- const subset = style.fontSubset?.trim().toLowerCase();
110
- if (!subset || subset === "exact" || subset === "used") return "exact";
111
- if (subset === "fallback" || subset === "auto" || subset === "ascii" || subset === "common") {
112
- return "fallback";
113
- }
114
- return "exact";
115
- }
116
-
117
- export function selectFontFaceForStyle(fontFaces: CSSFontFace[], style: CSSProperty): CSSFontFace | undefined {
118
- const family = normalizeFontFamily(style.fontFamily);
119
- if (!family) return undefined;
120
- const candidates = fontFaces.filter((face) => face.fontFamily.toLowerCase() === family.toLowerCase());
121
- if (candidates.length === 0) return undefined;
122
- const desiredWeight = Number(normalizeFontWeight(style.fontWeight));
123
- const desiredStyle = normalizeFontStyle(style.fontStyle);
124
- return [...candidates].sort((a, b) =>
125
- fontFaceScore(a, desiredWeight, desiredStyle) - fontFaceScore(b, desiredWeight, desiredStyle)
126
- )[0];
127
- }
128
-
129
- export function selectFontAssetForStyle(fontAssets: UIFontAssetModel[], style: CSSProperty): UIFontAssetModel | undefined {
130
- const family = normalizeFontFamily(style.fontFamily);
131
- if (!family) return undefined;
132
- const px = fontPxOf(style);
133
- const candidates = fontAssets.filter((asset) => asset.family.toLowerCase() === family.toLowerCase() && asset.px === px);
134
- if (candidates.length === 0) return undefined;
135
- const desiredWeight = Number(normalizeFontWeight(style.fontWeight));
136
- const desiredStyle = normalizeFontStyle(style.fontStyle);
137
- return [...candidates].sort((a, b) =>
138
- fontAssetScore(a, desiredWeight, desiredStyle) - fontAssetScore(b, desiredWeight, desiredStyle)
139
- )[0];
140
- }
141
-
142
- /** Measure a string's pixel width using the real per-glyph advances of the
143
- * asset font a node resolves to. Returns undefined when the node uses the
144
- * default font (no matching asset), so callers fall back to the 6*ts advance.
145
- * Characters missing from the asset's subset fall back to half the line height
146
- * (matching the runtime's ui_asset_text_width fallback). */
147
- export function assetTextWidth(text: string, style: CSSProperty, fontAssets: UIFontAssetModel[]): number | undefined {
148
- const asset = selectFontAssetForStyle(fontAssets, style);
149
- if (!asset) return undefined;
150
- if (text.length === 0) return 0;
151
- const fallback = Math.max(1, Math.floor(asset.lineHeight / 2));
152
- let w = 0;
153
- for (const ch of text) {
154
- const cp = ch.codePointAt(0)!;
155
- const glyph = asset.glyphs.find((g) => g.codepoint === cp);
156
- w += glyph ? glyph.advance : fallback;
157
- }
158
- return w;
159
- }
160
-
161
- /** The line height the runtime will render a custom-font node at
162
- * (ui_asset_text_height → face->lineHeight), or undefined when the node uses
163
- * the default font. Layout must use this for the glyph-cell height instead of
164
- * the 8*ts GFX bitmap default, otherwise the laid-out box is shorter than the
165
- * drawn glyphs and the text overflows its container (e.g. a bold @font-face
166
- * title spilling past its header's padded box). Mirrors the preview's
167
- * textHeight(size, fontFace) = asset.lineHeight ?? gfx.textHeight(size). */
168
- export function assetLineHeight(style: CSSProperty, fontAssets: UIFontAssetModel[]): number | undefined {
169
- const asset = selectFontAssetForStyle(fontAssets, style);
170
- if (!asset || asset.lineHeight <= 0) return undefined;
171
- return asset.lineHeight;
172
- }
173
-
174
- export function buildUIFontAssets(
175
- root: StyledNode,
176
- fontFaces: CSSFontFace[],
177
- baseDir: string,
178
- ): UIFontAssetModel[] {
179
- const plans = planUIFontAssets(root, fontFaces, baseDir);
180
-
181
- const parsedFonts = new Map<string, any>();
182
- const assets: UIFontAssetModel[] = [];
183
- let id = 1;
184
- for (const plan of plans) {
185
- let font = parsedFonts.get(plan.sourcePath);
186
- if (!font) {
187
- const bytes = fs.readFileSync(plan.sourcePath);
188
- const arrayBuffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
189
- font = opentype.parse(arrayBuffer);
190
- parsedFonts.set(plan.sourcePath, font);
191
- }
192
- assets.push(rasterizeFontAsset({
193
- id: id++,
194
- family: plan.family,
195
- sourcePath: plan.sourcePath,
196
- px: plan.px,
197
- fontWeight: plan.fontWeight,
198
- fontStyle: plan.fontStyle,
199
- subset: plan.subset,
200
- chars: plan.chars,
201
- font,
202
- }));
203
- }
204
-
205
- return assets;
206
- }
207
-
208
- export function planUIFontAssets(
209
- root: StyledNode,
210
- fontFaces: CSSFontFace[],
211
- baseDir: string,
212
- ): UIFontAssetPlan[] {
213
- if (fontFaces.length === 0) return [];
214
-
215
- const requests = new Map<string, FontAssetRequest>();
216
- const collect = (node: StyledNode) => {
217
- const face = selectFontFaceForStyle(fontFaces, node.style);
218
- if (face) {
219
- const px = fontPxOf(node.style);
220
- const sourcePath = resolveFontPath(face.src, baseDir);
221
- const fontWeight = normalizeFontWeight(face.fontWeight ?? node.style.fontWeight);
222
- const fontStyle = normalizeFontStyle(face.fontStyle ?? node.style.fontStyle);
223
- const key = `${sourcePath}:${px}:${fontWeight}:${fontStyle}`;
224
- let request = requests.get(key);
225
- if (!request) {
226
- request = {
227
- family: face.fontFamily,
228
- sourcePath,
229
- px,
230
- fontWeight,
231
- fontStyle,
232
- subset: "exact",
233
- chars: new Set<string>(),
234
- };
235
- requests.set(key, request);
236
- }
237
- if (fontSubsetOf(node.style) === "fallback" && request.subset !== "fallback") {
238
- request.subset = "fallback";
239
- addText(request.chars, FALLBACK_CHARS);
240
- }
241
- addNodeText(request.chars, node);
242
- }
243
- // Rich-text inline runs: each run has its own resolved style (bold, italic,
244
- // different font-size) which maps to a different font face/asset. Collect
245
- // each run's text under its OWN style so the per-face subsetting includes
246
- // the run's characters. Without this, the run's font face is subsetted from
247
- // unrelated text and glyphs go missing at draw time.
248
- if (node.runs) {
249
- for (const run of node.runs) {
250
- const runStyle = { ...node.style, ...run.style } as CSSProperty;
251
- const runFace = selectFontFaceForStyle(fontFaces, runStyle);
252
- if (!runFace) continue;
253
- const runPx = fontPxOf(runStyle);
254
- const runSourcePath = resolveFontPath(runFace.src, baseDir);
255
- const runWeight = normalizeFontWeight(runFace.fontWeight ?? runStyle.fontWeight);
256
- const runStyleAttr = normalizeFontStyle(runFace.fontStyle ?? runStyle.fontStyle);
257
- const runKey = `${runSourcePath}:${runPx}:${runWeight}:${runStyleAttr}`;
258
- let runReq = requests.get(runKey);
259
- if (!runReq) {
260
- runReq = {
261
- family: runFace.fontFamily,
262
- sourcePath: runSourcePath,
263
- px: runPx,
264
- fontWeight: runWeight,
265
- fontStyle: runStyleAttr,
266
- subset: "exact",
267
- chars: new Set<string>(),
268
- };
269
- requests.set(runKey, runReq);
270
- }
271
- addText(runReq.chars, applyTextTransform(run.text, runStyle));
272
- }
273
- }
274
- for (const child of node.children) collect(child);
275
- };
276
- collect(root);
277
-
278
- return [...requests.values()]
279
- .filter((request) => request.chars.size > 0)
280
- .map((request) => ({
281
- family: request.family,
282
- sourcePath: request.sourcePath,
283
- px: request.px,
284
- fontWeight: request.fontWeight,
285
- fontStyle: request.fontStyle,
286
- subset: request.subset,
287
- chars: [...request.chars].sort((a, b) => a.codePointAt(0)! - b.codePointAt(0)!),
288
- }));
289
- }
290
-
291
- function resolveFontPath(src: string, baseDir: string): string {
292
- if (/^https?:\/\//i.test(src)) {
293
- throw new Error(`@font-face src "${src}" is remote; use a local font file for embedded builds.`);
294
- }
295
- const withoutFileScheme = src.startsWith("file://") ? src.slice("file://".length) : src;
296
- const resolved = path.isAbsolute(withoutFileScheme)
297
- ? withoutFileScheme
298
- : path.resolve(baseDir, withoutFileScheme);
299
- if (!fs.existsSync(resolved)) {
300
- throw new Error(`@font-face font file not found: ${resolved}`);
301
- }
302
- return resolved;
303
- }
304
-
305
- function fontFaceScore(face: CSSFontFace, desiredWeight: number, desiredStyle: string): number {
306
- const style = normalizeFontStyle(face.fontStyle);
307
- const weight = Number(normalizeFontWeight(face.fontWeight));
308
- return styleScore(style, desiredStyle) * 10000 + Math.abs(weight - desiredWeight);
309
- }
310
-
311
- function fontAssetScore(asset: UIFontAssetModel, desiredWeight: number, desiredStyle: string): number {
312
- const weight = Number(normalizeFontWeight(asset.fontWeight));
313
- return styleScore(asset.fontStyle, desiredStyle) * 10000 + Math.abs(weight - desiredWeight);
314
- }
315
-
316
- function styleScore(actual: string, desired: string): number {
317
- if (actual === desired) return 0;
318
- if (actual === "normal") return 1;
319
- return 2;
320
- }
321
-
322
- function addNodeText(chars: Set<string>, node: StyledNode): void {
323
- addText(chars, applyTextTransform(node.text, node.style));
324
- addText(chars, applyTextTransform(node.placeholder, node.style));
325
- for (const option of node.options ?? []) {
326
- addText(chars, applyTextTransform(option.text, node.style));
327
- }
328
- // Interpolation ({expr}) and text bindings produce runtime text the static
329
- // template can't predict (numbers, dates, etc). Add digits + common
330
- // formatting chars so the font subset can render the runtime output.
331
- if (node.text && node.text.includes("{")) {
332
- addText(chars, "0123456789.,-+/()%");
333
- }
334
- // <input> nodes on the SDL desktop target (UI_HIDE_OSK) accept arbitrary
335
- // real-keyboard text — the OSK grid isn't shown, so the user can type any
336
- // character, not just the keys on the on-screen grid. Pack the full printable
337
- // ASCII range so every typed character has a glyph (otherwise letters absent
338
- // from static UI text render blank — ui_font_glyph returns null). Hardware
339
- // targets keep the minimal subset: the OSK grid is the only input path and
340
- // only carries the keys it shows.
341
- if (node.tag === "input") {
342
- let driver: string | undefined;
343
- try { driver = getDisplayProfile().driver; } catch { /* no profile bound */ }
344
- if (driver === "sdl") {
345
- let ascii = "";
346
- for (let cp = 0x20; cp <= 0x7e; cp++) ascii += String.fromCodePoint(cp);
347
- addText(chars, ascii);
348
- }
349
- }
350
- }
351
-
352
- function applyTextTransform(text: string | undefined, style: CSSProperty): string | undefined {
353
- if (!text) return text;
354
- switch (style.textTransform) {
355
- case "uppercase": return text.toUpperCase();
356
- case "lowercase": return text.toLowerCase();
357
- case "capitalize":
358
- return text.replace(/\b\w/g, (c) => c.toUpperCase());
359
- default: return text;
360
- }
361
- }
362
-
363
- function addText(chars: Set<string>, text: string | undefined): void {
364
- if (!text) return;
365
- for (const ch of text) {
366
- const cp = ch.codePointAt(0);
367
- if (cp !== undefined && cp >= 32 && cp <= 0xffff) chars.add(ch);
368
- }
369
- }
370
-
371
- function rasterizeFontAsset(options: {
372
- id: number;
373
- family: string;
374
- sourcePath: string;
375
- px: number;
376
- fontWeight: string;
377
- fontStyle: string;
378
- subset: UIFontSubsetMode;
379
- chars: string[];
380
- font: any;
381
- }): UIFontAssetModel {
382
- const scale = options.px / options.font.unitsPerEm;
383
- const baseline = Math.ceil((options.font.ascender ?? options.font.unitsPerEm) * scale) + 1;
384
- const lineHeight = Math.ceil(((options.font.ascender ?? options.font.unitsPerEm) - (options.font.descender ?? 0)) * scale) + 2;
385
- const glyphs: UIFontGlyphModel[] = [];
386
- const unpackedAlpha: number[] = [];
387
-
388
- for (const ch of options.chars) {
389
- const glyph = options.font.charToGlyph(ch);
390
- const advance = Math.max(1, Math.ceil((glyph.advanceWidth ?? options.font.unitsPerEm / 2) * scale));
391
- const path = glyph.getPath(0, 0, options.px);
392
- const bbox = path.getBoundingBox();
393
- const empty = !Number.isFinite(bbox.x1) || !Number.isFinite(bbox.y1) || bbox.x1 === bbox.x2 || bbox.y1 === bbox.y2;
394
- const xOffset = empty ? 0 : Math.floor(bbox.x1) - 1;
395
- const yOffset = empty ? 0 : Math.floor(bbox.y1) - 1;
396
- const width = empty ? 0 : Math.max(0, Math.ceil(bbox.x2) - xOffset + 1);
397
- const height = empty ? 0 : Math.max(0, Math.ceil(bbox.y2) - yOffset + 1);
398
- const dataOffset = unpackedAlpha.length;
399
-
400
- if (width > 0 && height > 0) {
401
- const contours = flattenPath(path.commands as OpenTypePathCommand[]);
402
- for (let py = 0; py < height; py++) {
403
- for (let px = 0; px < width; px++) {
404
- let covered = 0;
405
- for (let sy = 0; sy < SUPERSAMPLE; sy++) {
406
- for (let sx = 0; sx < SUPERSAMPLE; sx++) {
407
- const x = xOffset + px + (sx + 0.5) / SUPERSAMPLE;
408
- const y = yOffset + py + (sy + 0.5) / SUPERSAMPLE;
409
- if (pointInContours(x, y, contours)) covered++;
410
- }
411
- }
412
- unpackedAlpha.push(Math.round((covered * 15) / (SUPERSAMPLE * SUPERSAMPLE)));
413
- }
414
- }
415
- }
416
-
417
- glyphs.push({
418
- codepoint: ch.codePointAt(0) ?? 0,
419
- xOffset,
420
- yOffset,
421
- width,
422
- height,
423
- advance,
424
- dataOffset,
425
- });
426
- }
427
-
428
- return {
429
- id: options.id,
430
- family: options.family,
431
- sourcePath: options.sourcePath,
432
- px: options.px,
433
- fontWeight: options.fontWeight,
434
- fontStyle: options.fontStyle,
435
- subset: options.subset,
436
- lineHeight,
437
- baseline,
438
- glyphs,
439
- alpha: packNibbles(unpackedAlpha),
440
- };
441
- }
442
-
443
- function flattenPath(commands: OpenTypePathCommand[]): Point[][] {
444
- const contours: Point[][] = [];
445
- let current: Point = { x: 0, y: 0 };
446
- let start: Point | null = null;
447
- let contour: Point[] = [];
448
-
449
- const push = (p: Point) => {
450
- contour.push(p);
451
- current = p;
452
- };
453
- const finish = () => {
454
- if (contour.length > 1) contours.push(contour);
455
- contour = [];
456
- start = null;
457
- };
458
-
459
- for (const cmd of commands) {
460
- if (cmd.type === "M") {
461
- finish();
462
- current = { x: cmd.x ?? 0, y: cmd.y ?? 0 };
463
- start = current;
464
- contour = [current];
465
- } else if (cmd.type === "L") {
466
- push({ x: cmd.x ?? current.x, y: cmd.y ?? current.y });
467
- } else if (cmd.type === "Q") {
468
- const p0 = current;
469
- const p1 = { x: cmd.x1 ?? current.x, y: cmd.y1 ?? current.y };
470
- const p2 = { x: cmd.x ?? current.x, y: cmd.y ?? current.y };
471
- for (let i = 1; i <= 8; i++) {
472
- const t = i / 8;
473
- const mt = 1 - t;
474
- push({
475
- x: mt * mt * p0.x + 2 * mt * t * p1.x + t * t * p2.x,
476
- y: mt * mt * p0.y + 2 * mt * t * p1.y + t * t * p2.y,
477
- });
478
- }
479
- } else if (cmd.type === "C") {
480
- const p0 = current;
481
- const p1 = { x: cmd.x1 ?? current.x, y: cmd.y1 ?? current.y };
482
- const p2 = { x: cmd.x2 ?? current.x, y: cmd.y2 ?? current.y };
483
- const p3 = { x: cmd.x ?? current.x, y: cmd.y ?? current.y };
484
- for (let i = 1; i <= 12; i++) {
485
- const t = i / 12;
486
- const mt = 1 - t;
487
- push({
488
- x: mt * mt * mt * p0.x + 3 * mt * mt * t * p1.x + 3 * mt * t * t * p2.x + t * t * t * p3.x,
489
- y: mt * mt * mt * p0.y + 3 * mt * mt * t * p1.y + 3 * mt * t * t * p2.y + t * t * t * p3.y,
490
- });
491
- }
492
- } else if (cmd.type === "Z") {
493
- if (start) push(start);
494
- finish();
495
- }
496
- }
497
- finish();
498
- return contours;
499
- }
500
-
501
- function pointInContours(x: number, y: number, contours: Point[][]): boolean {
502
- let inside = false;
503
- for (const contour of contours) {
504
- for (let i = 0, j = contour.length - 1; i < contour.length; j = i++) {
505
- const a = contour[i];
506
- const b = contour[j];
507
- const crosses = (a.y > y) !== (b.y > y);
508
- if (crosses) {
509
- const ix = ((b.x - a.x) * (y - a.y)) / (b.y - a.y) + a.x;
510
- if (x < ix) inside = !inside;
511
- }
512
- }
513
- }
514
- return inside;
515
- }
516
-
517
- function packNibbles(values: number[]): number[] {
518
- const out: number[] = [];
519
- for (let i = 0; i < values.length; i += 2) {
520
- const hi = Math.max(0, Math.min(15, values[i] ?? 0));
521
- const lo = Math.max(0, Math.min(15, values[i + 1] ?? 0));
522
- out.push((hi << 4) | lo);
523
- }
524
- return out;
525
- }
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import opentype from "opentype.js";
4
+ import type { CSSFontFace, CSSProperty } from "./css-parser.js";
5
+ import type { StyledNode } from "./style-resolver.js";
6
+ import { getDisplayProfile } from "@typecad/cuttlefish/stores/display-profile-store";
7
+
8
+ export interface UIFontGlyphModel {
9
+ codepoint: number;
10
+ xOffset: number;
11
+ yOffset: number;
12
+ width: number;
13
+ height: number;
14
+ advance: number;
15
+ /** Offset into the packed alpha stream, measured in 4-bit pixels. */
16
+ dataOffset: number;
17
+ }
18
+
19
+ export type UIFontSubsetMode = "exact" | "fallback";
20
+
21
+ export interface UIFontAssetModel {
22
+ id: number;
23
+ family: string;
24
+ sourcePath: string;
25
+ px: number;
26
+ fontWeight: string;
27
+ fontStyle: string;
28
+ subset: UIFontSubsetMode;
29
+ lineHeight: number;
30
+ baseline: number;
31
+ glyphs: UIFontGlyphModel[];
32
+ alpha: number[];
33
+ }
34
+
35
+ interface FontAssetRequest {
36
+ family: string;
37
+ sourcePath: string;
38
+ px: number;
39
+ fontWeight: string;
40
+ fontStyle: string;
41
+ subset: UIFontSubsetMode;
42
+ chars: Set<string>;
43
+ }
44
+
45
+ export interface UIFontAssetPlan {
46
+ family: string;
47
+ sourcePath: string;
48
+ px: number;
49
+ fontWeight: string;
50
+ fontStyle: string;
51
+ subset: UIFontSubsetMode;
52
+ chars: string[];
53
+ }
54
+
55
+ interface OpenTypePathCommand {
56
+ type: "M" | "L" | "C" | "Q" | "Z";
57
+ x?: number;
58
+ y?: number;
59
+ x1?: number;
60
+ y1?: number;
61
+ x2?: number;
62
+ y2?: number;
63
+ }
64
+
65
+ interface Point {
66
+ x: number;
67
+ y: number;
68
+ }
69
+
70
+ const FALLBACK_CHARS = " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz.,:;!?+-*/=%()[]{}<>_#@&^~$'|";
71
+ const SUPERSAMPLE = 4;
72
+
73
+ export function normalizeFontFamily(value: string | undefined): string | undefined {
74
+ if (!value) return undefined;
75
+ const first = value.split(",")[0]?.trim();
76
+ if (!first) return undefined;
77
+ if ((first.startsWith('"') && first.endsWith('"')) || (first.startsWith("'") && first.endsWith("'"))) {
78
+ return first.slice(1, -1);
79
+ }
80
+ return first;
81
+ }
82
+
83
+ export function fontPxOf(style: CSSProperty): number {
84
+ if (!style.fontSize) return 16;
85
+ const px = parseInt(style.fontSize, 10);
86
+ return Number.isFinite(px) && px > 0 ? px : 16;
87
+ }
88
+
89
+ export function normalizeFontWeight(value: string | undefined): string {
90
+ const normalized = value?.trim().toLowerCase();
91
+ if (!normalized || normalized === "normal") return "400";
92
+ if (normalized === "bold" || normalized === "bolder") return "700";
93
+ if (normalized === "lighter") return "300";
94
+ const numeric = /^(\d{1,4})/.exec(normalized);
95
+ if (!numeric) return "400";
96
+ const n = Math.max(1, Math.min(1000, Number(numeric[1])));
97
+ return Number.isFinite(n) ? String(n) : "400";
98
+ }
99
+
100
+ export function normalizeFontStyle(value: string | undefined): string {
101
+ const normalized = value?.trim().toLowerCase();
102
+ if (!normalized || normalized === "normal") return "normal";
103
+ if (normalized.includes("italic")) return "italic";
104
+ if (normalized.includes("oblique")) return "oblique";
105
+ return "normal";
106
+ }
107
+
108
+ export function fontSubsetOf(style: CSSProperty): UIFontSubsetMode {
109
+ const subset = style.fontSubset?.trim().toLowerCase();
110
+ if (!subset || subset === "exact" || subset === "used") return "exact";
111
+ if (subset === "fallback" || subset === "auto" || subset === "ascii" || subset === "common") {
112
+ return "fallback";
113
+ }
114
+ return "exact";
115
+ }
116
+
117
+ export function selectFontFaceForStyle(fontFaces: CSSFontFace[], style: CSSProperty): CSSFontFace | undefined {
118
+ const family = normalizeFontFamily(style.fontFamily);
119
+ if (!family) return undefined;
120
+ const candidates = fontFaces.filter((face) => face.fontFamily.toLowerCase() === family.toLowerCase());
121
+ if (candidates.length === 0) return undefined;
122
+ const desiredWeight = Number(normalizeFontWeight(style.fontWeight));
123
+ const desiredStyle = normalizeFontStyle(style.fontStyle);
124
+ return [...candidates].sort((a, b) =>
125
+ fontFaceScore(a, desiredWeight, desiredStyle) - fontFaceScore(b, desiredWeight, desiredStyle)
126
+ )[0];
127
+ }
128
+
129
+ export function selectFontAssetForStyle(fontAssets: UIFontAssetModel[], style: CSSProperty): UIFontAssetModel | undefined {
130
+ const family = normalizeFontFamily(style.fontFamily);
131
+ if (!family) return undefined;
132
+ const px = fontPxOf(style);
133
+ const candidates = fontAssets.filter((asset) => asset.family.toLowerCase() === family.toLowerCase() && asset.px === px);
134
+ if (candidates.length === 0) return undefined;
135
+ const desiredWeight = Number(normalizeFontWeight(style.fontWeight));
136
+ const desiredStyle = normalizeFontStyle(style.fontStyle);
137
+ return [...candidates].sort((a, b) =>
138
+ fontAssetScore(a, desiredWeight, desiredStyle) - fontAssetScore(b, desiredWeight, desiredStyle)
139
+ )[0];
140
+ }
141
+
142
+ /** Measure a string's pixel width using the real per-glyph advances of the
143
+ * asset font a node resolves to. Returns undefined when the node uses the
144
+ * default font (no matching asset), so callers fall back to the 6*ts advance.
145
+ * Characters missing from the asset's subset fall back to half the line height
146
+ * (matching the runtime's ui_asset_text_width fallback). */
147
+ export function assetTextWidth(text: string, style: CSSProperty, fontAssets: UIFontAssetModel[]): number | undefined {
148
+ const asset = selectFontAssetForStyle(fontAssets, style);
149
+ if (!asset) return undefined;
150
+ if (text.length === 0) return 0;
151
+ const fallback = Math.max(1, Math.floor(asset.lineHeight / 2));
152
+ let w = 0;
153
+ for (const ch of text) {
154
+ const cp = ch.codePointAt(0)!;
155
+ const glyph = asset.glyphs.find((g) => g.codepoint === cp);
156
+ w += glyph ? glyph.advance : fallback;
157
+ }
158
+ return w;
159
+ }
160
+
161
+ /** The line height the runtime will render a custom-font node at
162
+ * (ui_asset_text_height → face->lineHeight), or undefined when the node uses
163
+ * the default font. Layout must use this for the glyph-cell height instead of
164
+ * the 8*ts GFX bitmap default, otherwise the laid-out box is shorter than the
165
+ * drawn glyphs and the text overflows its container (e.g. a bold @font-face
166
+ * title spilling past its header's padded box). Mirrors the preview's
167
+ * textHeight(size, fontFace) = asset.lineHeight ?? gfx.textHeight(size). */
168
+ export function assetLineHeight(style: CSSProperty, fontAssets: UIFontAssetModel[]): number | undefined {
169
+ const asset = selectFontAssetForStyle(fontAssets, style);
170
+ if (!asset || asset.lineHeight <= 0) return undefined;
171
+ return asset.lineHeight;
172
+ }
173
+
174
+ export function buildUIFontAssets(
175
+ root: StyledNode,
176
+ fontFaces: CSSFontFace[],
177
+ baseDir: string,
178
+ dynamicNodeIds?: Set<string>,
179
+ ): UIFontAssetModel[] {
180
+ const plans = planUIFontAssets(root, fontFaces, baseDir, dynamicNodeIds);
181
+
182
+ const parsedFonts = new Map<string, any>();
183
+ const assets: UIFontAssetModel[] = [];
184
+ let id = 1;
185
+ for (const plan of plans) {
186
+ let font = parsedFonts.get(plan.sourcePath);
187
+ if (!font) {
188
+ const bytes = fs.readFileSync(plan.sourcePath);
189
+ const arrayBuffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
190
+ font = opentype.parse(arrayBuffer);
191
+ parsedFonts.set(plan.sourcePath, font);
192
+ }
193
+ assets.push(rasterizeFontAsset({
194
+ id: id++,
195
+ family: plan.family,
196
+ sourcePath: plan.sourcePath,
197
+ px: plan.px,
198
+ fontWeight: plan.fontWeight,
199
+ fontStyle: plan.fontStyle,
200
+ subset: plan.subset,
201
+ chars: plan.chars,
202
+ font,
203
+ }));
204
+ }
205
+
206
+ return assets;
207
+ }
208
+
209
+ export function planUIFontAssets(
210
+ root: StyledNode,
211
+ fontFaces: CSSFontFace[],
212
+ baseDir: string,
213
+ dynamicNodeIds?: Set<string>,
214
+ ): UIFontAssetPlan[] {
215
+ if (fontFaces.length === 0) return [];
216
+
217
+ const requests = new Map<string, FontAssetRequest>();
218
+ const collect = (node: StyledNode) => {
219
+ const face = selectFontFaceForStyle(fontFaces, node.style);
220
+ if (face) {
221
+ // Runtime-dynamic text renders strings the static template can't
222
+ // predict widen those faces to the fallback charset so bound values
223
+ // ("Gamma" against a subset planned from authored "mode: alpha") don't
224
+ // drop glyphs. Dynamic = ui.bind(..., 'text') targets (via
225
+ // dynamicNodeIds), {expr} interpolations, and bind:text attributes.
226
+ const dynamicText =
227
+ (node.id !== undefined && dynamicNodeIds?.has(node.id)) ||
228
+ node.hasInterpolation === true ||
229
+ node.bind?.text !== undefined;
230
+ const px = fontPxOf(node.style);
231
+ const sourcePath = resolveFontPath(face.src, baseDir);
232
+ const fontWeight = normalizeFontWeight(face.fontWeight ?? node.style.fontWeight);
233
+ const fontStyle = normalizeFontStyle(face.fontStyle ?? node.style.fontStyle);
234
+ const key = `${sourcePath}:${px}:${fontWeight}:${fontStyle}`;
235
+ let request = requests.get(key);
236
+ if (!request) {
237
+ request = {
238
+ family: face.fontFamily,
239
+ sourcePath,
240
+ px,
241
+ fontWeight,
242
+ fontStyle,
243
+ subset: "exact",
244
+ chars: new Set<string>(),
245
+ };
246
+ requests.set(key, request);
247
+ }
248
+ if ((fontSubsetOf(node.style) === "fallback" || dynamicText) && request.subset !== "fallback") {
249
+ request.subset = "fallback";
250
+ addText(request.chars, FALLBACK_CHARS);
251
+ }
252
+ // Virtualized lists render RUNTIME text — item expressions produce
253
+ // strings the static template can't predict (the same reason `{expr}`
254
+ // interpolations add digits). Without this the list's face only carries
255
+ // glyphs from unrelated static text and items render blanks (e.g. a
256
+ // subset with '0' but no '1'-'9' draws "Item 10" as "Item 0").
257
+ if (node.tag === "list" && request.subset !== "fallback") {
258
+ request.subset = "fallback";
259
+ addText(request.chars, FALLBACK_CHARS);
260
+ }
261
+ addNodeText(request.chars, node);
262
+ }
263
+ // Rich-text inline runs: each run has its own resolved style (bold, italic,
264
+ // different font-size) which maps to a different font face/asset. Collect
265
+ // each run's text under its OWN style so the per-face subsetting includes
266
+ // the run's characters. Without this, the run's font face is subsetted from
267
+ // unrelated text and glyphs go missing at draw time.
268
+ if (node.runs) {
269
+ for (const run of node.runs) {
270
+ const runStyle = { ...node.style, ...run.style } as CSSProperty;
271
+ const runFace = selectFontFaceForStyle(fontFaces, runStyle);
272
+ if (!runFace) continue;
273
+ const runPx = fontPxOf(runStyle);
274
+ const runSourcePath = resolveFontPath(runFace.src, baseDir);
275
+ const runWeight = normalizeFontWeight(runFace.fontWeight ?? runStyle.fontWeight);
276
+ const runStyleAttr = normalizeFontStyle(runFace.fontStyle ?? runStyle.fontStyle);
277
+ const runKey = `${runSourcePath}:${runPx}:${runWeight}:${runStyleAttr}`;
278
+ let runReq = requests.get(runKey);
279
+ if (!runReq) {
280
+ runReq = {
281
+ family: runFace.fontFamily,
282
+ sourcePath: runSourcePath,
283
+ px: runPx,
284
+ fontWeight: runWeight,
285
+ fontStyle: runStyleAttr,
286
+ subset: "exact",
287
+ chars: new Set<string>(),
288
+ };
289
+ requests.set(runKey, runReq);
290
+ }
291
+ addText(runReq.chars, applyTextTransform(run.text, runStyle));
292
+ }
293
+ }
294
+ for (const child of node.children) collect(child);
295
+ };
296
+ collect(root);
297
+
298
+ return [...requests.values()]
299
+ .filter((request) => request.chars.size > 0)
300
+ .map((request) => ({
301
+ family: request.family,
302
+ sourcePath: request.sourcePath,
303
+ px: request.px,
304
+ fontWeight: request.fontWeight,
305
+ fontStyle: request.fontStyle,
306
+ subset: request.subset,
307
+ chars: [...request.chars].sort((a, b) => a.codePointAt(0)! - b.codePointAt(0)!),
308
+ }));
309
+ }
310
+
311
+ function resolveFontPath(src: string, baseDir: string): string {
312
+ if (/^https?:\/\//i.test(src)) {
313
+ throw new Error(`@font-face src "${src}" is remote; use a local font file for embedded builds.`);
314
+ }
315
+ const withoutFileScheme = src.startsWith("file://") ? src.slice("file://".length) : src;
316
+ const resolved = path.isAbsolute(withoutFileScheme)
317
+ ? withoutFileScheme
318
+ : path.resolve(baseDir, withoutFileScheme);
319
+ if (!fs.existsSync(resolved)) {
320
+ throw new Error(`@font-face font file not found: ${resolved}`);
321
+ }
322
+ return resolved;
323
+ }
324
+
325
+ function fontFaceScore(face: CSSFontFace, desiredWeight: number, desiredStyle: string): number {
326
+ const style = normalizeFontStyle(face.fontStyle);
327
+ const weight = Number(normalizeFontWeight(face.fontWeight));
328
+ return styleScore(style, desiredStyle) * 10000 + Math.abs(weight - desiredWeight);
329
+ }
330
+
331
+ function fontAssetScore(asset: UIFontAssetModel, desiredWeight: number, desiredStyle: string): number {
332
+ const weight = Number(normalizeFontWeight(asset.fontWeight));
333
+ return styleScore(asset.fontStyle, desiredStyle) * 10000 + Math.abs(weight - desiredWeight);
334
+ }
335
+
336
+ function styleScore(actual: string, desired: string): number {
337
+ if (actual === desired) return 0;
338
+ if (actual === "normal") return 1;
339
+ return 2;
340
+ }
341
+
342
+ function addNodeText(chars: Set<string>, node: StyledNode): void {
343
+ addText(chars, applyTextTransform(node.text, node.style));
344
+ addText(chars, applyTextTransform(node.placeholder, node.style));
345
+ for (const option of node.options ?? []) {
346
+ addText(chars, applyTextTransform(option.text, node.style));
347
+ }
348
+ // Interpolation ({expr}) and text bindings produce runtime text the static
349
+ // template can't predict (numbers, dates, etc). Add digits + common
350
+ // formatting chars so the font subset can render the runtime output.
351
+ if (node.text && node.text.includes("{")) {
352
+ addText(chars, "0123456789.,-+/()%");
353
+ }
354
+ // <input> nodes on the SDL desktop target (UI_HIDE_OSK) accept arbitrary
355
+ // real-keyboard text — the OSK grid isn't shown, so the user can type any
356
+ // character, not just the keys on the on-screen grid. Pack the full printable
357
+ // ASCII range so every typed character has a glyph (otherwise letters absent
358
+ // from static UI text render blank — ui_font_glyph returns null). Hardware
359
+ // targets keep the minimal subset: the OSK grid is the only input path and
360
+ // only carries the keys it shows.
361
+ if (node.tag === "input") {
362
+ let driver: string | undefined;
363
+ try { driver = getDisplayProfile().driver; } catch { /* no profile bound */ }
364
+ if (driver === "sdl") {
365
+ let ascii = "";
366
+ for (let cp = 0x20; cp <= 0x7e; cp++) ascii += String.fromCodePoint(cp);
367
+ addText(chars, ascii);
368
+ }
369
+ }
370
+ }
371
+
372
+ function applyTextTransform(text: string | undefined, style: CSSProperty): string | undefined {
373
+ if (!text) return text;
374
+ switch (style.textTransform) {
375
+ case "uppercase": return text.toUpperCase();
376
+ case "lowercase": return text.toLowerCase();
377
+ case "capitalize":
378
+ return text.replace(/\b\w/g, (c) => c.toUpperCase());
379
+ default: return text;
380
+ }
381
+ }
382
+
383
+ function addText(chars: Set<string>, text: string | undefined): void {
384
+ if (!text) return;
385
+ for (const ch of text) {
386
+ const cp = ch.codePointAt(0);
387
+ if (cp !== undefined && cp >= 32 && cp <= 0xffff) chars.add(ch);
388
+ }
389
+ }
390
+
391
+ function rasterizeFontAsset(options: {
392
+ id: number;
393
+ family: string;
394
+ sourcePath: string;
395
+ px: number;
396
+ fontWeight: string;
397
+ fontStyle: string;
398
+ subset: UIFontSubsetMode;
399
+ chars: string[];
400
+ font: any;
401
+ }): UIFontAssetModel {
402
+ const scale = options.px / options.font.unitsPerEm;
403
+ const baseline = Math.ceil((options.font.ascender ?? options.font.unitsPerEm) * scale) + 1;
404
+ const lineHeight = Math.ceil(((options.font.ascender ?? options.font.unitsPerEm) - (options.font.descender ?? 0)) * scale) + 2;
405
+ const glyphs: UIFontGlyphModel[] = [];
406
+ const unpackedAlpha: number[] = [];
407
+
408
+ for (const ch of options.chars) {
409
+ const glyph = options.font.charToGlyph(ch);
410
+ const advance = Math.max(1, Math.ceil((glyph.advanceWidth ?? options.font.unitsPerEm / 2) * scale));
411
+ const path = glyph.getPath(0, 0, options.px);
412
+ const bbox = path.getBoundingBox();
413
+ const empty = !Number.isFinite(bbox.x1) || !Number.isFinite(bbox.y1) || bbox.x1 === bbox.x2 || bbox.y1 === bbox.y2;
414
+ const xOffset = empty ? 0 : Math.floor(bbox.x1) - 1;
415
+ const yOffset = empty ? 0 : Math.floor(bbox.y1) - 1;
416
+ const width = empty ? 0 : Math.max(0, Math.ceil(bbox.x2) - xOffset + 1);
417
+ const height = empty ? 0 : Math.max(0, Math.ceil(bbox.y2) - yOffset + 1);
418
+ const dataOffset = unpackedAlpha.length;
419
+
420
+ if (width > 0 && height > 0) {
421
+ const contours = flattenPath(path.commands as OpenTypePathCommand[]);
422
+ for (let py = 0; py < height; py++) {
423
+ for (let px = 0; px < width; px++) {
424
+ let covered = 0;
425
+ for (let sy = 0; sy < SUPERSAMPLE; sy++) {
426
+ for (let sx = 0; sx < SUPERSAMPLE; sx++) {
427
+ const x = xOffset + px + (sx + 0.5) / SUPERSAMPLE;
428
+ const y = yOffset + py + (sy + 0.5) / SUPERSAMPLE;
429
+ if (pointInContours(x, y, contours)) covered++;
430
+ }
431
+ }
432
+ unpackedAlpha.push(Math.round((covered * 15) / (SUPERSAMPLE * SUPERSAMPLE)));
433
+ }
434
+ }
435
+ }
436
+
437
+ glyphs.push({
438
+ codepoint: ch.codePointAt(0) ?? 0,
439
+ xOffset,
440
+ yOffset,
441
+ width,
442
+ height,
443
+ advance,
444
+ dataOffset,
445
+ });
446
+ }
447
+
448
+ return {
449
+ id: options.id,
450
+ family: options.family,
451
+ sourcePath: options.sourcePath,
452
+ px: options.px,
453
+ fontWeight: options.fontWeight,
454
+ fontStyle: options.fontStyle,
455
+ subset: options.subset,
456
+ lineHeight,
457
+ baseline,
458
+ glyphs,
459
+ alpha: packNibbles(unpackedAlpha),
460
+ };
461
+ }
462
+
463
+ function flattenPath(commands: OpenTypePathCommand[]): Point[][] {
464
+ const contours: Point[][] = [];
465
+ let current: Point = { x: 0, y: 0 };
466
+ let start: Point | null = null;
467
+ let contour: Point[] = [];
468
+
469
+ const push = (p: Point) => {
470
+ contour.push(p);
471
+ current = p;
472
+ };
473
+ const finish = () => {
474
+ if (contour.length > 1) contours.push(contour);
475
+ contour = [];
476
+ start = null;
477
+ };
478
+
479
+ for (const cmd of commands) {
480
+ if (cmd.type === "M") {
481
+ finish();
482
+ current = { x: cmd.x ?? 0, y: cmd.y ?? 0 };
483
+ start = current;
484
+ contour = [current];
485
+ } else if (cmd.type === "L") {
486
+ push({ x: cmd.x ?? current.x, y: cmd.y ?? current.y });
487
+ } else if (cmd.type === "Q") {
488
+ const p0 = current;
489
+ const p1 = { x: cmd.x1 ?? current.x, y: cmd.y1 ?? current.y };
490
+ const p2 = { x: cmd.x ?? current.x, y: cmd.y ?? current.y };
491
+ for (let i = 1; i <= 8; i++) {
492
+ const t = i / 8;
493
+ const mt = 1 - t;
494
+ push({
495
+ x: mt * mt * p0.x + 2 * mt * t * p1.x + t * t * p2.x,
496
+ y: mt * mt * p0.y + 2 * mt * t * p1.y + t * t * p2.y,
497
+ });
498
+ }
499
+ } else if (cmd.type === "C") {
500
+ const p0 = current;
501
+ const p1 = { x: cmd.x1 ?? current.x, y: cmd.y1 ?? current.y };
502
+ const p2 = { x: cmd.x2 ?? current.x, y: cmd.y2 ?? current.y };
503
+ const p3 = { x: cmd.x ?? current.x, y: cmd.y ?? current.y };
504
+ for (let i = 1; i <= 12; i++) {
505
+ const t = i / 12;
506
+ const mt = 1 - t;
507
+ push({
508
+ x: mt * mt * mt * p0.x + 3 * mt * mt * t * p1.x + 3 * mt * t * t * p2.x + t * t * t * p3.x,
509
+ y: mt * mt * mt * p0.y + 3 * mt * mt * t * p1.y + 3 * mt * t * t * p2.y + t * t * t * p3.y,
510
+ });
511
+ }
512
+ } else if (cmd.type === "Z") {
513
+ if (start) push(start);
514
+ finish();
515
+ }
516
+ }
517
+ finish();
518
+ return contours;
519
+ }
520
+
521
+ function pointInContours(x: number, y: number, contours: Point[][]): boolean {
522
+ let inside = false;
523
+ for (const contour of contours) {
524
+ for (let i = 0, j = contour.length - 1; i < contour.length; j = i++) {
525
+ const a = contour[i];
526
+ const b = contour[j];
527
+ const crosses = (a.y > y) !== (b.y > y);
528
+ if (crosses) {
529
+ const ix = ((b.x - a.x) * (y - a.y)) / (b.y - a.y) + a.x;
530
+ if (x < ix) inside = !inside;
531
+ }
532
+ }
533
+ }
534
+ return inside;
535
+ }
536
+
537
+ function packNibbles(values: number[]): number[] {
538
+ const out: number[] = [];
539
+ for (let i = 0; i < values.length; i += 2) {
540
+ const hi = Math.max(0, Math.min(15, values[i] ?? 0));
541
+ const lo = Math.max(0, Math.min(15, values[i + 1] ?? 0));
542
+ out.push((hi << 4) | lo);
543
+ }
544
+ return out;
545
+ }