@typecad/ui 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1443 -0
  3. package/dist/engine-index.d.ts +7 -0
  4. package/dist/engine-index.js +41 -0
  5. package/dist/index.d.ts +165 -0
  6. package/dist/index.js +42 -0
  7. package/dist/preview/build-program.d.ts +7 -0
  8. package/dist/preview/build-program.js +665 -0
  9. package/dist/preview/host-gfx.d.ts +90 -0
  10. package/dist/preview/host-gfx.js +552 -0
  11. package/dist/preview/host-ui-runtime.d.ts +241 -0
  12. package/dist/preview/host-ui-runtime.js +3236 -0
  13. package/dist/preview/types.d.ts +83 -0
  14. package/dist/preview/types.js +1 -0
  15. package/dist/types.d.ts +108 -0
  16. package/dist/types.js +10 -0
  17. package/dist/ui-engine/block-layout.d.ts +8 -0
  18. package/dist/ui-engine/block-layout.js +95 -0
  19. package/dist/ui-engine/color.d.ts +49 -0
  20. package/dist/ui-engine/color.js +273 -0
  21. package/dist/ui-engine/css-parser.d.ts +155 -0
  22. package/dist/ui-engine/css-parser.js +1058 -0
  23. package/dist/ui-engine/default-keyboards.d.ts +3 -0
  24. package/dist/ui-engine/default-keyboards.js +53 -0
  25. package/dist/ui-engine/easing.d.ts +12 -0
  26. package/dist/ui-engine/easing.js +76 -0
  27. package/dist/ui-engine/font-assets.d.ts +50 -0
  28. package/dist/ui-engine/font-assets.js +431 -0
  29. package/dist/ui-engine/html-parser.d.ts +116 -0
  30. package/dist/ui-engine/html-parser.js +284 -0
  31. package/dist/ui-engine/image-assets.d.ts +21 -0
  32. package/dist/ui-engine/image-assets.js +125 -0
  33. package/dist/ui-engine/inline-parser.d.ts +30 -0
  34. package/dist/ui-engine/inline-parser.js +66 -0
  35. package/dist/ui-engine/keyframes.d.ts +3 -0
  36. package/dist/ui-engine/keyframes.js +39 -0
  37. package/dist/ui-engine/layout-engine.d.ts +35 -0
  38. package/dist/ui-engine/layout-engine.js +203 -0
  39. package/dist/ui-engine/model.d.ts +239 -0
  40. package/dist/ui-engine/model.js +987 -0
  41. package/dist/ui-engine/rich-layout.d.ts +51 -0
  42. package/dist/ui-engine/rich-layout.js +220 -0
  43. package/dist/ui-engine/run-types.d.ts +13 -0
  44. package/dist/ui-engine/run-types.js +1 -0
  45. package/dist/ui-engine/runtime-header/antialiasing.d.ts +1 -0
  46. package/dist/ui-engine/runtime-header/antialiasing.js +141 -0
  47. package/dist/ui-engine/runtime-header/blend-bodies.d.ts +1 -0
  48. package/dist/ui-engine/runtime-header/blend-bodies.js +39 -0
  49. package/dist/ui-engine/runtime-header/canvas-helpers.d.ts +1 -0
  50. package/dist/ui-engine/runtime-header/canvas-helpers.js +155 -0
  51. package/dist/ui-engine/runtime-header/canvas-scrollbar.d.ts +1 -0
  52. package/dist/ui-engine/runtime-header/canvas-scrollbar.js +105 -0
  53. package/dist/ui-engine/runtime-header/color-mono-refresh.d.ts +1 -0
  54. package/dist/ui-engine/runtime-header/color-mono-refresh.js +154 -0
  55. package/dist/ui-engine/runtime-header/dirty-scroll-mutators.d.ts +1 -0
  56. package/dist/ui-engine/runtime-header/dirty-scroll-mutators.js +169 -0
  57. package/dist/ui-engine/runtime-header/display-shim.d.ts +1 -0
  58. package/dist/ui-engine/runtime-header/display-shim.js +104 -0
  59. package/dist/ui-engine/runtime-header/forward-decls.d.ts +1 -0
  60. package/dist/ui-engine/runtime-header/forward-decls.js +137 -0
  61. package/dist/ui-engine/runtime-header/guard-close.d.ts +1 -0
  62. package/dist/ui-engine/runtime-header/guard-close.js +8 -0
  63. package/dist/ui-engine/runtime-header/image-drawing.d.ts +1 -0
  64. package/dist/ui-engine/runtime-header/image-drawing.js +300 -0
  65. package/dist/ui-engine/runtime-header/init-press-input.d.ts +1 -0
  66. package/dist/ui-engine/runtime-header/init-press-input.js +126 -0
  67. package/dist/ui-engine/runtime-header/keyboard.d.ts +1 -0
  68. package/dist/ui-engine/runtime-header/keyboard.js +342 -0
  69. package/dist/ui-engine/runtime-header/node-decoration.d.ts +1 -0
  70. package/dist/ui-engine/runtime-header/node-decoration.js +212 -0
  71. package/dist/ui-engine/runtime-header/paint-order-coords.d.ts +1 -0
  72. package/dist/ui-engine/runtime-header/paint-order-coords.js +190 -0
  73. package/dist/ui-engine/runtime-header/paint-rects-repair.d.ts +1 -0
  74. package/dist/ui-engine/runtime-header/paint-rects-repair.js +553 -0
  75. package/dist/ui-engine/runtime-header/scroll-physics.d.ts +1 -0
  76. package/dist/ui-engine/runtime-header/scroll-physics.js +161 -0
  77. package/dist/ui-engine/runtime-header/state-bindings-nav.d.ts +1 -0
  78. package/dist/ui-engine/runtime-header/state-bindings-nav.js +176 -0
  79. package/dist/ui-engine/runtime-header/structs.d.ts +1 -0
  80. package/dist/ui-engine/runtime-header/structs.js +220 -0
  81. package/dist/ui-engine/runtime-header/text-rendering.d.ts +1 -0
  82. package/dist/ui-engine/runtime-header/text-rendering.js +715 -0
  83. package/dist/ui-engine/runtime-header/tick/bindings-phase.d.ts +1 -0
  84. package/dist/ui-engine/runtime-header/tick/bindings-phase.js +103 -0
  85. package/dist/ui-engine/runtime-header/tick/dirty-draw-phase.d.ts +1 -0
  86. package/dist/ui-engine/runtime-header/tick/dirty-draw-phase.js +1051 -0
  87. package/dist/ui-engine/runtime-header/tick/flush-phase.d.ts +1 -0
  88. package/dist/ui-engine/runtime-header/tick/flush-phase.js +17 -0
  89. package/dist/ui-engine/runtime-header/tick/scroll-canvas-phase.d.ts +1 -0
  90. package/dist/ui-engine/runtime-header/tick/scroll-canvas-phase.js +15 -0
  91. package/dist/ui-engine/runtime-header/tick/transitions-phase.d.ts +1 -0
  92. package/dist/ui-engine/runtime-header/tick/transitions-phase.js +213 -0
  93. package/dist/ui-engine/runtime-header/tick-motion-helpers.d.ts +1 -0
  94. package/dist/ui-engine/runtime-header/tick-motion-helpers.js +26 -0
  95. package/dist/ui-engine/runtime-header/tick.d.ts +1 -0
  96. package/dist/ui-engine/runtime-header/tick.js +17 -0
  97. package/dist/ui-engine/runtime-header/touch-keyboard-fwd.d.ts +1 -0
  98. package/dist/ui-engine/runtime-header/touch-keyboard-fwd.js +406 -0
  99. package/dist/ui-engine/runtime-header/types-defines.d.ts +1 -0
  100. package/dist/ui-engine/runtime-header/types-defines.js +79 -0
  101. package/dist/ui-engine/runtime-header.d.ts +1 -0
  102. package/dist/ui-engine/runtime-header.js +69 -0
  103. package/dist/ui-engine/scroll-memory-diagnostics.d.ts +16 -0
  104. package/dist/ui-engine/scroll-memory-diagnostics.js +58 -0
  105. package/dist/ui-engine/select-engine.d.ts +4 -0
  106. package/dist/ui-engine/select-engine.js +19 -0
  107. package/dist/ui-engine/style-resolver.d.ts +77 -0
  108. package/dist/ui-engine/style-resolver.js +341 -0
  109. package/dist/ui-engine/text-layout.d.ts +19 -0
  110. package/dist/ui-engine/text-layout.js +92 -0
  111. package/dist/ui-engine/transpile-ui.d.ts +11 -0
  112. package/dist/ui-engine/transpile-ui.js +33 -0
  113. package/dist/ui-engine/ua-stylesheet.d.ts +3 -0
  114. package/dist/ui-engine/ua-stylesheet.js +56 -0
  115. package/dist/ui-engine/ui-file-splitter.d.ts +14 -0
  116. package/dist/ui-engine/ui-file-splitter.js +47 -0
  117. package/dist/ui-engine/ui-lowering.d.ts +33 -0
  118. package/dist/ui-engine/ui-lowering.js +390 -0
  119. package/dist/ui-engine/ui-registry.d.ts +81 -0
  120. package/dist/ui-engine/ui-registry.js +429 -0
  121. package/dist/ui-engine/yoga-layout.d.ts +12 -0
  122. package/dist/ui-engine/yoga-layout.js +314 -0
  123. package/package.json +86 -0
  124. package/src/engine-index.ts +51 -0
  125. package/src/index.ts +232 -0
  126. package/src/preview/build-program.ts +734 -0
  127. package/src/preview/host-gfx.ts +580 -0
  128. package/src/preview/host-ui-runtime.ts +3245 -0
  129. package/src/preview/types.ts +96 -0
  130. package/src/types.ts +127 -0
  131. package/src/ui-engine/block-layout.ts +104 -0
  132. package/src/ui-engine/color.ts +263 -0
  133. package/src/ui-engine/css-parser.ts +1003 -0
  134. package/src/ui-engine/default-keyboards.ts +58 -0
  135. package/src/ui-engine/easing.ts +53 -0
  136. package/src/ui-engine/font-assets.ts +512 -0
  137. package/src/ui-engine/html-parser.ts +397 -0
  138. package/src/ui-engine/image-assets.ts +170 -0
  139. package/src/ui-engine/inline-parser.ts +78 -0
  140. package/src/ui-engine/keyframes.ts +47 -0
  141. package/src/ui-engine/layout-engine.ts +228 -0
  142. package/src/ui-engine/model.ts +1225 -0
  143. package/src/ui-engine/rich-layout.ts +257 -0
  144. package/src/ui-engine/run-types.ts +14 -0
  145. package/src/ui-engine/runtime-header/antialiasing.ts +141 -0
  146. package/src/ui-engine/runtime-header/blend-bodies.ts +39 -0
  147. package/src/ui-engine/runtime-header/canvas-helpers.ts +155 -0
  148. package/src/ui-engine/runtime-header/canvas-scrollbar.ts +105 -0
  149. package/src/ui-engine/runtime-header/color-mono-refresh.ts +154 -0
  150. package/src/ui-engine/runtime-header/dirty-scroll-mutators.ts +169 -0
  151. package/src/ui-engine/runtime-header/display-shim.ts +104 -0
  152. package/src/ui-engine/runtime-header/forward-decls.ts +137 -0
  153. package/src/ui-engine/runtime-header/guard-close.ts +8 -0
  154. package/src/ui-engine/runtime-header/image-drawing.ts +300 -0
  155. package/src/ui-engine/runtime-header/init-press-input.ts +126 -0
  156. package/src/ui-engine/runtime-header/keyboard.ts +342 -0
  157. package/src/ui-engine/runtime-header/node-decoration.ts +212 -0
  158. package/src/ui-engine/runtime-header/paint-order-coords.ts +190 -0
  159. package/src/ui-engine/runtime-header/paint-rects-repair.ts +553 -0
  160. package/src/ui-engine/runtime-header/scroll-physics.ts +161 -0
  161. package/src/ui-engine/runtime-header/state-bindings-nav.ts +176 -0
  162. package/src/ui-engine/runtime-header/structs.ts +220 -0
  163. package/src/ui-engine/runtime-header/text-rendering.ts +715 -0
  164. package/src/ui-engine/runtime-header/tick/bindings-phase.ts +103 -0
  165. package/src/ui-engine/runtime-header/tick/dirty-draw-phase.ts +1051 -0
  166. package/src/ui-engine/runtime-header/tick/flush-phase.ts +17 -0
  167. package/src/ui-engine/runtime-header/tick/scroll-canvas-phase.ts +15 -0
  168. package/src/ui-engine/runtime-header/tick/transitions-phase.ts +213 -0
  169. package/src/ui-engine/runtime-header/tick-motion-helpers.ts +26 -0
  170. package/src/ui-engine/runtime-header/tick.ts +18 -0
  171. package/src/ui-engine/runtime-header/touch-keyboard-fwd.ts +406 -0
  172. package/src/ui-engine/runtime-header/types-defines.ts +79 -0
  173. package/src/ui-engine/runtime-header.ts +71 -0
  174. package/src/ui-engine/scroll-memory-diagnostics.ts +79 -0
  175. package/src/ui-engine/select-engine.ts +21 -0
  176. package/src/ui-engine/style-resolver.ts +416 -0
  177. package/src/ui-engine/text-layout.ts +114 -0
  178. package/src/ui-engine/transpile-ui.ts +45 -0
  179. package/src/ui-engine/ua-stylesheet.ts +60 -0
  180. package/src/ui-engine/ui-file-splitter.ts +59 -0
  181. package/src/ui-engine/ui-lowering.ts +459 -0
  182. package/src/ui-engine/ui-registry.ts +530 -0
  183. package/src/ui-engine/yoga-layout.ts +309 -0
