@glissade/core 0.11.0-pre.1 → 0.12.0-pre.0
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/dist/clips.d.ts +114 -0
- package/dist/clips.js +208 -0
- package/dist/cmap.js +112 -0
- package/dist/font-ingest.d.ts +154 -0
- package/dist/font-ingest.js +282 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.js +3 -112
- package/dist/sidecar.d.ts +5 -470
- package/dist/sidecar.js +2 -967
- package/dist/studioHost.d.ts +4 -1
- package/dist/studioHost.js +2 -1
- package/dist/targetRef.d.ts +33 -0
- package/dist/targetRef.js +992 -0
- package/dist/timeline.d.ts +138 -0
- package/dist/track.d.ts +329 -0
- package/package.json +12 -1
package/dist/clips.d.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { J as EaseSpec, T as ValueTypeId, r as Track, t as Key } from "./track.js";
|
|
2
|
+
import { r as TweenTarget } from "./targetRef.js";
|
|
3
|
+
|
|
4
|
+
//#region src/clip.d.ts
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One channel of a clip: a relative-time key schedule (t from 0) plus the default
|
|
8
|
+
* target-suffix it binds to and an optional value type. `type` defaults via
|
|
9
|
+
* `inferValueType(keys[0].value)` at apply-time.
|
|
10
|
+
*/
|
|
11
|
+
interface ClipChannel<T = unknown> {
|
|
12
|
+
/** Relative-time keys; `t` runs from 0. Strictly-increasing t enforced at apply. */
|
|
13
|
+
keys: Key<T>[];
|
|
14
|
+
/** Default target suffix, e.g. 'opacity', 'scale', 'position'. */
|
|
15
|
+
path: string;
|
|
16
|
+
/** Registered value type; defaults to inferValueType(keys[0].value). */
|
|
17
|
+
type?: ValueTypeId;
|
|
18
|
+
}
|
|
19
|
+
/** A clip spec: a bag of named channels. */
|
|
20
|
+
interface ClipSpec {
|
|
21
|
+
channels: Record<string, ClipChannel>;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Per-channel value/ease substitution. Keeps the channel's key TOPOLOGY — the
|
|
25
|
+
* first key's value becomes `from`, the last key's value becomes `to`, and `ease`
|
|
26
|
+
* replaces the arriving ease of the LAST segment. No keys are added or removed
|
|
27
|
+
* (0.12 scope).
|
|
28
|
+
*/
|
|
29
|
+
interface ChannelOverride<T = unknown> {
|
|
30
|
+
from?: T;
|
|
31
|
+
to?: T;
|
|
32
|
+
ease?: EaseSpec;
|
|
33
|
+
}
|
|
34
|
+
interface ApplyOpts {
|
|
35
|
+
/** Per-channel value/ease substitution (topology-preserving). */
|
|
36
|
+
overrides?: Record<string, ChannelOverride>;
|
|
37
|
+
/** Divides every relative `t` (speed 2 = half-time). Must be > 0. */
|
|
38
|
+
speed?: number;
|
|
39
|
+
}
|
|
40
|
+
interface ClipResult {
|
|
41
|
+
tracks: Track[];
|
|
42
|
+
/** Wall-clock end second of the longest channel. */
|
|
43
|
+
end: number;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* A per-channel target map for `apply`: each channel name maps to a `TweenTarget`
|
|
47
|
+
* (string or property signal) overriding the channel's default-path resolution —
|
|
48
|
+
* e.g. a `glow` channel → `'card-halo/opacity'`. The string form of `apply` is a
|
|
49
|
+
* strict superset of this (it resolves every channel against ONE node id).
|
|
50
|
+
*/
|
|
51
|
+
type ClipTarget = string | Record<string, TweenTarget>;
|
|
52
|
+
declare class ClipError extends Error {
|
|
53
|
+
constructor(message: string);
|
|
54
|
+
}
|
|
55
|
+
/** A compiled clip: the spec plus `apply`/`duration`. */
|
|
56
|
+
interface Clip {
|
|
57
|
+
readonly spec: ClipSpec;
|
|
58
|
+
/** Longest channel's last relative key time (the clip's intrinsic length). */
|
|
59
|
+
readonly duration: number;
|
|
60
|
+
apply(target: ClipTarget, startSec: number, opts?: ApplyOpts): ClipResult;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Build a clip from a spec. Pure: holds no state; `apply` returns fresh tracks.
|
|
64
|
+
*
|
|
65
|
+
* const fade = clip({ channels: { fade: { keys: [key(0, 0), key(0.3, 1)], path: 'opacity' } } });
|
|
66
|
+
* const { tracks, end } = fade.apply('card', 1.0); // → 'card/opacity' track at t∈[1.0,1.3]
|
|
67
|
+
*/
|
|
68
|
+
declare function clip(spec: ClipSpec): Clip;
|
|
69
|
+
/** Per-target list fan-out delay — reuses the `stagger` shape/semantics. */
|
|
70
|
+
type ClipListDelay = number | ((index: number) => number);
|
|
71
|
+
interface ClipListOpts extends ApplyOpts {
|
|
72
|
+
/** Per-index offset (seconds), or a function of the index. Default 0. */
|
|
73
|
+
stagger?: ClipListDelay;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Fan a clip out over a list of targets, offsetting child i by a `stagger`-style
|
|
77
|
+
* delay (REUSES the existing `stagger` shape/semantics — number per-index gap, or
|
|
78
|
+
* a function of the index). `end` is the max child end.
|
|
79
|
+
*
|
|
80
|
+
* clipList(popIn(), items.map((it) => it.id), 0, { stagger: 0.08 })
|
|
81
|
+
*/
|
|
82
|
+
declare function clipList(c: Clip, targets: readonly ClipTarget[], startSec: number, opts?: ClipListOpts): ClipResult;
|
|
83
|
+
//#endregion
|
|
84
|
+
//#region src/clipStdlib.d.ts
|
|
85
|
+
interface DurationOpts {
|
|
86
|
+
/** Total clip length in seconds. */
|
|
87
|
+
duration?: number;
|
|
88
|
+
/** Arriving ease of the motion. */
|
|
89
|
+
ease?: EaseSpec;
|
|
90
|
+
}
|
|
91
|
+
/** Entrance: opacity 0→1 and scale 0.8→1 (a "pop" in). */
|
|
92
|
+
declare function popIn(opts?: DurationOpts): Clip;
|
|
93
|
+
type SlideEdge = 'left' | 'right' | 'top' | 'bottom';
|
|
94
|
+
/**
|
|
95
|
+
* Entrance: fade in while sliding a position OFFSET in from one edge. The offset
|
|
96
|
+
* channel binds to a `position` suffix by default and animates a vec2 from the
|
|
97
|
+
* edge-displaced point to [0,0], so it composes as a local translation.
|
|
98
|
+
*/
|
|
99
|
+
declare function slideIn(edge: SlideEdge, opts?: (DurationOpts & {
|
|
100
|
+
distance?: number;
|
|
101
|
+
})): Clip;
|
|
102
|
+
/** Emphasis: a single scale up-and-back pulse. First/last value match (loopable). */
|
|
103
|
+
declare function pulse(opts?: (DurationOpts & {
|
|
104
|
+
scale?: number;
|
|
105
|
+
})): Clip;
|
|
106
|
+
/**
|
|
107
|
+
* Ambient: a slow position drift out and back. First/last value match, so tiling
|
|
108
|
+
* (clipList or repeat) reads as a continuous loop with no seam.
|
|
109
|
+
*/
|
|
110
|
+
declare function driftLoop(opts?: (DurationOpts & {
|
|
111
|
+
amplitude?: number;
|
|
112
|
+
})): Clip;
|
|
113
|
+
//#endregion
|
|
114
|
+
export { type ApplyOpts, type ApplyOpts as ClipApplyOpts, type ChannelOverride, type Clip, type ClipChannel, ClipError, type ClipListDelay, type ClipListOpts, type ClipResult, type ClipSpec, type ClipTarget, type DurationOpts, type SlideEdge, clip, clipList, driftLoop, popIn, pulse, slideIn };
|
package/dist/clips.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { i as resolveTweenTarget, p as track, s as key, x as inferValueType } from "./targetRef.js";
|
|
2
|
+
//#region src/clip.ts
|
|
3
|
+
/**
|
|
4
|
+
* Motion clips (DESIGN.md §2 build-time sugar): `clip()` captures a relative-time
|
|
5
|
+
* key schedule over named prop CHANNELS; `clip.apply()` compiles to ordinary keyed
|
|
6
|
+
* `Track[]` at apply-time — authoring SUGAR exactly like `springTo`/`stagger`, NOT a
|
|
7
|
+
* runtime concept and NOT part of the serialized Timeline document. Emitted tracks
|
|
8
|
+
* are BYTE-INDISTINGUISHABLE from hand-authored `track(...)`: every channel compiles
|
|
9
|
+
* through `track(target, type, keys)`, so `validateTrack` runs and the output is
|
|
10
|
+
* deep-equal to the literal form.
|
|
11
|
+
*
|
|
12
|
+
* The binding shape locked here (the channel-spec + apply signature) is FROZEN for
|
|
13
|
+
* the later 1.0 cards (presence / each / morph), which inherit it.
|
|
14
|
+
*/
|
|
15
|
+
var ClipError = class extends Error {
|
|
16
|
+
constructor(message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = "ClipError";
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
function channelDuration(ch) {
|
|
22
|
+
return ch.keys.length === 0 ? 0 : ch.keys[ch.keys.length - 1].t;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Resolve a channel's canonical track target. A string `target` resolves every
|
|
26
|
+
* channel against the single node id (`'<nodeId>/<channel.path>'`); a map looks
|
|
27
|
+
* the channel up and resolves the supplied `TweenTarget` directly (free rejection
|
|
28
|
+
* of structural / anonymous ids via `resolveTweenTarget`).
|
|
29
|
+
*/
|
|
30
|
+
function resolveChannelTarget(target, channel, path) {
|
|
31
|
+
if (typeof target === "string") return resolveTweenTarget(`${target}/${path}`);
|
|
32
|
+
const override = target[channel];
|
|
33
|
+
if (override === void 0) throw new ClipError(`clip target map is missing channel '${channel}' — provide a target for every channel, or pass a node-id string`);
|
|
34
|
+
return resolveTweenTarget(override);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Compile ONE channel into a hand-authored-equivalent `Track`. Applies the
|
|
38
|
+
* override (value/ease) topology-preservingly, then scales relative `t` by
|
|
39
|
+
* `1/speed` and offsets by `startSec`. Routes through `track()` so `validateTrack`
|
|
40
|
+
* runs (strictly-increasing t, hold-canonicalize) and the output is byte-identical
|
|
41
|
+
* to the literal `track(target, type, keys)` form.
|
|
42
|
+
*/
|
|
43
|
+
function compileChannel(target, ch, startSec, speed, override) {
|
|
44
|
+
if (ch.keys.length === 0) throw new ClipError(`clip channel '${target}' has no keys`);
|
|
45
|
+
const type = ch.type ?? inferValueType(ch.keys[0].value);
|
|
46
|
+
const lastIdx = ch.keys.length - 1;
|
|
47
|
+
return track(target, type, ch.keys.map((k, i) => {
|
|
48
|
+
let value = k.value;
|
|
49
|
+
if (override) {
|
|
50
|
+
if (i === 0 && override.from !== void 0) value = override.from;
|
|
51
|
+
if (i === lastIdx && override.to !== void 0) value = override.to;
|
|
52
|
+
}
|
|
53
|
+
const out = {
|
|
54
|
+
t: startSec + k.t / speed,
|
|
55
|
+
value
|
|
56
|
+
};
|
|
57
|
+
const ease = override && i === lastIdx && override.ease !== void 0 ? override.ease : k.ease;
|
|
58
|
+
if (ease !== void 0) out.ease = ease;
|
|
59
|
+
if (k.interp !== void 0) out.interp = k.interp;
|
|
60
|
+
if (k.id !== void 0) out.id = k.id;
|
|
61
|
+
if (k.derived !== void 0) out.derived = k.derived;
|
|
62
|
+
return out;
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Build a clip from a spec. Pure: holds no state; `apply` returns fresh tracks.
|
|
67
|
+
*
|
|
68
|
+
* const fade = clip({ channels: { fade: { keys: [key(0, 0), key(0.3, 1)], path: 'opacity' } } });
|
|
69
|
+
* const { tracks, end } = fade.apply('card', 1.0); // → 'card/opacity' track at t∈[1.0,1.3]
|
|
70
|
+
*/
|
|
71
|
+
function clip(spec) {
|
|
72
|
+
const names = Object.keys(spec.channels);
|
|
73
|
+
if (names.length === 0) throw new ClipError("clip() requires at least one channel");
|
|
74
|
+
let duration = 0;
|
|
75
|
+
for (const name of names) {
|
|
76
|
+
const ch = spec.channels[name];
|
|
77
|
+
if (ch.keys.length === 0) throw new ClipError(`clip channel '${name}' has no keys`);
|
|
78
|
+
duration = Math.max(duration, channelDuration(ch));
|
|
79
|
+
}
|
|
80
|
+
function apply(target, startSec, opts) {
|
|
81
|
+
const speed = opts?.speed ?? 1;
|
|
82
|
+
if (!(speed > 0)) throw new ClipError(`clip speed must be > 0 (got ${speed})`);
|
|
83
|
+
const overrides = opts?.overrides;
|
|
84
|
+
const tracks = [];
|
|
85
|
+
let end = startSec;
|
|
86
|
+
for (const name of names) {
|
|
87
|
+
const ch = spec.channels[name];
|
|
88
|
+
const trackTarget = resolveChannelTarget(target, name, ch.path);
|
|
89
|
+
tracks.push(compileChannel(trackTarget, ch, startSec, speed, overrides?.[name]));
|
|
90
|
+
end = Math.max(end, startSec + channelDuration(ch) / speed);
|
|
91
|
+
}
|
|
92
|
+
if (overrides) {
|
|
93
|
+
for (const name of Object.keys(overrides)) if (!(name in spec.channels)) throw new ClipError(`clip override references unknown channel '${name}'`);
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
tracks,
|
|
97
|
+
end
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
spec,
|
|
102
|
+
duration,
|
|
103
|
+
apply
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Fan a clip out over a list of targets, offsetting child i by a `stagger`-style
|
|
108
|
+
* delay (REUSES the existing `stagger` shape/semantics — number per-index gap, or
|
|
109
|
+
* a function of the index). `end` is the max child end.
|
|
110
|
+
*
|
|
111
|
+
* clipList(popIn(), items.map((it) => it.id), 0, { stagger: 0.08 })
|
|
112
|
+
*/
|
|
113
|
+
function clipList(c, targets, startSec, opts) {
|
|
114
|
+
const delay = opts?.stagger ?? 0;
|
|
115
|
+
const at = typeof delay === "function" ? delay : (i) => i * delay;
|
|
116
|
+
const { stagger: _stagger, ...applyOpts } = opts ?? {};
|
|
117
|
+
const tracks = [];
|
|
118
|
+
let end = startSec;
|
|
119
|
+
targets.forEach((target, i) => {
|
|
120
|
+
const r = c.apply(target, startSec + at(i), applyOpts);
|
|
121
|
+
tracks.push(...r.tracks);
|
|
122
|
+
if (r.end > end) end = r.end;
|
|
123
|
+
});
|
|
124
|
+
return {
|
|
125
|
+
tracks,
|
|
126
|
+
end
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
//#endregion
|
|
130
|
+
//#region src/clipStdlib.ts
|
|
131
|
+
/**
|
|
132
|
+
* Stdlib motion clips (DESIGN.md §2 build-time sugar): a small set of `clip(...)`
|
|
133
|
+
* literals for the common entrance / emphasis / ambient motions. Tree-shakeable —
|
|
134
|
+
* shipped from the `@glissade/core/clips` SUB-PATH, never the base index, because
|
|
135
|
+
* the keyframe literals are the byte weight (the base core budget is tight).
|
|
136
|
+
*
|
|
137
|
+
* Loop clips (`pulse`, `driftLoop`) author first-key-value == last-key-value, so
|
|
138
|
+
* tiling them under `clipList` (or repeating the clip) reads seamlessly.
|
|
139
|
+
*/
|
|
140
|
+
/** Entrance: opacity 0→1 and scale 0.8→1 (a "pop" in). */
|
|
141
|
+
function popIn(opts) {
|
|
142
|
+
const d = opts?.duration ?? .3;
|
|
143
|
+
const ease = opts?.ease ?? "easeOutCubic";
|
|
144
|
+
return clip({ channels: {
|
|
145
|
+
opacity: {
|
|
146
|
+
path: "opacity",
|
|
147
|
+
keys: [key(0, 0), key(d, 1, ease)]
|
|
148
|
+
},
|
|
149
|
+
scale: {
|
|
150
|
+
path: "scale",
|
|
151
|
+
keys: [key(0, .8), key(d, 1, ease)]
|
|
152
|
+
}
|
|
153
|
+
} });
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Entrance: fade in while sliding a position OFFSET in from one edge. The offset
|
|
157
|
+
* channel binds to a `position` suffix by default and animates a vec2 from the
|
|
158
|
+
* edge-displaced point to [0,0], so it composes as a local translation.
|
|
159
|
+
*/
|
|
160
|
+
function slideIn(edge, opts) {
|
|
161
|
+
const d = opts?.duration ?? .3;
|
|
162
|
+
const ease = opts?.ease ?? "easeOutCubic";
|
|
163
|
+
const dist = opts?.distance ?? 40;
|
|
164
|
+
const from = edge === "left" ? [-dist, 0] : edge === "right" ? [dist, 0] : edge === "top" ? [0, -dist] : [0, dist];
|
|
165
|
+
return clip({ channels: {
|
|
166
|
+
opacity: {
|
|
167
|
+
path: "opacity",
|
|
168
|
+
keys: [key(0, 0), key(d, 1, ease)]
|
|
169
|
+
},
|
|
170
|
+
offset: {
|
|
171
|
+
path: "position",
|
|
172
|
+
keys: [key(0, from), key(d, [0, 0], ease)]
|
|
173
|
+
}
|
|
174
|
+
} });
|
|
175
|
+
}
|
|
176
|
+
/** Emphasis: a single scale up-and-back pulse. First/last value match (loopable). */
|
|
177
|
+
function pulse(opts) {
|
|
178
|
+
const d = opts?.duration ?? .4;
|
|
179
|
+
const ease = opts?.ease ?? "easeInOutSine";
|
|
180
|
+
const peak = opts?.scale ?? 1.1;
|
|
181
|
+
return clip({ channels: { scale: {
|
|
182
|
+
path: "scale",
|
|
183
|
+
keys: [
|
|
184
|
+
key(0, 1),
|
|
185
|
+
key(d / 2, peak, ease),
|
|
186
|
+
key(d, 1, ease)
|
|
187
|
+
]
|
|
188
|
+
} } });
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Ambient: a slow position drift out and back. First/last value match, so tiling
|
|
192
|
+
* (clipList or repeat) reads as a continuous loop with no seam.
|
|
193
|
+
*/
|
|
194
|
+
function driftLoop(opts) {
|
|
195
|
+
const d = opts?.duration ?? 2;
|
|
196
|
+
const ease = opts?.ease ?? "easeInOutSine";
|
|
197
|
+
const a = opts?.amplitude ?? 8;
|
|
198
|
+
return clip({ channels: { drift: {
|
|
199
|
+
path: "position",
|
|
200
|
+
keys: [
|
|
201
|
+
key(0, [0, 0]),
|
|
202
|
+
key(d / 2, [a, 0], ease),
|
|
203
|
+
key(d, [0, 0], ease)
|
|
204
|
+
]
|
|
205
|
+
} } });
|
|
206
|
+
}
|
|
207
|
+
//#endregion
|
|
208
|
+
export { ClipError, clip, clipList, driftLoop, popIn, pulse, slideIn };
|
package/dist/cmap.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
//#region src/cmap.ts
|
|
2
|
+
/**
|
|
3
|
+
* Minimal sfnt `cmap` reader (DESIGN.md §3.6 glyph-coverage check) — pure,
|
|
4
|
+
* byte-only, ZERO DOM / ZERO Node. Given the raw bytes of a TrueType/OpenType
|
|
5
|
+
* (or the first table of a TTC) font, returns the set of Unicode code points
|
|
6
|
+
* the font claims to cover. I/O (reading the file / fetching the URL) happens
|
|
7
|
+
* at the call site; this function only parses bytes.
|
|
8
|
+
*
|
|
9
|
+
* Hand-rolled (no font-parsing dependency) to keep core zero-dep and in budget.
|
|
10
|
+
* It covers the two cmap subtable formats that matter in practice:
|
|
11
|
+
* - format 4 (segment mapping to delta values — the BMP workhorse)
|
|
12
|
+
* - format 12 (segmented coverage — astral planes, e.g. emoji)
|
|
13
|
+
* Malformed or unsupported input yields an empty set; it never throws or hangs.
|
|
14
|
+
*/
|
|
15
|
+
const PLATFORM_UNICODE = 0;
|
|
16
|
+
const PLATFORM_WINDOWS = 3;
|
|
17
|
+
/** Read a big-endian u16 with bounds checking (0 when out of range). */
|
|
18
|
+
function u16(dv, off) {
|
|
19
|
+
return off + 2 <= dv.byteLength ? dv.getUint16(off) : 0;
|
|
20
|
+
}
|
|
21
|
+
function u32(dv, off) {
|
|
22
|
+
return off + 4 <= dv.byteLength ? dv.getUint32(off) : 0;
|
|
23
|
+
}
|
|
24
|
+
/** Rank a (platform, encoding) cmap subtable: higher = better Unicode coverage. */
|
|
25
|
+
function encodingScore(platform, encoding) {
|
|
26
|
+
if (platform === PLATFORM_WINDOWS && encoding === 10) return 5;
|
|
27
|
+
if (platform === PLATFORM_UNICODE && encoding >= 4) return 5;
|
|
28
|
+
if (platform === PLATFORM_WINDOWS && encoding === 1) return 3;
|
|
29
|
+
if (platform === PLATFORM_UNICODE) return 3;
|
|
30
|
+
return 0;
|
|
31
|
+
}
|
|
32
|
+
function parseFormat4(dv, base, into) {
|
|
33
|
+
const segCountX2 = u16(dv, base + 6);
|
|
34
|
+
const segCount = segCountX2 >> 1;
|
|
35
|
+
const endOff = base + 14;
|
|
36
|
+
const startOff = endOff + segCountX2 + 2;
|
|
37
|
+
const deltaOff = startOff + segCountX2;
|
|
38
|
+
const rangeOff = deltaOff + segCountX2;
|
|
39
|
+
for (let i = 0; i < segCount; i++) {
|
|
40
|
+
const end = u16(dv, endOff + i * 2);
|
|
41
|
+
const start = u16(dv, startOff + i * 2);
|
|
42
|
+
if (start > end) continue;
|
|
43
|
+
const idDelta = u16(dv, deltaOff + i * 2);
|
|
44
|
+
const idRangeOffset = u16(dv, rangeOff + i * 2);
|
|
45
|
+
for (let c = start; c <= end; c++) {
|
|
46
|
+
if (c === 65535) continue;
|
|
47
|
+
let glyph;
|
|
48
|
+
if (idRangeOffset === 0) glyph = c + idDelta & 65535;
|
|
49
|
+
else {
|
|
50
|
+
const g = u16(dv, rangeOff + i * 2 + idRangeOffset + (c - start) * 2);
|
|
51
|
+
glyph = g === 0 ? 0 : g + idDelta & 65535;
|
|
52
|
+
}
|
|
53
|
+
if (glyph !== 0) into.add(c);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function parseFormat12(dv, base, into) {
|
|
58
|
+
const declared = u32(dv, base + 12);
|
|
59
|
+
const maxGroups = Math.max(0, Math.floor((dv.byteLength - (base + 16)) / 12));
|
|
60
|
+
const nGroups = Math.min(declared, maxGroups);
|
|
61
|
+
let off = base + 16;
|
|
62
|
+
for (let i = 0; i < nGroups; i++, off += 12) {
|
|
63
|
+
const startChar = u32(dv, off);
|
|
64
|
+
const endChar = u32(dv, off + 4);
|
|
65
|
+
const startGlyph = u32(dv, off + 8);
|
|
66
|
+
if (startChar > endChar) continue;
|
|
67
|
+
if (endChar - startChar > 2097152) continue;
|
|
68
|
+
for (let c = startChar; c <= endChar; c++) if (startGlyph + (c - startChar) !== 0) into.add(c);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function parseCmap(bytes) {
|
|
72
|
+
const out = /* @__PURE__ */ new Set();
|
|
73
|
+
try {
|
|
74
|
+
const dv = ArrayBuffer.isView(bytes) ? new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) : new DataView(bytes);
|
|
75
|
+
if (dv.byteLength < 12) return out;
|
|
76
|
+
let sfntOff = 0;
|
|
77
|
+
if (u32(dv, 0) === 1953784678) sfntOff = u32(dv, 12);
|
|
78
|
+
const numTables = u16(dv, sfntOff + 4);
|
|
79
|
+
let cmapOff = 0;
|
|
80
|
+
for (let i = 0; i < numTables; i++) {
|
|
81
|
+
const rec = sfntOff + 12 + i * 16;
|
|
82
|
+
if (u32(dv, rec) === 1668112752) {
|
|
83
|
+
cmapOff = u32(dv, rec + 8);
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (cmapOff === 0) return out;
|
|
88
|
+
const nSub = u16(dv, cmapOff + 2);
|
|
89
|
+
let bestOff = 0;
|
|
90
|
+
let bestScore = -1;
|
|
91
|
+
for (let i = 0; i < nSub; i++) {
|
|
92
|
+
const rec = cmapOff + 4 + i * 8;
|
|
93
|
+
const platform = u16(dv, rec);
|
|
94
|
+
const encoding = u16(dv, rec + 2);
|
|
95
|
+
const subOff = cmapOff + u32(dv, rec + 4);
|
|
96
|
+
const score = encodingScore(platform, encoding);
|
|
97
|
+
if (score > bestScore && subOff + 2 <= dv.byteLength) {
|
|
98
|
+
bestScore = score;
|
|
99
|
+
bestOff = subOff;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (bestScore < 0) return out;
|
|
103
|
+
const format = u16(dv, bestOff);
|
|
104
|
+
if (format === 4) parseFormat4(dv, bestOff, out);
|
|
105
|
+
else if (format === 12) parseFormat12(dv, bestOff, out);
|
|
106
|
+
} catch {
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
//#endregion
|
|
112
|
+
export { parseCmap as t };
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { a as FontFaceRef } from "./timeline.js";
|
|
2
|
+
|
|
3
|
+
//#region src/fontIngest.d.ts
|
|
4
|
+
|
|
5
|
+
/** The four sfnt/woff magic-byte families this front door understands. */
|
|
6
|
+
type FontFormat = 'truetype' | 'opentype' | 'collection' | 'woff' | 'woff2';
|
|
7
|
+
/** Raw bytes in any form the ingest accepts; a path is read by the caller's I/O. */
|
|
8
|
+
type FontSource = Uint8Array | ArrayBuffer;
|
|
9
|
+
/**
|
|
10
|
+
* A fixed variable-axis tuple to INSTANCE at (e.g. `{ wght: 600 }` or
|
|
11
|
+
* `{ wght: 600, wdth: 100 }`). Each axis is pinned to a single value, producing
|
|
12
|
+
* a static face — the parity-safe case (DESIGN §3.6). Animatable axes are
|
|
13
|
+
* deferred; an axis RANGE is intentionally not accepted here.
|
|
14
|
+
*/
|
|
15
|
+
type AxisTuple = Record<string, number>;
|
|
16
|
+
interface RegisterFontInit {
|
|
17
|
+
/** The CSS family name to register under (the §3.6 asset-id convention). */
|
|
18
|
+
family: string;
|
|
19
|
+
/** Raw font bytes (a path is read to bytes by the caller before this point). */
|
|
20
|
+
src: FontSource;
|
|
21
|
+
weight?: number | undefined;
|
|
22
|
+
style?: 'normal' | 'italic' | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* Pin these variable axes to fixed values, instancing the variable font to a
|
|
25
|
+
* single static sfnt at ingest time. Omitted → the face is used as-is.
|
|
26
|
+
*/
|
|
27
|
+
axes?: AxisTuple | undefined;
|
|
28
|
+
/** Explicit fallback family chain (mirrors AssetRef.fallback). */
|
|
29
|
+
fallback?: readonly string[] | undefined;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The result of ingesting one face: the decoded/instanced STATIC sfnt bytes,
|
|
33
|
+
* a content hash (stable identity for caching + the asset url), the parsed
|
|
34
|
+
* coverage, and a `covers()` predicate. `toFaceRef()` / `toAssetRef()` bridge
|
|
35
|
+
* back into the serializable Timeline document the render path consumes.
|
|
36
|
+
*/
|
|
37
|
+
interface FontFaceResult {
|
|
38
|
+
family: string;
|
|
39
|
+
weight: number;
|
|
40
|
+
style: 'normal' | 'italic';
|
|
41
|
+
/** The decoded + (optionally) instanced static sfnt bytes (ttf/otf). */
|
|
42
|
+
bytes: Uint8Array;
|
|
43
|
+
/** Detected source format before any decode/instance. */
|
|
44
|
+
sourceFormat: FontFormat;
|
|
45
|
+
/** FNV-1a content hash of `bytes` (hex), stable across runs for equal input. */
|
|
46
|
+
hash: string;
|
|
47
|
+
/** The code points the instanced face's cmap covers. */
|
|
48
|
+
coverage: ReadonlySet<number>;
|
|
49
|
+
fallback: readonly string[];
|
|
50
|
+
/** True iff every code point in `text` is covered by this face. */
|
|
51
|
+
covers(text: string): boolean;
|
|
52
|
+
/** Code points in `text` this face does NOT cover (ascending, de-duped). */
|
|
53
|
+
missing(text: string): number[];
|
|
54
|
+
}
|
|
55
|
+
/** Sniff the leading 4 bytes; throws on input that is not a recognized font. */
|
|
56
|
+
declare function sniffFontFormat(src: FontSource): FontFormat;
|
|
57
|
+
declare class FontIngestError extends Error {
|
|
58
|
+
constructor(message: string);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Ingest one face: sniff → (woff2 decode) → (instance at `axes`) → a static
|
|
62
|
+
* sfnt + content hash + coverage. The heavy path (woff2 / instancing) runs
|
|
63
|
+
* hb-subset; a plain ttf/otf with no axes skips it entirely (zero-dep fast
|
|
64
|
+
* path), keeping the common case off the wasm boundary.
|
|
65
|
+
*/
|
|
66
|
+
declare function ingestFont(init: RegisterFontInit): Promise<FontFaceResult>;
|
|
67
|
+
/** A registered face plus the bytes the render path will hand to the rasterizer. */
|
|
68
|
+
interface IngestedFace extends FontFaceResult {
|
|
69
|
+
/** The `FontFaceRef` this face contributes to a Timeline AssetRef. */
|
|
70
|
+
faceRef(url: string): FontFaceRef;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* `registerFont(...)` — the merged front door. Ingests `init` and records it in
|
|
74
|
+
* `store` (the in-process registry the render path consumes). Returns the
|
|
75
|
+
* ingested face (bytes + coverage + `covers()`); the caller decides whether to
|
|
76
|
+
* persist the static sfnt to disk (content-hashed url) and/or feed Skia.
|
|
77
|
+
*/
|
|
78
|
+
declare function registerFont(init: RegisterFontInit, store?: FontStore): Promise<IngestedFace>;
|
|
79
|
+
/**
|
|
80
|
+
* A tiny in-process store the export/prepare path fills via `registerFont` and
|
|
81
|
+
* the renderer drains. Groups faces by family and remembers each family's
|
|
82
|
+
* declared fallback chain — the same shape `buildFontRegistry` consumes, so the
|
|
83
|
+
* programmatic and document-driven paths converge on one registry.
|
|
84
|
+
*/
|
|
85
|
+
declare class FontStore {
|
|
86
|
+
private readonly byFamily;
|
|
87
|
+
private readonly fallbacks;
|
|
88
|
+
add(face: IngestedFace): void;
|
|
89
|
+
families(): string[];
|
|
90
|
+
facesOf(family: string): readonly IngestedFace[];
|
|
91
|
+
fallbackOf(family: string): readonly string[];
|
|
92
|
+
/** Every ingested face across every family (the renderer registers these). */
|
|
93
|
+
all(): IngestedFace[];
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* One source entry in a `font()` plan: a file path (or raw bytes) plus the
|
|
97
|
+
* (weight, style) it represents. Paths are resolved to bytes by the ingest
|
|
98
|
+
* caller (`buildFontPlan`'s `read`), keeping this module's pure spec free of I/O.
|
|
99
|
+
*/
|
|
100
|
+
interface FontSrcEntry {
|
|
101
|
+
path?: string | undefined;
|
|
102
|
+
bytes?: FontSource | undefined;
|
|
103
|
+
weight?: number | undefined;
|
|
104
|
+
style?: 'normal' | 'italic' | undefined;
|
|
105
|
+
}
|
|
106
|
+
/** The declarative plan a `font(...).build()` produces — pure data, no I/O. */
|
|
107
|
+
interface FontPlan {
|
|
108
|
+
family: string;
|
|
109
|
+
sources: FontSrcEntry[];
|
|
110
|
+
fallback: string[];
|
|
111
|
+
/** When set, every source is instanced at this fixed axis tuple. */
|
|
112
|
+
axes?: AxisTuple | undefined;
|
|
113
|
+
/** Marks the plan as a variable font (axes are expected / required). */
|
|
114
|
+
variable: boolean;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Fluent author-time builder for a font family (DESIGN §3.6). PURE: it only
|
|
118
|
+
* assembles a `FontPlan`; no bytes are read and no wasm is touched until the
|
|
119
|
+
* plan is ingested. Two shapes:
|
|
120
|
+
*
|
|
121
|
+
* font('Inter').src('Inter.ttf').weights([400, 600, 700]).fallback(['Arial']).build()
|
|
122
|
+
* font('InterVar').src('InterVar.woff2').variable().axis('wght', 600).build()
|
|
123
|
+
*
|
|
124
|
+
* `.weights([...])` is sugar for "the same src at each of these weights" only
|
|
125
|
+
* when a single src is given; with explicit per-weight `.src(path, weight)`
|
|
126
|
+
* calls it is ignored in favor of the explicit entries.
|
|
127
|
+
*/
|
|
128
|
+
interface FontBuilder {
|
|
129
|
+
src(path: string, opts?: {
|
|
130
|
+
weight?: number;
|
|
131
|
+
style?: 'normal' | 'italic';
|
|
132
|
+
}): FontBuilder;
|
|
133
|
+
bytes(data: FontSource, opts?: {
|
|
134
|
+
weight?: number;
|
|
135
|
+
style?: 'normal' | 'italic';
|
|
136
|
+
}): FontBuilder;
|
|
137
|
+
weights(weights: readonly number[]): FontBuilder;
|
|
138
|
+
fallback(families: readonly string[]): FontBuilder;
|
|
139
|
+
/** Mark this family as variable (axes are pinned via `.axis()`). */
|
|
140
|
+
variable(): FontBuilder;
|
|
141
|
+
/** Pin a variable axis to a fixed value (static instancing). */
|
|
142
|
+
axis(tag: string, value: number): FontBuilder;
|
|
143
|
+
build(): FontPlan;
|
|
144
|
+
}
|
|
145
|
+
declare function font(family: string): FontBuilder;
|
|
146
|
+
/**
|
|
147
|
+
* Ingest a whole `FontPlan` into `store`. `read` resolves a source path to bytes
|
|
148
|
+
* (the I/O seam — the CLI reads files, a bundler plugin could fetch). Every
|
|
149
|
+
* source becomes one ingested face under the plan's family; the family fallback
|
|
150
|
+
* is attached to the first face so the store records it once.
|
|
151
|
+
*/
|
|
152
|
+
declare function buildFontPlan(plan: FontPlan, read: (path: string) => Promise<FontSource>, store?: FontStore): Promise<IngestedFace[]>;
|
|
153
|
+
//#endregion
|
|
154
|
+
export { type AxisTuple, type FontBuilder, type FontFaceResult, type FontFormat, FontIngestError, type FontPlan, type FontSource, type FontSrcEntry, FontStore, type IngestedFace, type RegisterFontInit, buildFontPlan, font, ingestFont, registerFont, sniffFontFormat };
|