@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.
- package/assets/fonts/dejavu/DejaVuSans-Bold.ttf +0 -0
- package/assets/fonts/dejavu/DejaVuSans-ExtraLight.ttf +0 -0
- package/assets/fonts/dejavu/DejaVuSans-Oblique.ttf +0 -0
- package/assets/fonts/dejavu/DejaVuSans.ttf +0 -0
- package/assets/fonts/dejavu/DejaVuSansMono-Bold.ttf +0 -0
- package/assets/fonts/dejavu/DejaVuSansMono.ttf +0 -0
- package/assets/fonts/dejavu/LICENSE +187 -0
- package/assets/fonts/dejavu/README.md +49 -0
- package/dist/engine-index.js +2 -0
- package/dist/preview/build-program.d.ts +7 -1
- package/dist/preview/build-program.js +94 -15
- package/dist/preview/host-gfx.d.ts +3 -0
- package/dist/preview/host-gfx.js +30 -3
- package/dist/preview/host-ui-runtime.d.ts +115 -0
- package/dist/preview/host-ui-runtime.js +928 -46
- package/dist/ui-engine/block-layout.js +25 -11
- package/dist/ui-engine/color.d.ts +6 -0
- package/dist/ui-engine/color.js +80 -2
- package/dist/ui-engine/compat-report.d.ts +8 -0
- package/dist/ui-engine/compat-report.js +124 -0
- package/dist/ui-engine/css-imports.d.ts +4 -0
- package/dist/ui-engine/css-imports.js +70 -0
- package/dist/ui-engine/css-parser.js +58 -14
- package/dist/ui-engine/default-font.d.ts +25 -0
- package/dist/ui-engine/default-font.js +80 -0
- package/dist/ui-engine/font-assets.d.ts +2 -2
- package/dist/ui-engine/font-assets.js +22 -5
- package/dist/ui-engine/html-parser.d.ts +4 -0
- package/dist/ui-engine/html-parser.js +241 -26
- package/dist/ui-engine/image-assets.d.ts +8 -1
- package/dist/ui-engine/image-assets.js +35 -2
- package/dist/ui-engine/image-decode.d.ts +22 -0
- package/dist/ui-engine/image-decode.js +194 -0
- package/dist/ui-engine/inline-parser.js +1 -1
- package/dist/ui-engine/layout-engine.d.ts +19 -0
- package/dist/ui-engine/layout-engine.js +59 -3
- package/dist/ui-engine/model.d.ts +18 -0
- package/dist/ui-engine/model.js +75 -7
- package/dist/ui-engine/runtime-header/blend-bodies.js +29 -9
- package/dist/ui-engine/runtime-header/canvas-helpers.js +3 -0
- package/dist/ui-engine/runtime-header/canvas-scrollbar.js +115 -111
- package/dist/ui-engine/runtime-header/cuttlefish-gfx.js +21 -0
- package/dist/ui-engine/runtime-header/dirty-scroll-mutators.js +182 -179
- package/dist/ui-engine/runtime-header/forward-decls.js +215 -167
- package/dist/ui-engine/runtime-header/init-press-input.js +138 -135
- package/dist/ui-engine/runtime-header/keyboard.js +682 -380
- package/dist/ui-engine/runtime-header/node-draw-body.js +1656 -1386
- package/dist/ui-engine/runtime-header/paint-order-coords.js +259 -214
- package/dist/ui-engine/runtime-header/paint-rects-repair.js +736 -568
- package/dist/ui-engine/runtime-header/scroll-physics.js +25 -1
- package/dist/ui-engine/runtime-header/state-bindings-nav.js +9 -0
- package/dist/ui-engine/runtime-header/structs.js +228 -216
- package/dist/ui-engine/runtime-header/text-rendering.js +888 -770
- package/dist/ui-engine/runtime-header/tick/bindings-phase.js +118 -116
- package/dist/ui-engine/runtime-header/tick/dirty-draw-phase.js +897 -633
- package/dist/ui-engine/runtime-header/tick/scroll-canvas-phase.js +24 -25
- package/dist/ui-engine/runtime-header/touch-keyboard-fwd.js +78 -23
- package/dist/ui-engine/runtime-header/types-defines.js +110 -93
- package/dist/ui-engine/shadcn-kit.d.ts +1 -0
- package/dist/ui-engine/shadcn-kit.js +502 -0
- package/dist/ui-engine/style-resolver.d.ts +4 -0
- package/dist/ui-engine/style-resolver.js +58 -6
- package/dist/ui-engine/transpile-ui.js +12 -5
- package/dist/ui-engine/ua-stylesheet.d.ts +22 -1
- package/dist/ui-engine/ua-stylesheet.js +119 -32
- package/dist/ui-engine/ui-lowering.js +37 -3
- package/dist/ui-engine/ui-registry.js +32 -4
- package/dist/ui-engine/yoga-layout.js +57 -52
- package/package.json +9 -3
- package/src/engine-index.ts +53 -51
- package/src/preview/build-program.ts +810 -734
- package/src/preview/host-gfx.ts +30 -3
- package/src/preview/host-ui-runtime.ts +4133 -3289
- package/src/ui-engine/block-layout.ts +24 -11
- package/src/ui-engine/color.ts +77 -2
- package/src/ui-engine/compat-report.ts +139 -0
- package/src/ui-engine/css-imports.ts +69 -0
- package/src/ui-engine/css-parser.ts +64 -15
- package/src/ui-engine/default-font.ts +95 -0
- package/src/ui-engine/font-assets.ts +545 -525
- package/src/ui-engine/html-parser.ts +615 -397
- package/src/ui-engine/image-assets.ts +37 -2
- package/src/ui-engine/image-decode.ts +244 -0
- package/src/ui-engine/inline-parser.ts +1 -1
- package/src/ui-engine/layout-engine.ts +61 -3
- package/src/ui-engine/model.ts +1317 -1230
- package/src/ui-engine/runtime-header/blend-bodies.ts +29 -9
- package/src/ui-engine/runtime-header/canvas-helpers.ts +3 -0
- package/src/ui-engine/runtime-header/canvas-scrollbar.ts +121 -117
- package/src/ui-engine/runtime-header/cuttlefish-gfx.ts +21 -0
- package/src/ui-engine/runtime-header/dirty-scroll-mutators.ts +188 -185
- package/src/ui-engine/runtime-header/forward-decls.ts +227 -179
- package/src/ui-engine/runtime-header/init-press-input.ts +144 -141
- package/src/ui-engine/runtime-header/keyboard.ts +689 -386
- package/src/ui-engine/runtime-header/node-draw-body.ts +1681 -1411
- package/src/ui-engine/runtime-header/paint-order-coords.ts +265 -220
- package/src/ui-engine/runtime-header/paint-rects-repair.ts +742 -574
- package/src/ui-engine/runtime-header/scroll-physics.ts +25 -1
- package/src/ui-engine/runtime-header/state-bindings-nav.ts +9 -0
- package/src/ui-engine/runtime-header/structs.ts +234 -222
- package/src/ui-engine/runtime-header/text-rendering.ts +894 -776
- package/src/ui-engine/runtime-header/tick/bindings-phase.ts +124 -122
- package/src/ui-engine/runtime-header/tick/dirty-draw-phase.ts +902 -638
- package/src/ui-engine/runtime-header/tick/scroll-canvas-phase.ts +30 -31
- package/src/ui-engine/runtime-header/touch-keyboard-fwd.ts +78 -23
- package/src/ui-engine/runtime-header/types-defines.ts +116 -99
- package/src/ui-engine/shadcn-kit.ts +503 -0
- package/src/ui-engine/style-resolver.ts +471 -416
- package/src/ui-engine/transpile-ui.ts +12 -5
- package/src/ui-engine/ua-stylesheet.ts +137 -31
- package/src/ui-engine/ui-lowering.ts +486 -452
- package/src/ui-engine/ui-registry.ts +556 -524
- 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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
function
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
const
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
height
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
+
}
|