@cube-drone/marquee-html-renderer 0.9.0 → 0.9.2

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/src/effects.ts ADDED
@@ -0,0 +1,316 @@
1
+ // The effect vocabulary's look, as data: which classes, custom properties
2
+ // and data attributes an effect span's container wears, and - for by=letter
3
+ // / by=word - exactly which runs of which text nodes are animated units and
4
+ // with what phase offset. The HTML renderer builds its markup from this, and
5
+ // any other surface that draws effects (the live editor's in-place marks)
6
+ // takes the same answer, so the two can't disagree about segmentation,
7
+ // phase order, offsets, or caps.
8
+
9
+ import type { Attrs, Node } from "@cube-drone/marquee-parser";
10
+
11
+ const TOKEN = /^[a-z][a-z0-9-]{0,31}$/;
12
+ const COUNT = /^[0-9]{1,4}$/;
13
+
14
+ /** The language-defined animated effects (SPEC.md, "Text effects"). */
15
+ export const EFFECT_NAMES: ReadonlySet<string> = new Set([
16
+ "marquee", "blink", "rainbow", "bounce", "jitter", "wave", "rubber", "typewriter", "fadein",
17
+ ]);
18
+
19
+ /** One animated unit: `[start, end)` in its text node's `value` (UTF-16
20
+ * offsets), and the `--mq-o` phase offset it wears. */
21
+ export interface EffectUnit {
22
+ start: number;
23
+ end: number;
24
+ offset: string;
25
+ }
26
+
27
+ export interface EffectLook {
28
+ /** The container's classes: `mq-<name>`, plus `mq-split` when split. */
29
+ className: string;
30
+ /** Custom properties on the container (`--mq-speed`, `--mq-rate`, a
31
+ * reveal's step), in emission order. */
32
+ vars: [string, string][];
33
+ /** Data attributes on the container (a marquee's direction). */
34
+ data: [string, string][];
35
+ /** Per-unit animation, or null when the run animates as one piece (no
36
+ * `by=`, no units, or past the unit cap). Keyed by text node identity. */
37
+ units: Map<Node, EffectUnit[]> | null;
38
+ /** With units: the word groups - `[start, end)` value ranges, each a run
39
+ * of text between spaces that holds units - which must wrap as one. Units
40
+ * are inline-blocks (transforms need them to be), and a line may break on
41
+ * either side of every inline-block; a `white-space: nowrap` group puts
42
+ * the break opportunities back where the spaces are. */
43
+ groups: Map<Node, [number, number][]> | null;
44
+ }
45
+
46
+ /** How an effect span looks, or null if `name` isn't an effect. */
47
+ export function effectLook(name: string, attrs: Attrs, children: Node[]): EffectLook | null {
48
+ if (!EFFECT_NAMES.has(name)) {
49
+ return null;
50
+ }
51
+ const look: EffectLook = { className: `mq-${name}`, vars: [], data: [], units: null, groups: null };
52
+ const by = attrs["by"] === "letter" || attrs["by"] === "word" ? attrs["by"] : null;
53
+ let splitBy: SplitBy | null = null;
54
+ switch (name) {
55
+ case "marquee":
56
+ if (TOKEN.test(attrs["direction"] ?? "")) {
57
+ look.data.push(["direction", attrs["direction"]!]);
58
+ }
59
+ if (COUNT.test(attrs["speed"] ?? "")) {
60
+ look.vars.push(["--mq-speed", attrs["speed"]!]);
61
+ }
62
+ return look;
63
+ case "blink":
64
+ // Split blink: ramp is theater-marquee chase lights, scatter is
65
+ // twinkle. The rate rides the container; units inherit it.
66
+ if (COUNT.test(attrs["rate"] ?? "")) {
67
+ look.vars.push(["--mq-rate", attrs["rate"]!]);
68
+ }
69
+ splitBy = by;
70
+ break;
71
+ case "typewriter":
72
+ // Inherently per-unit: the reveal IS a by=letter effect (by=word for
73
+ // word-at-a-time). speed= is units per second; the container carries
74
+ // the per-unit delay step, each unit its ordinal in --mq-o.
75
+ splitBy = by === "word" ? "word" : "letter";
76
+ look.vars.push(["--mq-tw-step", `${revealStep(attrs["speed"], 14)}s`]);
77
+ break;
78
+ case "fadein":
79
+ // Bare [fadein] fades the whole run in once; by= drifts units in on
80
+ // staggered starts (the same one-shot family as typewriter).
81
+ if (by !== null) {
82
+ splitBy = by;
83
+ look.vars.push(["--mq-fi-step", `${revealStep(attrs["speed"], 16)}s`]);
84
+ }
85
+ break;
86
+ default: // rainbow, bounce, jitter, wave, rubber
87
+ splitBy = by;
88
+ }
89
+ if (splitBy === null) {
90
+ return look;
91
+ }
92
+ const units = splitUnits(name, splitBy, attrs["phase"], children);
93
+ if (units === null) {
94
+ // Past the cap (or nothing to split): the run animates whole, and the
95
+ // split's container knobs go with the split.
96
+ look.vars = [];
97
+ return look;
98
+ }
99
+ look.className += " mq-split";
100
+ look.units = units;
101
+ look.groups = wordGroups(units);
102
+ return look;
103
+ }
104
+
105
+ /** A text node, laid out for a split render: plain text that rides along,
106
+ * animated units, and word groups holding units (and the punctuation that
107
+ * clings to them) that must not wrap apart. */
108
+ export type SplitPiece =
109
+ | { kind: "text"; text: string }
110
+ | { kind: "unit"; text: string; offset: string }
111
+ | { kind: "group"; pieces: SplitPiece[] };
112
+
113
+ export function splitPieces(node: Node & { type: "text" }, look: EffectLook): SplitPiece[] {
114
+ const units = look.units?.get(node) ?? [];
115
+ const groups = look.groups?.get(node) ?? [];
116
+ const lay = (from: number, to: number): SplitPiece[] => {
117
+ const out: SplitPiece[] = [];
118
+ let at = from;
119
+ for (const u of units) {
120
+ if (u.start < from || u.end > to) continue;
121
+ if (u.start > at) out.push({ kind: "text", text: node.value.slice(at, u.start) });
122
+ out.push({ kind: "unit", text: node.value.slice(u.start, u.end), offset: u.offset });
123
+ at = u.end;
124
+ }
125
+ if (to > at) out.push({ kind: "text", text: node.value.slice(at, to) });
126
+ return out;
127
+ };
128
+ const out: SplitPiece[] = [];
129
+ let at = 0;
130
+ for (const [start, end] of groups) {
131
+ out.push(...lay(at, start), { kind: "group", pieces: lay(start, end) });
132
+ at = end;
133
+ }
134
+ out.push(...lay(at, node.value.length));
135
+ return out;
136
+ }
137
+
138
+ /** Scripts written without spaces between words wrap between characters,
139
+ * so their runs are never grouped (a whole sentence would become one
140
+ * unbreakable word): Han, kana, fullwidth forms, Thai, Lao, Khmer, Myanmar. */
141
+ const SPACELESS = /[\u0E00-\u0EFF\u1000-\u109F\u1780-\u17FF\u2E80-\u9FFF\uF900-\uFAFF\uFF00-\uFFEF\u{20000}-\u{3FFFF}]/u;
142
+
143
+ function wordGroups(units: Map<Node, EffectUnit[]>): Map<Node, [number, number][]> {
144
+ const out = new Map<Node, [number, number][]>();
145
+ for (const [node, list] of units) {
146
+ if (node.type !== "text") continue;
147
+ const groups: [number, number][] = [];
148
+ for (const m of node.value.matchAll(/\S+/g)) {
149
+ const start = m.index;
150
+ const end = start + m[0].length;
151
+ const inside = list.filter((u) => u.start >= start && u.end <= end);
152
+ // Nothing to hold together: no units, a spaceless script, or a run
153
+ // that is exactly one unit (already a single inline-block).
154
+ if (inside.length === 0 || SPACELESS.test(m[0])) continue;
155
+ if (inside.length === 1 && inside[0]!.start === start && inside[0]!.end === end) continue;
156
+ groups.push([start, end]);
157
+ }
158
+ out.set(node, groups);
159
+ }
160
+ return out;
161
+ }
162
+
163
+ // -- per-unit effects (by=letter / by=word): each unit carries a phase
164
+ // offset in --mq-o; the stylesheet replays the effect's keyframes through a
165
+ // negative animation-delay. Locale pinned so segmentation (and thus output)
166
+ // is stable.
167
+
168
+ const SEGMENTERS = {
169
+ letter: new Intl.Segmenter("en", { granularity: "grapheme" }),
170
+ word: new Intl.Segmenter("en", { granularity: "word" }),
171
+ };
172
+
173
+ type SplitBy = keyof typeof SEGMENTERS;
174
+
175
+ /** DOM-weight discipline: past this many units, the run animates whole.
176
+ * Loopers pay a live animation per element forever, so they cap low;
177
+ * typewriter's units are 1ms one-shots (finished animations cost nothing)
178
+ * and its natural material is long text, so it caps high. */
179
+ const MAX_SPLIT_UNITS = 400;
180
+ const MAX_REVEAL_UNITS = 2000;
181
+
182
+ type Phase = "ramp" | "scatter";
183
+
184
+ interface SplitState {
185
+ effect: string;
186
+ by: SplitBy;
187
+ phase: Phase;
188
+ i: number;
189
+ total: number;
190
+ }
191
+
192
+ function splitUnits(
193
+ effect: string,
194
+ by: SplitBy,
195
+ phaseAttr: string | undefined,
196
+ children: Node[],
197
+ ): Map<Node, EffectUnit[]> | null {
198
+ // Each effect has a natural phase order (jitter scatters, the rest sweep);
199
+ // the knob overrides it either way. Invalid values degrade to the default.
200
+ const phase: Phase =
201
+ phaseAttr === "scatter" || phaseAttr === "ramp" ? phaseAttr : effect === "jitter" ? "scatter" : "ramp";
202
+ const total = countUnits(children, by);
203
+ const cap = isReveal(effect) ? MAX_REVEAL_UNITS : MAX_SPLIT_UNITS;
204
+ if (total === 0 || total > cap) {
205
+ return null;
206
+ }
207
+ const state: SplitState = { effect, by, phase, i: 0, total };
208
+ const units = new Map<Node, EffectUnit[]>();
209
+ collectUnits(children, state, units);
210
+ return units;
211
+ }
212
+
213
+ /** The nodes a split descends into: text, through emphasis/strong/strike.
214
+ * Anything else (a link, an emoji, a nested span) renders whole, un-split. */
215
+ function collectUnits(nodes: Node[], state: SplitState, out: Map<Node, EffectUnit[]>): void {
216
+ for (const node of nodes) {
217
+ if (node.type === "text") {
218
+ const list: EffectUnit[] = [];
219
+ for (const seg of SEGMENTERS[state.by].segment(node.value)) {
220
+ if (isUnit(seg, state.by)) {
221
+ list.push({ start: seg.index, end: seg.index + seg.segment.length, offset: unitOffset(state) });
222
+ state.i += 1;
223
+ }
224
+ }
225
+ out.set(node, list);
226
+ } else if (node.type === "emphasis" || node.type === "strong" || node.type === "strikethrough") {
227
+ collectUnits(node.children, state, out);
228
+ }
229
+ }
230
+ }
231
+
232
+ function countUnits(nodes: Node[], by: SplitBy): number {
233
+ let n = 0;
234
+ for (const node of nodes) {
235
+ if (node.type === "text") {
236
+ for (const seg of SEGMENTERS[by].segment(node.value)) {
237
+ if (isUnit(seg, by)) {
238
+ n += 1;
239
+ }
240
+ }
241
+ } else if (node.type === "emphasis" || node.type === "strong" || node.type === "strikethrough") {
242
+ n += countUnits(node.children, by);
243
+ }
244
+ }
245
+ return n;
246
+ }
247
+
248
+ /** A segment gets wrapped if it's animatable: for words, word-like segments
249
+ * (spaces and bare punctuation ride along); for letters, anything that
250
+ * isn't whitespace. */
251
+ function isUnit(seg: Intl.SegmentData, by: SplitBy): boolean {
252
+ return by === "word" ? seg.isWordLike === true : !/^\s+$/.test(seg.segment);
253
+ }
254
+
255
+ /** Offsets are deterministic (goldens exist; a document renders the same
256
+ * twice) in both phase orders: ramp sweeps, scatter scrambles by a fixed
257
+ * integer hash - randomness-shaped, never random. */
258
+ function unitOffset(state: SplitState): string {
259
+ // Reveal offsets (typewriter, fadein) are sequential INTEGERS (the
260
+ // ordinal; delay = ordinal x step), unlike the cyclic 0..1 fractions the
261
+ // looping effects replay. phase=scatter reveals in a
262
+ // scrambled-but-deterministic order: a stride at the run's golden-ratio
263
+ // point, nudged coprime with the total, walks a well-spread permutation
264
+ // at EVERY length. (A fixed prime stride is a trap: 7919 mod 40 = 39 =
265
+ // -1, so forty-unit runs typed in backwards.)
266
+ if (isReveal(state.effect)) {
267
+ const o = state.phase === "scatter" ? (state.i * scatterStride(state.total)) % state.total : state.i;
268
+ return String(o);
269
+ }
270
+ let o: number;
271
+ if (state.phase === "scatter") {
272
+ o = ((state.i * 7919) % 101) / 101;
273
+ } else {
274
+ switch (state.effect) {
275
+ case "rainbow":
276
+ o = state.i / state.total; // gradient across the whole run
277
+ break;
278
+ case "wave":
279
+ o = (state.i % 8) / 8; // fixed ripple wavelength
280
+ break;
281
+ case "bounce":
282
+ o = (state.i % 6) / 6;
283
+ break;
284
+ default:
285
+ o = (state.i % 8) / 8; // jitter in ramp mode: a rippling shudder
286
+ break;
287
+ }
288
+ }
289
+ return String(Math.round(o * 1000) / 1000);
290
+ }
291
+
292
+ /** speed= (units per second, a COUNT) into a per-unit delay step in
293
+ * seconds; invalid or absent falls to the effect's default rate. */
294
+ function revealStep(speedAttr: string | undefined, dflt: number): number {
295
+ const speed = speedAttr !== undefined && COUNT.test(speedAttr) && Number(speedAttr) > 0 ? Number(speedAttr) : dflt;
296
+ return Math.round(1000 / speed) / 1000;
297
+ }
298
+
299
+ /** The one-shot reveals: sequential ordinals, the high unit cap. */
300
+ function isReveal(effect: string): boolean {
301
+ return effect === "typewriter" || effect === "fadein";
302
+ }
303
+
304
+ function gcd(a: number, b: number): number {
305
+ return b === 0 ? a : gcd(b, a % b);
306
+ }
307
+
308
+ /** The smallest stride >= ~61.8% of total that's coprime with it (falls
309
+ * back to 1 for degenerate totals - a 1- or 2-unit "scramble" is fate). */
310
+ function scatterStride(total: number): number {
311
+ let stride = Math.max(1, Math.round(total * 0.618));
312
+ while (stride < total && gcd(stride, total) !== 1) {
313
+ stride += 1;
314
+ }
315
+ return gcd(stride, total) === 1 ? stride : 1;
316
+ }
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@ import type { Profile } from "./profile.ts";
11
11
 
12
12
  export { render, escapeText, escapeAttr, FONTS, usedFontTokens, containerLook } from "./render.ts";
13
13
  export { bareWebProfile } from "./profile.ts";
14
+ export { EFFECT_NAMES, effectLook, splitPieces, type EffectLook, type EffectUnit, type SplitPiece } from "./effects.ts";
14
15
  export type { EmojiResolution, Profile, MediaResolution, TurbolinkLevel } from "./profile.ts";
15
16
 
16
17
  /** Parse and render in one step. Throws UnsupportedVersionError for unknown