@@ -0,0 +1,58 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Built-in default keyboard templates — emitted by the transpiler when an
3
+ // <input> has no explicit <keyboard> ref. Both are KeyboardTemplate constants
4
+ // (same shape as author-written <keyboard> blocks) so the lowering path is
5
+ // uniform: a loader function is generated for each.
6
+ //
7
+ // Every key carries well-known CSS classes (ui-key, ui-key-ok, etc.) so authors
8
+ // can theme the defaults without writing a custom <keyboard>. The lowering
9
+ // resolves these via CSS and falls back to hardcoded defaults when no CSS
10
+ // matches.
11
+ // ---------------------------------------------------------------------------
12
+
13
+ import type { KeyboardTemplate, UIKeyTemplate } from "./html-parser.js";
14
+
15
+ const k = (ch: string): UIKeyTemplate => ({ ch, special: 0, classes: ["ui-key"] });
16
+ const sk = (ch: string, special: 1 | 2 | 3 | 4, kind: string): UIKeyTemplate => ({
17
+ ch,
18
+ special,
19
+ classes: ["ui-key", `ui-key-${kind}`],
20
+ });
21
+
22
+ // Alpha: 10×4 grid (bottom dock). Row 2 has shift + backspace; row 3 has
23
+ // 123 (page-swap to symbols), space (_), and OK.
24
+ export const DEFAULT_ALPHA_KEYBOARD: KeyboardTemplate = {
25
+ id: "default_alpha",
26
+ variant: "alpha",
27
+ classes: ["ui-keyboard"],
28
+ rows: [
29
+ [k("1"), k("2"), k("3"), k("4"), k("5"), k("6"), k("7"), k("8"), k("9"), k("0")],
30
+ [k("q"), k("w"), k("e"), k("r"), k("t"), k("y"), k("u"), k("i"), k("o"), k("p")],
31
+ [
32
+ sk("⇧", 1, "shift"),
33
+ k("a"), k("s"), k("d"), k("f"), k("g"), k("h"), k("j"), k("k"), k("l"),
34
+ sk("⌫", 2, "del"),
35
+ ],
36
+ [
37
+ sk("123", 4, "page"),
38
+ k("z"), k("x"), k("c"), k("v"), k("b"), k("n"), k("m"),
39
+ k("_"),
40
+ sk("OK", 3, "ok"),
41
+ ],
42
+ ],
43
+ };
44
+
45
+ // Number: 4×4 grid (kept uniform with the alpha column count so the hit-mapping
46
+ // math stays simple). Covers digits, ".", "-", ABC (page-swap to alpha),
47
+ // backspace, and OK.
48
+ export const DEFAULT_NUMBER_KEYBOARD: KeyboardTemplate = {
49
+ id: "default_number",
50
+ variant: "number",
51
+ classes: ["ui-keyboard"],
52
+ rows: [
53
+ [k("1"), k("2"), k("3"), sk("⌫", 2, "del")],
54
+ [k("4"), k("5"), k("6"), k(".")],
55
+ [k("7"), k("8"), k("9"), k("-")],
56
+ [sk("ABC", 4, "page"), k("0"), sk("OK", 3, "ok")],
57
+ ],
58
+ };
@@ -0,0 +1,53 @@
1
+ // animation-timing-function codes (kept in sync with runtime-header.ts UI_TIMING_*).
2
+ export const TIMING_LINEAR = 0;
3
+ export const TIMING_EASE_IN_OUT = 1;
4
+ export const TIMING_EASE = 2;
5
+ export const TIMING_EASE_IN = 3;
6
+ export const TIMING_EASE_OUT = 4;
7
+
8
+ /** Map a CSS animation-timing-function keyword to its TIMING_* code.
9
+ * Unknown/unsupported (cubic-bezier(), steps()) -> linear. */
10
+ export function timingFunctionCode(keyword: string | undefined): number {
11
+ switch ((keyword ?? "").trim().toLowerCase()) {
12
+ case "ease-in-out": return TIMING_EASE_IN_OUT;
13
+ case "ease": return TIMING_EASE;
14
+ case "ease-in": return TIMING_EASE_IN;
15
+ case "ease-out": return TIMING_EASE_OUT;
16
+ default: return TIMING_LINEAR; // linear + cubic-bezier() + steps() unsupported
17
+ }
18
+ }
19
+
20
+ /** Apply the easing curve to a 0..100 linear lerp factor (fixed-point, no
21
+ * floats). Mirrors the C++ ui_ease_lerp_k in runtime-header.ts exactly.
22
+ * Returns k unchanged for TIMING_LINEAR. */
23
+ export function easeCurveLerpK(timing: number, k: number): number {
24
+ if (timing === TIMING_LINEAR || k <= 0) return k;
25
+ if (k >= 100) return 100;
26
+ // CSS cubic-bezier control points (normalized 0..1). Endpoints are (0,0),(1,1).
27
+ let x1 = 0, y1 = 0, x2 = 1, y2 = 1;
28
+ switch (timing) {
29
+ case TIMING_EASE_IN_OUT: x1 = 0.42; y1 = 0; x2 = 0.58; y2 = 1; break;
30
+ case TIMING_EASE: x1 = 0.25; y1 = 0.1; x2 = 0.25; y2 = 1; break;
31
+ case TIMING_EASE_IN: x1 = 0.42; y1 = 0; x2 = 1; y2 = 1; break;
32
+ case TIMING_EASE_OUT: x1 = 0; y1 = 0; x2 = 0.58; y2 = 1; break;
33
+ default: return k;
34
+ }
35
+ // Solve X(t)=input for t, then return Y(t). X(t)=3(1-t)^2*t*x1 + 3(1-t)*t^2*x2 + t^3.
36
+ // Bisection (not Newton-Raphson): Newton diverges for curves whose x-derivative
37
+ // is ~0 near an endpoint (ease-out: x1=0), snapping the dot to the wrong stop.
38
+ // X(t) is monotonic increasing for valid CSS control points, so bisection always
39
+ // converges. The device runtime's ui_ease_lerp_k uses the identical algorithm +
40
+ // control points (in /1000 fixed point) so preview and device agree.
41
+ const input = k / 100;
42
+ let lo = 0, hi = 1;
43
+ for (let i = 0; i < 20; i++) {
44
+ const t = (lo + hi) / 2;
45
+ const mt = 1 - t;
46
+ const x = 3 * mt * mt * t * x1 + 3 * mt * t * t * x2 + t * t * t;
47
+ if (x < input) lo = t; else hi = t;
48
+ }
49
+ const t = (lo + hi) / 2;
50
+ const mt = 1 - t;
51
+ const y = 3 * mt * mt * t * y1 + 3 * mt * t * t * y2 + t * t * t;
52
+ return Math.round(y * 100);
53
+ }
@@ -0,0 +1,512 @@
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
+ export function buildUIFontAssets(
162
+ root: StyledNode,
163
+ fontFaces: CSSFontFace[],
164
+ baseDir: string,
165
+ ): UIFontAssetModel[] {
166
+ const plans = planUIFontAssets(root, fontFaces, baseDir);
167
+
168
+ const parsedFonts = new Map<string, any>();
169
+ const assets: UIFontAssetModel[] = [];
170
+ let id = 1;
171
+ for (const plan of plans) {
172
+ let font = parsedFonts.get(plan.sourcePath);
173
+ if (!font) {
174
+ const bytes = fs.readFileSync(plan.sourcePath);
175
+ const arrayBuffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
176
+ font = opentype.parse(arrayBuffer);
177
+ parsedFonts.set(plan.sourcePath, font);
178
+ }
179
+ assets.push(rasterizeFontAsset({
180
+ id: id++,
181
+ family: plan.family,
182
+ sourcePath: plan.sourcePath,
183
+ px: plan.px,
184
+ fontWeight: plan.fontWeight,
185
+ fontStyle: plan.fontStyle,
186
+ subset: plan.subset,
187
+ chars: plan.chars,
188
+ font,
189
+ }));
190
+ }
191
+
192
+ return assets;
193
+ }
194
+
195
+ export function planUIFontAssets(
196
+ root: StyledNode,
197
+ fontFaces: CSSFontFace[],
198
+ baseDir: string,
199
+ ): UIFontAssetPlan[] {
200
+ if (fontFaces.length === 0) return [];
201
+
202
+ const requests = new Map<string, FontAssetRequest>();
203
+ const collect = (node: StyledNode) => {
204
+ const face = selectFontFaceForStyle(fontFaces, node.style);
205
+ if (face) {
206
+ const px = fontPxOf(node.style);
207
+ const sourcePath = resolveFontPath(face.src, baseDir);
208
+ const fontWeight = normalizeFontWeight(face.fontWeight ?? node.style.fontWeight);
209
+ const fontStyle = normalizeFontStyle(face.fontStyle ?? node.style.fontStyle);
210
+ const key = `${sourcePath}:${px}:${fontWeight}:${fontStyle}`;
211
+ let request = requests.get(key);
212
+ if (!request) {
213
+ request = {
214
+ family: face.fontFamily,
215
+ sourcePath,
216
+ px,
217
+ fontWeight,
218
+ fontStyle,
219
+ subset: "exact",
220
+ chars: new Set<string>(),
221
+ };
222
+ requests.set(key, request);
223
+ }
224
+ if (fontSubsetOf(node.style) === "fallback" && request.subset !== "fallback") {
225
+ request.subset = "fallback";
226
+ addText(request.chars, FALLBACK_CHARS);
227
+ }
228
+ addNodeText(request.chars, node);
229
+ }
230
+ // Rich-text inline runs: each run has its own resolved style (bold, italic,
231
+ // different font-size) which maps to a different font face/asset. Collect
232
+ // each run's text under its OWN style so the per-face subsetting includes
233
+ // the run's characters. Without this, the run's font face is subsetted from
234
+ // unrelated text and glyphs go missing at draw time.
235
+ if (node.runs) {
236
+ for (const run of node.runs) {
237
+ const runStyle = { ...node.style, ...run.style } as CSSProperty;
238
+ const runFace = selectFontFaceForStyle(fontFaces, runStyle);
239
+ if (!runFace) continue;
240
+ const runPx = fontPxOf(runStyle);
241
+ const runSourcePath = resolveFontPath(runFace.src, baseDir);
242
+ const runWeight = normalizeFontWeight(runFace.fontWeight ?? runStyle.fontWeight);
243
+ const runStyleAttr = normalizeFontStyle(runFace.fontStyle ?? runStyle.fontStyle);
244
+ const runKey = `${runSourcePath}:${runPx}:${runWeight}:${runStyleAttr}`;
245
+ let runReq = requests.get(runKey);
246
+ if (!runReq) {
247
+ runReq = {
248
+ family: runFace.fontFamily,
249
+ sourcePath: runSourcePath,
250
+ px: runPx,
251
+ fontWeight: runWeight,
252
+ fontStyle: runStyleAttr,
253
+ subset: "exact",
254
+ chars: new Set<string>(),
255
+ };
256
+ requests.set(runKey, runReq);
257
+ }
258
+ addText(runReq.chars, applyTextTransform(run.text, runStyle));
259
+ }
260
+ }
261
+ for (const child of node.children) collect(child);
262
+ };
263
+ collect(root);
264
+
265
+ return [...requests.values()]
266
+ .filter((request) => request.chars.size > 0)
267
+ .map((request) => ({
268
+ family: request.family,
269
+ sourcePath: request.sourcePath,
270
+ px: request.px,
271
+ fontWeight: request.fontWeight,
272
+ fontStyle: request.fontStyle,
273
+ subset: request.subset,
274
+ chars: [...request.chars].sort((a, b) => a.codePointAt(0)! - b.codePointAt(0)!),
275
+ }));
276
+ }
277
+
278
+ function resolveFontPath(src: string, baseDir: string): string {
279
+ if (/^https?:\/\//i.test(src)) {
280
+ throw new Error(`@font-face src "${src}" is remote; use a local font file for embedded builds.`);
281
+ }
282
+ const withoutFileScheme = src.startsWith("file://") ? src.slice("file://".length) : src;
283
+ const resolved = path.isAbsolute(withoutFileScheme)
284
+ ? withoutFileScheme
285
+ : path.resolve(baseDir, withoutFileScheme);
286
+ if (!fs.existsSync(resolved)) {
287
+ throw new Error(`@font-face font file not found: ${resolved}`);
288
+ }
289
+ return resolved;
290
+ }
291
+
292
+ function fontFaceScore(face: CSSFontFace, desiredWeight: number, desiredStyle: string): number {
293
+ const style = normalizeFontStyle(face.fontStyle);
294
+ const weight = Number(normalizeFontWeight(face.fontWeight));
295
+ return styleScore(style, desiredStyle) * 10000 + Math.abs(weight - desiredWeight);
296
+ }
297
+
298
+ function fontAssetScore(asset: UIFontAssetModel, desiredWeight: number, desiredStyle: string): number {
299
+ const weight = Number(normalizeFontWeight(asset.fontWeight));
300
+ return styleScore(asset.fontStyle, desiredStyle) * 10000 + Math.abs(weight - desiredWeight);
301
+ }
302
+
303
+ function styleScore(actual: string, desired: string): number {
304
+ if (actual === desired) return 0;
305
+ if (actual === "normal") return 1;
306
+ return 2;
307
+ }
308
+
309
+ function addNodeText(chars: Set<string>, node: StyledNode): void {
310
+ addText(chars, applyTextTransform(node.text, node.style));
311
+ addText(chars, applyTextTransform(node.placeholder, node.style));
312
+ for (const option of node.options ?? []) {
313
+ addText(chars, applyTextTransform(option.text, node.style));
314
+ }
315
+ // Interpolation ({expr}) and text bindings produce runtime text the static
316
+ // template can't predict (numbers, dates, etc). Add digits + common
317
+ // formatting chars so the font subset can render the runtime output.
318
+ if (node.text && node.text.includes("{")) {
319
+ addText(chars, "0123456789.,-+/()%");
320
+ }
321
+ // <input> nodes on the SDL desktop target (UI_HIDE_OSK) accept arbitrary
322
+ // real-keyboard text — the OSK grid isn't shown, so the user can type any
323
+ // character, not just the keys on the on-screen grid. Pack the full printable
324
+ // ASCII range so every typed character has a glyph (otherwise letters absent
325
+ // from static UI text render blank — ui_font_glyph returns null). Hardware
326
+ // targets keep the minimal subset: the OSK grid is the only input path and
327
+ // only carries the keys it shows.
328
+ if (node.tag === "input") {
329
+ let driver: string | undefined;
330
+ try { driver = getDisplayProfile().driver; } catch { /* no profile bound */ }
331
+ if (driver === "sdl") {
332
+ let ascii = "";
333
+ for (let cp = 0x20; cp <= 0x7e; cp++) ascii += String.fromCodePoint(cp);
334
+ addText(chars, ascii);
335
+ }
336
+ }
337
+ }
338
+
339
+ function applyTextTransform(text: string | undefined, style: CSSProperty): string | undefined {
340
+ if (!text) return text;
341
+ switch (style.textTransform) {
342
+ case "uppercase": return text.toUpperCase();
343
+ case "lowercase": return text.toLowerCase();
344
+ case "capitalize":
345
+ return text.replace(/\b\w/g, (c) => c.toUpperCase());
346
+ default: return text;
347
+ }
348
+ }
349
+
350
+ function addText(chars: Set<string>, text: string | undefined): void {
351
+ if (!text) return;
352
+ for (const ch of text) {
353
+ const cp = ch.codePointAt(0);
354
+ if (cp !== undefined && cp >= 32 && cp <= 0xffff) chars.add(ch);
355
+ }
356
+ }
357
+
358
+ function rasterizeFontAsset(options: {
359
+ id: number;
360
+ family: string;
361
+ sourcePath: string;
362
+ px: number;
363
+ fontWeight: string;
364
+ fontStyle: string;
365
+ subset: UIFontSubsetMode;
366
+ chars: string[];
367
+ font: any;
368
+ }): UIFontAssetModel {
369
+ const scale = options.px / options.font.unitsPerEm;
370
+ const baseline = Math.ceil((options.font.ascender ?? options.font.unitsPerEm) * scale) + 1;
371
+ const lineHeight = Math.ceil(((options.font.ascender ?? options.font.unitsPerEm) - (options.font.descender ?? 0)) * scale) + 2;
372
+ const glyphs: UIFontGlyphModel[] = [];
373
+ const unpackedAlpha: number[] = [];
374
+
375
+ for (const ch of options.chars) {
376
+ const glyph = options.font.charToGlyph(ch);
377
+ const advance = Math.max(1, Math.ceil((glyph.advanceWidth ?? options.font.unitsPerEm / 2) * scale));
378
+ const path = glyph.getPath(0, 0, options.px);
379
+ const bbox = path.getBoundingBox();
380
+ const empty = !Number.isFinite(bbox.x1) || !Number.isFinite(bbox.y1) || bbox.x1 === bbox.x2 || bbox.y1 === bbox.y2;
381
+ const xOffset = empty ? 0 : Math.floor(bbox.x1) - 1;
382
+ const yOffset = empty ? 0 : Math.floor(bbox.y1) - 1;
383
+ const width = empty ? 0 : Math.max(0, Math.ceil(bbox.x2) - xOffset + 1);
384
+ const height = empty ? 0 : Math.max(0, Math.ceil(bbox.y2) - yOffset + 1);
385
+ const dataOffset = unpackedAlpha.length;
386
+
387
+ if (width > 0 && height > 0) {
388
+ const contours = flattenPath(path.commands as OpenTypePathCommand[]);
389
+ for (let py = 0; py < height; py++) {
390
+ for (let px = 0; px < width; px++) {
391
+ let covered = 0;
392
+ for (let sy = 0; sy < SUPERSAMPLE; sy++) {
393
+ for (let sx = 0; sx < SUPERSAMPLE; sx++) {
394
+ const x = xOffset + px + (sx + 0.5) / SUPERSAMPLE;
395
+ const y = yOffset + py + (sy + 0.5) / SUPERSAMPLE;
396
+ if (pointInContours(x, y, contours)) covered++;
397
+ }
398
+ }
399
+ unpackedAlpha.push(Math.round((covered * 15) / (SUPERSAMPLE * SUPERSAMPLE)));
400
+ }
401
+ }
402
+ }
403
+
404
+ glyphs.push({
405
+ codepoint: ch.codePointAt(0) ?? 0,
406
+ xOffset,
407
+ yOffset,
408
+ width,
409
+ height,
410
+ advance,
411
+ dataOffset,
412
+ });
413
+ }
414
+
415
+ return {
416
+ id: options.id,
417
+ family: options.family,
418
+ sourcePath: options.sourcePath,
419
+ px: options.px,
420
+ fontWeight: options.fontWeight,
421
+ fontStyle: options.fontStyle,
422
+ subset: options.subset,
423
+ lineHeight,
424
+ baseline,
425
+ glyphs,
426
+ alpha: packNibbles(unpackedAlpha),
427
+ };
428
+ }
429
+
430
+ function flattenPath(commands: OpenTypePathCommand[]): Point[][] {
431
+ const contours: Point[][] = [];
432
+ let current: Point = { x: 0, y: 0 };
433
+ let start: Point | null = null;
434
+ let contour: Point[] = [];
435
+
436
+ const push = (p: Point) => {
437
+ contour.push(p);
438
+ current = p;
439
+ };
440
+ const finish = () => {
441
+ if (contour.length > 1) contours.push(contour);
442
+ contour = [];
443
+ start = null;
444
+ };
445
+
446
+ for (const cmd of commands) {
447
+ if (cmd.type === "M") {
448
+ finish();
449
+ current = { x: cmd.x ?? 0, y: cmd.y ?? 0 };
450
+ start = current;
451
+ contour = [current];
452
+ } else if (cmd.type === "L") {
453
+ push({ x: cmd.x ?? current.x, y: cmd.y ?? current.y });
454
+ } else if (cmd.type === "Q") {
455
+ const p0 = current;
456
+ const p1 = { x: cmd.x1 ?? current.x, y: cmd.y1 ?? current.y };
457
+ const p2 = { x: cmd.x ?? current.x, y: cmd.y ?? current.y };
458
+ for (let i = 1; i <= 8; i++) {
459
+ const t = i / 8;
460
+ const mt = 1 - t;
461
+ push({
462
+ x: mt * mt * p0.x + 2 * mt * t * p1.x + t * t * p2.x,
463
+ y: mt * mt * p0.y + 2 * mt * t * p1.y + t * t * p2.y,
464
+ });
465
+ }
466
+ } else if (cmd.type === "C") {
467
+ const p0 = current;
468
+ const p1 = { x: cmd.x1 ?? current.x, y: cmd.y1 ?? current.y };
469
+ const p2 = { x: cmd.x2 ?? current.x, y: cmd.y2 ?? current.y };
470
+ const p3 = { x: cmd.x ?? current.x, y: cmd.y ?? current.y };
471
+ for (let i = 1; i <= 12; i++) {
472
+ const t = i / 12;
473
+ const mt = 1 - t;
474
+ push({
475
+ x: mt * mt * mt * p0.x + 3 * mt * mt * t * p1.x + 3 * mt * t * t * p2.x + t * t * t * p3.x,
476
+ y: mt * mt * mt * p0.y + 3 * mt * mt * t * p1.y + 3 * mt * t * t * p2.y + t * t * t * p3.y,
477
+ });
478
+ }
479
+ } else if (cmd.type === "Z") {
480
+ if (start) push(start);
481
+ finish();
482
+ }
483
+ }
484
+ finish();
485
+ return contours;
486
+ }
487
+
488
+ function pointInContours(x: number, y: number, contours: Point[][]): boolean {
489
+ let inside = false;
490
+ for (const contour of contours) {
491
+ for (let i = 0, j = contour.length - 1; i < contour.length; j = i++) {
492
+ const a = contour[i];
493
+ const b = contour[j];
494
+ const crosses = (a.y > y) !== (b.y > y);
495
+ if (crosses) {
496
+ const ix = ((b.x - a.x) * (y - a.y)) / (b.y - a.y) + a.x;
497
+ if (x < ix) inside = !inside;
498
+ }
499
+ }
500
+ }
501
+ return inside;
502
+ }
503
+
504
+ function packNibbles(values: number[]): number[] {
505
+ const out: number[] = [];
506
+ for (let i = 0; i < values.length; i += 2) {
507
+ const hi = Math.max(0, Math.min(15, values[i] ?? 0));
508
+ const lo = Math.max(0, Math.min(15, values[i + 1] ?? 0));
509
+ out.push((hi << 4) | lo);
510
+ }
511
+ return out;
512
+ }