@px-petals/roku.motion-web 0.0.0-stage → 1.1.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.
Files changed (99) hide show
  1. package/LICENSE +201 -0
  2. package/audio/audio.cues.js +27 -0
  3. package/audio/audio.js +259 -0
  4. package/bands/bands.js +392 -0
  5. package/bundle/bundle.fonts.js +55 -0
  6. package/bundle/bundle.js +131 -0
  7. package/focus/focus.direction.js +46 -0
  8. package/focus/focus.dom.js +158 -0
  9. package/focus/focus.js +277 -0
  10. package/focus/focus.tree.js +146 -0
  11. package/hit/hit.geometry.js +113 -0
  12. package/hit/hit.js +129 -0
  13. package/list/list.flex.js +181 -0
  14. package/list/list.items.js +80 -0
  15. package/list/list.js +518 -0
  16. package/lottie/lottie.js +124 -0
  17. package/lottie/lottie.layers.js +416 -0
  18. package/lottie/lottie.paint.js +238 -0
  19. package/lottie/lottie.paths.js +474 -0
  20. package/lottie/lottie.props.js +265 -0
  21. package/lottie/lottie.shapes.js +259 -0
  22. package/mixer/mixer.js +307 -0
  23. package/mixer/mixer.mix.js +107 -0
  24. package/mixer/mixer.stage.js +431 -0
  25. package/motion/motion.convert.js +404 -0
  26. package/motion/motion.flow.js +421 -0
  27. package/motion/motion.keys.js +150 -0
  28. package/motion/motion.machine.js +382 -0
  29. package/motion/motion.nested.js +345 -0
  30. package/motion/motion.values.js +368 -0
  31. package/motion.js +1155 -0
  32. package/package.json +47 -4
  33. package/player/overlay/overlay.js +325 -0
  34. package/player/overlay/overlay.layout.js +208 -0
  35. package/player/player.fit.js +101 -0
  36. package/player/player.js +421 -0
  37. package/player/player.raster.js +127 -0
  38. package/pointer/pointer.js +292 -0
  39. package/readme.md +345 -0
  40. package/script/luau/lib/base/base.js +202 -0
  41. package/script/luau/lib/base/base.table.js +189 -0
  42. package/script/luau/lib/lib.args.js +152 -0
  43. package/script/luau/lib/lib.js +23 -0
  44. package/script/luau/lib/math/math.bit32.js +144 -0
  45. package/script/luau/lib/math/math.js +288 -0
  46. package/script/luau/lib/math/math.vector.js +125 -0
  47. package/script/luau/lib/string/string.format.js +143 -0
  48. package/script/luau/lib/string/string.js +160 -0
  49. package/script/luau/lib/string/string.pattern.js +505 -0
  50. package/script/luau/load/load.js +362 -0
  51. package/script/luau/load/load.ops.js +79 -0
  52. package/script/luau/luau.convert.js +259 -0
  53. package/script/luau/luau.js +132 -0
  54. package/script/luau/luau.md +111 -0
  55. package/script/luau/vm/vm.js +167 -0
  56. package/script/luau/vm/vm.meta.js +401 -0
  57. package/script/luau/vm/vm.num.js +236 -0
  58. package/script/luau/vm/vm.run.js +842 -0
  59. package/script/luau/vm/vm.value.js +406 -0
  60. package/script/play/play.js +199 -0
  61. package/script/play/play.objects.js +295 -0
  62. package/script/play/play.scope.js +91 -0
  63. package/script/rive/api/api.statics.js +31 -0
  64. package/script/rive/api/api.type.js +188 -0
  65. package/script/rive/api/artboard/artboard.js +89 -0
  66. package/script/rive/api/assets/assets.js +72 -0
  67. package/script/rive/api/data/data.value.js +99 -0
  68. package/script/rive/api/draw/draw.contours.js +163 -0
  69. package/script/rive/api/draw/draw.measure.js +196 -0
  70. package/script/rive/api/draw/draw.paint.js +235 -0
  71. package/script/rive/api/draw/draw.path.js +153 -0
  72. package/script/rive/api/draw/draw.renderer.js +117 -0
  73. package/script/rive/api/math/math.color.js +96 -0
  74. package/script/rive/api/math/math.mat2d.js +180 -0
  75. package/script/rive/api/math/math.vector.js +121 -0
  76. package/script/rive/api/media/media.audio.js +113 -0
  77. package/script/rive/api/media/media.draw.js +73 -0
  78. package/script/rive/api/media/media.image.js +61 -0
  79. package/script/rive/api/media/media.mesh.js +50 -0
  80. package/script/rive/api/pointer/pointer.event.js +64 -0
  81. package/script/rive/api/pointer/pointer.route.js +69 -0
  82. package/script/rive/api/text/text.font.js +129 -0
  83. package/script/rive/api/text/text.js +278 -0
  84. package/script/rive/api/text/text.layout.js +259 -0
  85. package/script/rive/context/context.js +76 -0
  86. package/script/rive/context/context.memory.js +196 -0
  87. package/script/rive/context/context.viewmodel.js +302 -0
  88. package/script/rive/context/lists/lists.js +160 -0
  89. package/script/rive/context/lists/lists.values.js +281 -0
  90. package/script/rive/host/host.globals.js +44 -0
  91. package/script/rive/host/host.js +533 -0
  92. package/script/rive/host/host.node.js +45 -0
  93. package/script/rive/rive.js +23 -0
  94. package/script/rive/rive.md +180 -0
  95. package/script/script.js +11 -0
  96. package/skin/skin.deform.js +514 -0
  97. package/skin/skin.ik.js +260 -0
  98. package/skin/skin.js +297 -0
  99. package/README.md +0 -3
package/package.json CHANGED
@@ -1,6 +1,49 @@
1
1
  {
2
2
  "name": "@px-petals/roku.motion-web",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.1.0",
4
+ "description": "Motion for the web: plays a lottie-roku bundle on a 2D canvas, vector from its Lottie or from its raster sheets, with its state machine, live data, keys and text, for browsers that can't run Rive's own runtime.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "main": "motion.js",
8
+ "exports": {
9
+ ".": "./motion.js",
10
+ "./*/.tests": null,
11
+ "./*.test.js": null,
12
+ "./*.js": "./*.js"
13
+ },
14
+ "files": [
15
+ "**/*",
16
+ "!**/.tests",
17
+ "!**/*.test.js",
18
+ "!jsconfig*.json",
19
+ "!legacy",
20
+ "!.artifacts"
21
+ ],
22
+ "sideEffects": false,
23
+ "browserslist": [
24
+ "chrome >= 32",
25
+ "safari >= 7.1",
26
+ "ios_saf >= 8",
27
+ "firefox >= 29",
28
+ "android >= 4.4.3",
29
+ "samsung >= 4",
30
+ "edge >= 18"
31
+ ],
32
+ "scripts": {
33
+ "build:legacy": "node legacy/legacy.js",
34
+ "legacy": "node .tests/legacy/legacy.js",
35
+ "parity": "node .tests/parity/parity.js",
36
+ "test": "node --test \".tests/*.test.js\" \"audio/.tests/*.test.js\" \"bands/.tests/*.test.js\" \"hit/.tests/*.test.js\" \"focus/.tests/*.test.js\" \"player/.tests/*.test.js\" \"pointer/.tests/*.test.js\" \"lottie/.tests/*.test.js\" \"list/.tests/*.test.js\" \"mixer/.tests/*.test.js\" \"skin/.tests/*.test.js\" \"motion/.tests/*.test.js\" \"script/luau/.tests/*.test.js\" \"script/luau/.tests/reference/*.test.js\" \"script/rive/.tests/*.test.js\" \"script/play/.tests/*.test.js\" \".tests/browser/*.test.js\"",
37
+ "typecheck": "tsc -p jsconfig.json",
38
+ "luau:fetch": "node script/luau/.tests/reference/reference.fetch.js",
39
+ "luau:fixtures": "node script/luau/.tests/reference/reference.regen.js"
40
+ },
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/px-petals/motion.git",
44
+ "directory": "src/runtime/web"
45
+ },
46
+ "publishConfig": {
47
+ "access": "public"
48
+ }
49
+ }
@@ -0,0 +1,325 @@
1
+ /**
2
+ * Text overlays: the text layers lottie-roku lifts out of an animation,
3
+ * drawn on the canvas each frame along their tracks. A track's keys are
4
+ * linear per frame (lottie-roku samples the Lottie's easing), so drawing at
5
+ * a frame is a linear blend between two keys. A live input replaces the
6
+ * text it fills: the first run's, when the text has several.
7
+ *
8
+ * A text is one or more styled runs. They flow together through lines that
9
+ * wrap at the box width (`wrap`), align across it (`align`) and sit in its
10
+ * height (`valign`); see overlay.layout.js.
11
+ */
12
+
13
+ import { fontOf, layoutRuns } from './overlay.layout.js'
14
+
15
+ /** @import { Matrix } from '../../lottie/lottie.paths.js' */
16
+ /** @import { Line, Run } from './overlay.layout.js' */
17
+
18
+ /** Where a font's baseline sits below the top of its em box, as a fraction of its size */
19
+ const ASCENT = 0.8
20
+
21
+ /**
22
+ * @param {CanvasRenderingContext2D} ctx
23
+ * @param {any[]} texts an animation's `texts`
24
+ * @param {number} frame from the animation's start
25
+ * @param {Record<string, any>} values live inputs
26
+ * @param {Record<string, string>} families bundle font name → registered family
27
+ * @param {Matrix} matrix the animation's space onto the canvas
28
+ * @param {Record<string, string>} inputOf overlay slot id → the input that fills it
29
+ */
30
+ export function drawOverlays(ctx, texts, frame, values, families, matrix, inputOf) {
31
+ for (let n = 0; n < (texts || []).length; n++) {
32
+ const text = texts[n]
33
+ const track = text.track
34
+ const isVisible = frame >= track.visible[0] && frame < track.visible[1]
35
+ if (!isVisible) continue
36
+
37
+ const opacity = keyAt(track.opacity, frame)[0] / 100
38
+ if (opacity <= 0) continue
39
+
40
+ const input = text.sid ? inputOf[text.sid] : undefined
41
+ const live = input !== undefined && values[input] !== undefined && values[input] !== null ? String(values[input]) : null
42
+ const position = keyAt(track.position, frame)
43
+ const scale = keyAt(track.scale, frame)
44
+ const rotation = (keyAt(track.rotation, frame)[0] * Math.PI) / 180
45
+
46
+ ctx.save()
47
+ ctx.setTransform(matrix[0], matrix[1], matrix[2], matrix[3], matrix[4], matrix[5])
48
+ ctx.translate(position[0], position[1])
49
+ ctx.rotate(rotation)
50
+ ctx.scale(scale[0] / 100, scale[1] / 100)
51
+ ctx.globalAlpha = Math.min(1, opacity)
52
+ ctx.textAlign = 'left'
53
+ drawText(ctx, text, runsOf(text, live), families)
54
+ ctx.restore()
55
+ }
56
+ }
57
+
58
+ /**
59
+ * A text layer drawn where the renderer is: a Lottie text document, which a
60
+ * nested artboard's text stays as inside its precomp, drawn at a matrix to
61
+ * the canvas as an overlay is, with the live words in place of the document's.
62
+ *
63
+ * @param {CanvasRenderingContext2D} ctx
64
+ * @param {any} doc the layer's text document
65
+ * @param {string | null} live the words a data bind sets
66
+ * @param {Record<string, string>} families bundle font name → registered family
67
+ * @param {Matrix} matrix the layer's space onto the canvas
68
+ */
69
+ export function drawDocument(ctx, doc, live, families, matrix) {
70
+ const text = overlayOf(doc)
71
+ ctx.save()
72
+ ctx.setTransform(matrix[0], matrix[1], matrix[2], matrix[3], matrix[4], matrix[5])
73
+ ctx.textAlign = 'left'
74
+ drawText(ctx, text, runsOf(text, live), families)
75
+ ctx.restore()
76
+ }
77
+
78
+ /**
79
+ * The overlay lottie-roku makes of a text document, without its track.
80
+ *
81
+ * @param {any} doc
82
+ * @returns {any}
83
+ */
84
+ function overlayOf(doc) {
85
+ const size = doc.s || 12
86
+ const spacing = (/** @type {number} */ tracking) => Math.round(((tracking || 0) / 1000) * size * 100) / 100
87
+ const color = (/** @type {number[] | undefined} */ fc) => {
88
+ const channels = (fc || [ 0, 0, 0 ]).map((/** @type {number} */ channel) => Math.round(Math.min(1, Math.max(0, channel)) * 255))
89
+ return '0x' + channels.map((/** @type {number} */ n) => (n < 16 ? '0' : '') + n.toString(16)).join('') + 'ff'
90
+ }
91
+ const runs = Array.isArray(doc.rs) && doc.rs.length > 1 ? doc.rs.map((/** @type {any} */ run) => {
92
+ const own = run.s || size
93
+ return { text: String(run.t || '').replace(/\r/g, '\n'), font: run.f || doc.f || '', size: own, color: color(run.fc || doc.fc), lineHeight: run.lh || own * 1.2, letterSpacing: Math.round(((run.tr || 0) / 1000) * own * 100) / 100 }
94
+ }) : null
95
+ return {
96
+ text: String(doc.t || '').replace(/\r/g, '\n'),
97
+ font: doc.f || '',
98
+ size,
99
+ color: color(doc.fc),
100
+ align: [ 'left', 'right', 'center' ][doc.j] || 'left',
101
+ lineHeight: doc.lh || size * 1.2,
102
+ letterSpacing: spacing(doc.tr),
103
+ runs,
104
+ wrap: !doc.nw,
105
+ valign: doc.va === 'middle' || doc.va === 'bottom' ? doc.va : 'top',
106
+ box: doc.sz ? [ doc.sz[0], doc.sz[1] ] : null,
107
+ boxOrigin: doc.ps ? [ doc.ps[0], doc.ps[1] ] : [ 0, 0 ],
108
+ }
109
+ }
110
+
111
+ /**
112
+ * A track's value at a frame: before the first key, its value; between two,
113
+ * their linear blend; after the last, its value.
114
+ *
115
+ * @param {{ t: number, v: number[] }[]} keys
116
+ * @param {number} frame
117
+ * @returns {number[]}
118
+ */
119
+ export function keyAt(keys, frame) {
120
+ if (!keys.length) return [ 0, 0 ]
121
+ if (frame <= keys[0].t) return keys[0].v
122
+ for (let n = 1; n < keys.length; n++) {
123
+ const next = keys[n]
124
+ if (frame > next.t) continue
125
+ const previous = keys[n - 1]
126
+ const span = next.t - previous.t
127
+ const t = span > 0 ? (frame - previous.t) / span : 1
128
+ return previous.v.map((value, d) => value + (next.v[d] - value) * t)
129
+ }
130
+ return keys[keys.length - 1].v
131
+ }
132
+
133
+ /** The channels of a text's track, and how many values each key holds */
134
+ const CHANNELS = [ 'position', 'scale', 'rotation', 'opacity' ]
135
+
136
+ /**
137
+ * What several animations playing at once show of their texts: each text of
138
+ * the first, with every channel of its track mixed as Rive mixes the
139
+ * animations that key it, in order, each lerping toward its own value by its
140
+ * weight. A channel an animation does not move (one key) is left alone by it.
141
+ * A text shows when it shows in the animation with the most weight.
142
+ *
143
+ * The texts of every animation are the same list, in the same order. The
144
+ * result is for `drawOverlays` at frame 0.
145
+ *
146
+ * @param {{ texts: any[], frame: number, weight: number }[]} poses in the order they apply
147
+ * @returns {any[]}
148
+ */
149
+ export function mixOverlays(poses) {
150
+ const first = poses.length ? poses[0].texts || [] : []
151
+ /** @type {any[]} */
152
+ const mixed = []
153
+ for (let n = 0; n < first.length; n++) {
154
+ let heaviest = poses[0]
155
+ for (let p = 1; p < poses.length; p++) if (poses[p].weight >= heaviest.weight) heaviest = poses[p]
156
+ const lead = (heaviest.texts || [])[n]
157
+ if (!lead) continue
158
+ const isVisible = heaviest.frame >= lead.track.visible[0] && heaviest.frame < lead.track.visible[1]
159
+ if (!isVisible) continue
160
+
161
+ /** @type {Record<string, any>} */
162
+ const track = { frames: lead.track.frames, fps: lead.track.fps, visible: [ 0, 1 ] }
163
+ for (let c = 0; c < CHANNELS.length; c++) {
164
+ const channel = CHANNELS[c]
165
+ // what no animation moves: the static value, from the one that is heaviest
166
+ let value = keyAt(lead.track[channel], heaviest.frame)
167
+ for (let p = 0; p < poses.length; p++) {
168
+ const other = (poses[p].texts || [])[n]
169
+ if (other && other.track[channel].length < 2) value = keyAt(other.track[channel], 0)
170
+ }
171
+ for (let p = 0; p < poses.length; p++) {
172
+ const other = (poses[p].texts || [])[n]
173
+ if (!other || other.track[channel].length < 2) continue
174
+ const sample = keyAt(other.track[channel], poses[p].frame)
175
+ const weight = Math.min(1, Math.max(0, poses[p].weight))
176
+ value = value.map((v, d) => v + (sample[d] - v) * weight)
177
+ }
178
+ track[channel] = [ { t: 0, v: value } ]
179
+ }
180
+ mixed.push({ ...lead, track })
181
+ }
182
+ return mixed
183
+ }
184
+
185
+ /**
186
+ * The runs a text draws: its own, or one in the text's style, with a live
187
+ * value in place of the first run's words.
188
+ *
189
+ * @param {any} text
190
+ * @param {string | null} live
191
+ * @returns {Run[]}
192
+ */
193
+ function runsOf(text, live) {
194
+ const spacing = text.letterSpacing || 0
195
+ const hasRuns = text.runs && text.runs.length
196
+ if (!hasRuns) {
197
+ return [ { text: live === null ? text.text : live, font: text.font, size: text.size, color: text.color, lineHeight: text.lineHeight || text.size * 1.2, letterSpacing: spacing } ]
198
+ }
199
+
200
+ const runs = []
201
+ for (let n = 0; n < text.runs.length; n++) {
202
+ const run = text.runs[n]
203
+ runs.push({ text: n === 0 && live !== null ? live : run.text, font: run.font, size: run.size, color: run.color, lineHeight: run.lineHeight || run.size * 1.2, letterSpacing: run.letterSpacing || 0 })
204
+ }
205
+ return runs
206
+ }
207
+
208
+ /**
209
+ * Box text wraps at the box's width and sits in its height from its
210
+ * top-left; point text hangs from its origin, the first baseline on it.
211
+ *
212
+ * @param {CanvasRenderingContext2D} ctx
213
+ * @param {any} text
214
+ * @param {Run[]} runs
215
+ * @param {Record<string, string>} families
216
+ */
217
+ function drawText(ctx, text, runs, families) {
218
+ const [ x, y ] = text.boxOrigin
219
+ const box = text.box
220
+ const lines = layoutRuns(ctx, runs, box ? box[0] : null, text.wrap !== false, families)
221
+
222
+ let top = y
223
+ if (box) {
224
+ let total = 0
225
+ for (let n = 0; n < lines.length; n++) total += lines[n].height
226
+ if (text.valign === 'middle') top += (box[1] - total) / 2
227
+ else if (text.valign === 'bottom') top += box[1] - total
228
+ } else if (lines.length) {
229
+ // the first baseline sits on the origin
230
+ top -= lines[0].height
231
+ }
232
+
233
+ for (let n = 0; n < lines.length; n++) {
234
+ const line = lines[n]
235
+ const left = lineLeft(text.align, line.width, x, box)
236
+ drawLine(ctx, line, left, box ? top : top + line.height, !!box, families)
237
+ top += line.height
238
+ }
239
+ }
240
+
241
+ /**
242
+ * @param {string} align
243
+ * @param {number} width the line's
244
+ * @param {number} x the box's left, or point text's origin
245
+ * @param {number[] | null} box
246
+ * @returns {number} where the line starts
247
+ */
248
+ function lineLeft(align, width, x, box) {
249
+ const room = box ? box[0] - width : -width
250
+ if (align === 'center') return x + room / 2
251
+ if (align === 'right') return x + room
252
+ return x
253
+ }
254
+
255
+ /**
256
+ * Draws a line's pieces side by side. In a box a line hangs from its top
257
+ * and a smaller run is dropped to share the line's baseline; point text
258
+ * stands on its baseline.
259
+ *
260
+ * @param {CanvasRenderingContext2D} ctx
261
+ * @param {Line} line
262
+ * @param {number} left
263
+ * @param {number} y the line's top in a box, its baseline otherwise
264
+ * @param {boolean} isBoxed
265
+ * @param {Record<string, string>} families
266
+ */
267
+ function drawLine(ctx, line, left, y, isBoxed, families) {
268
+ let x = left
269
+ for (let n = 0; n < line.pieces.length; n++) {
270
+ const piece = line.pieces[n]
271
+ const run = piece.run
272
+ ctx.font = fontOf(run, families)
273
+ ctx.fillStyle = cssColor(run.color)
274
+ // a line in a box hangs from its top: the font's own ascent above the baseline, as Rive sets it, where the browser measures it
275
+ const ascent = isBoxed ? ascentOf(ctx) : null
276
+ ctx.textBaseline = isBoxed && ascent === null ? 'top' : 'alphabetic'
277
+ const at = isBoxed ? y + (line.size - run.size) * ASCENT + (ascent === null ? 0 : ascent) : y
278
+ if (run.letterSpacing) drawSpaced(ctx, piece.text, x, at, run.letterSpacing)
279
+ else ctx.fillText(piece.text, x, at)
280
+ x += piece.width
281
+ }
282
+ }
283
+
284
+ /**
285
+ * How far above its baseline a font's line reaches, where the browser
286
+ * measures it (an old one does not: its text hangs from the em box's top).
287
+ *
288
+ * @param {CanvasRenderingContext2D} ctx the context, with the run's font set
289
+ * @returns {number | null}
290
+ */
291
+ function ascentOf(ctx) {
292
+ const ascent = /** @type {any} */ (ctx.measureText('Hg')).fontBoundingBoxAscent
293
+ return typeof ascent === 'number' && ascent > 0 ? ascent : null
294
+ }
295
+
296
+ /**
297
+ * Canvas text has no letter spacing in older browsers, so each character is
298
+ * placed on its own.
299
+ *
300
+ * @param {CanvasRenderingContext2D} ctx
301
+ * @param {string} text
302
+ * @param {number} x
303
+ * @param {number} y
304
+ * @param {number} spacing px after each character
305
+ */
306
+ function drawSpaced(ctx, text, x, y, spacing) {
307
+ let pen = x
308
+ for (let n = 0; n < text.length; n++) {
309
+ const char = text.charAt(n)
310
+ ctx.fillText(char, pen, y)
311
+ pen += ctx.measureText(char).width + spacing
312
+ }
313
+ }
314
+
315
+ /**
316
+ * lottie-roku's `0xRRGGBBAA` as CSS.
317
+ *
318
+ * @param {string} value
319
+ */
320
+ function cssColor(value) {
321
+ const hex = String(value).replace(/^0x|^#/i, '')
322
+ const byte = (/** @type {number} */ at) => parseInt(hex.slice(at, at + 2), 16)
323
+ const alpha = hex.length >= 8 ? byte(6) / 255 : 1
324
+ return 'rgba(' + byte(0) + ',' + byte(2) + ',' + byte(4) + ',' + alpha + ')'
325
+ }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Lays styled runs out in lines with `measureText`: words wrap at the box
3
+ * width, a word wider than the box breaks between characters, and each line
4
+ * records the pieces to draw, its width and its height.
5
+ */
6
+
7
+ /**
8
+ * One run's style, as an overlay carries it.
9
+ *
10
+ * @typedef {object} Run
11
+ * @property {string} text
12
+ * @property {string} font bundle font name
13
+ * @property {number} size px
14
+ * @property {string} color 0xRRGGBBAA
15
+ * @property {number} lineHeight px
16
+ * @property {number} letterSpacing px after each character
17
+ */
18
+
19
+ /**
20
+ * A stretch of one run on one line.
21
+ *
22
+ * @typedef {object} Piece
23
+ * @property {Run} run
24
+ * @property {string} text
25
+ * @property {number} width
26
+ */
27
+
28
+ /**
29
+ * @typedef {object} Line
30
+ * @property {Piece[]} pieces
31
+ * @property {number} width without trailing spaces
32
+ * @property {number} height the tallest run's line height
33
+ * @property {number} size the largest run's font size
34
+ */
35
+
36
+ /**
37
+ * @param {Run} run
38
+ * @param {Record<string, string>} families bundle font name → registered family
39
+ * @returns {string} a canvas font
40
+ */
41
+ export function fontOf(run, families) {
42
+ return run.size + 'px "' + (families[run.font] || run.font) + '", sans-serif'
43
+ }
44
+
45
+ /**
46
+ * @param {CanvasRenderingContext2D} ctx
47
+ * @param {Run[]} runs
48
+ * @param {number | null} width the box's width; null for point text, which never wraps
49
+ * @param {boolean} isWrapped
50
+ * @param {Record<string, string>} families
51
+ * @returns {Line[]}
52
+ */
53
+ export function layoutRuns(ctx, runs, width, isWrapped, families) {
54
+ const limit = isWrapped && width !== null ? width : Infinity
55
+ const lines = [ newLine(runs[0]) ]
56
+
57
+ const words = wordsOf(ctx, runs, families)
58
+ for (let n = 0; n < words.length; n++) {
59
+ const word = words[n]
60
+ if (word.isBreak) {
61
+ lines.push(newLine(word.run))
62
+ } else if (word.isSpace) {
63
+ // a space never starts a wrapped line, and trailing ones don't count
64
+ const line = lines[lines.length - 1]
65
+ if (line.pieces.length) addPiece(line, word.pieces[0])
66
+ } else {
67
+ placeWord(ctx, lines, word, limit, families)
68
+ }
69
+ }
70
+ for (let n = 0; n < lines.length; n++) trim(lines[n])
71
+ return lines
72
+ }
73
+
74
+ /**
75
+ * @param {Run} [run] the run the line starts in, for the height of an empty one
76
+ * @returns {Line}
77
+ */
78
+ function newLine(run) {
79
+ return { pieces: [], width: 0, height: run ? run.lineHeight : 0, size: run ? run.size : 0 }
80
+ }
81
+
82
+ /**
83
+ * @param {Line} line
84
+ * @param {Piece} piece added to the end of the line
85
+ */
86
+ function addPiece(line, piece) {
87
+ line.pieces.push(piece)
88
+ line.height = Math.max(line.height, piece.run.lineHeight)
89
+ line.size = Math.max(line.size, piece.run.size)
90
+ line.width += piece.width
91
+ }
92
+
93
+ /**
94
+ * Words, spaces and line breaks of the runs, in order. A word is the
95
+ * stretch between spaces and may cross runs, which only changes its pieces'
96
+ * style.
97
+ *
98
+ * @param {CanvasRenderingContext2D} ctx
99
+ * @param {Run[]} runs
100
+ * @param {Record<string, string>} families
101
+ * @returns {Array<{ pieces: Piece[], width: number, isSpace: boolean, isBreak: boolean, run: Run }>}
102
+ */
103
+ function wordsOf(ctx, runs, families) {
104
+ const out = []
105
+ /** @type {{ pieces: Piece[], width: number, isSpace: boolean, isBreak: boolean, run: Run } | null} */
106
+ let word = null
107
+ const flush = () => {
108
+ if (word) out.push(word)
109
+ word = null
110
+ }
111
+
112
+ for (let r = 0; r < runs.length; r++) {
113
+ const run = runs[r]
114
+ const text = String(run.text).replace(/\r\n|\r/g, '\n')
115
+ let start = 0
116
+ for (let n = 0; n <= text.length; n++) {
117
+ const char = n < text.length ? text.charAt(n) : ''
118
+ const isEdge = char === ' ' || char === '\n' || n === text.length
119
+ if (!isEdge) continue
120
+
121
+ if (n > start) {
122
+ const piece = pieceOf(ctx, run, text.slice(start, n), families)
123
+ word = word || { pieces: [], width: 0, isSpace: false, isBreak: false, run }
124
+ word.pieces.push(piece)
125
+ word.width += piece.width
126
+ }
127
+ start = n + 1
128
+ // a word ends at a space or a break; at the end of a run it may go on
129
+ if (char === ' ') {
130
+ flush()
131
+ out.push({ pieces: [ pieceOf(ctx, run, ' ', families) ], width: 0, isSpace: true, isBreak: false, run })
132
+ } else if (char === '\n') {
133
+ flush()
134
+ out.push({ pieces: [], width: 0, isSpace: false, isBreak: true, run })
135
+ }
136
+ }
137
+ }
138
+ flush()
139
+ return out
140
+ }
141
+
142
+ /**
143
+ * @param {CanvasRenderingContext2D} ctx
144
+ * @param {Run} run
145
+ * @param {string} text
146
+ * @param {Record<string, string>} families
147
+ * @returns {Piece}
148
+ */
149
+ function pieceOf(ctx, run, text, families) {
150
+ ctx.font = fontOf(run, families)
151
+ return { run, text, width: ctx.measureText(text).width + run.letterSpacing * text.length }
152
+ }
153
+
154
+ /**
155
+ * Puts a word on the current line, or the next when it doesn't fit, or
156
+ * across several when it is wider than a line of its own.
157
+ *
158
+ * @param {CanvasRenderingContext2D} ctx
159
+ * @param {Line[]} lines
160
+ * @param {{ pieces: Piece[], width: number, run: Run }} word
161
+ * @param {number} limit
162
+ * @param {Record<string, string>} families
163
+ */
164
+ function placeWord(ctx, lines, word, limit, families) {
165
+ let line = lines[lines.length - 1]
166
+ if (line.pieces.length && line.width + word.width > limit) {
167
+ trim(line)
168
+ line = newLine(word.run)
169
+ lines.push(line)
170
+ }
171
+
172
+ const isTooWide = word.width > limit
173
+ if (!isTooWide) {
174
+ for (let n = 0; n < word.pieces.length; n++) addPiece(line, word.pieces[n])
175
+ return
176
+ }
177
+
178
+ // a word wider than the line breaks between characters
179
+ for (let n = 0; n < word.pieces.length; n++) {
180
+ const piece = word.pieces[n]
181
+ const text = piece.text
182
+ for (let c = 0; c < text.length; c++) {
183
+ let chars = text.charAt(c)
184
+ // keep a surrogate pair together
185
+ const code = chars.charCodeAt(0)
186
+ if (code >= 0xd800 && code < 0xdc00 && c + 1 < text.length) chars += text.charAt(++c)
187
+ const one = pieceOf(ctx, piece.run, chars, families)
188
+ if (line.pieces.length && line.width + one.width > limit) {
189
+ trim(line)
190
+ line = newLine(piece.run)
191
+ lines.push(line)
192
+ }
193
+ addPiece(line, one)
194
+ }
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Drops a line's trailing spaces, so they neither count towards its width
200
+ * nor push centred or right-aligned text off balance.
201
+ *
202
+ * @param {Line} line
203
+ */
204
+ function trim(line) {
205
+ while (line.pieces.length && line.pieces[line.pieces.length - 1].text === ' ') {
206
+ line.width -= /** @type {Piece} */ (line.pieces.pop()).width
207
+ }
208
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Where an animation goes in its canvas: Rive's fit and alignment, and which
3
+ * of a responsive artboard's layout bands the canvas is wide enough for.
4
+ *
5
+ * Fit is how the artboard scales into the canvas (`contain`, `cover`,
6
+ * `fill`, `fitWidth`, `fitHeight`, `scaleDown`, `none`, `layout`); alignment
7
+ * is where the rest of the canvas goes, from -1 (left, top) to 1 (right,
8
+ * bottom) on each axis. A fit that does not fill the canvas leaves the
9
+ * remainder to the alignment, and one that overflows it is cut by the canvas.
10
+ *
11
+ * `none` and `scaleDown` count in CSS pixels, so an animation keeps its size
12
+ * on a high-density screen. `layout` plays the layout band for the canvas's
13
+ * width, then fits it as `contain` does.
14
+ */
15
+
16
+ /** @import { Matrix } from '../lottie/lottie.paths.js' */
17
+
18
+ /** Rive's alignment names, as [x, y] */
19
+ const ALIGNMENTS = {
20
+ topLeft: [ -1, -1 ],
21
+ topCenter: [ 0, -1 ],
22
+ topRight: [ 1, -1 ],
23
+ centerLeft: [ -1, 0 ],
24
+ center: [ 0, 0 ],
25
+ centerRight: [ 1, 0 ],
26
+ bottomLeft: [ -1, 1 ],
27
+ bottomCenter: [ 0, 1 ],
28
+ bottomRight: [ 1, 1 ],
29
+ }
30
+
31
+ const FITS = [ 'contain', 'cover', 'fill', 'fitWidth', 'fitHeight', 'scaleDown', 'none', 'layout' ]
32
+
33
+ /**
34
+ * @param {any} fit one of Rive's fits; anything else is `contain`
35
+ * @returns {string}
36
+ */
37
+ export function fitName(fit) {
38
+ return FITS.indexOf(fit) >= 0 ? fit : 'contain'
39
+ }
40
+
41
+ /**
42
+ * @param {any} alignment a Rive alignment name, or `[x, y]` from -1 to 1
43
+ * @returns {[number, number]} centre for anything unrecognised
44
+ */
45
+ export function alignmentOf(alignment) {
46
+ if (typeof alignment === 'string' && ALIGNMENTS[/** @type {keyof typeof ALIGNMENTS} */ (alignment)]) return /** @type {[number, number]} */ (ALIGNMENTS[/** @type {keyof typeof ALIGNMENTS} */ (alignment)])
47
+ const isPair = alignment && alignment.length === 2 && typeof alignment[0] === 'number' && typeof alignment[1] === 'number'
48
+ return isPair ? [ clamp(alignment[0]), clamp(alignment[1]) ] : [ 0, 0 ]
49
+ }
50
+
51
+ /** @param {number} value */
52
+ function clamp(value) {
53
+ return Math.max(-1, Math.min(1, value))
54
+ }
55
+
56
+ /**
57
+ * The matrix taking the animation's space onto the canvas's.
58
+ *
59
+ * @param {string} fit
60
+ * @param {[number, number]} alignment
61
+ * @param {number} width the animation's own width
62
+ * @param {number} height
63
+ * @param {number} canvasWidth in canvas pixels
64
+ * @param {number} canvasHeight
65
+ * @param {number} [ratio] canvas pixels per CSS pixel; default 1
66
+ * @returns {Matrix}
67
+ */
68
+ export function fitMatrix(fit, alignment, width, height, canvasWidth, canvasHeight, ratio) {
69
+ const scaleX = canvasWidth / width
70
+ const scaleY = canvasHeight / height
71
+ const contain = Math.min(scaleX, scaleY)
72
+ const unit = ratio || 1
73
+
74
+ let sx = contain
75
+ let sy = contain
76
+ if (fit === 'cover') sx = sy = Math.max(scaleX, scaleY)
77
+ else if (fit === 'fill') {
78
+ sx = scaleX
79
+ sy = scaleY
80
+ } else if (fit === 'fitWidth') sx = sy = scaleX
81
+ else if (fit === 'fitHeight') sx = sy = scaleY
82
+ else if (fit === 'none') sx = sy = unit
83
+ else if (fit === 'scaleDown') sx = sy = Math.min(unit, contain)
84
+
85
+ return [ sx, 0, 0, sy, ((canvasWidth - width * sx) / 2) * (1 + alignment[0]), ((canvasHeight - height * sy) / 2) * (1 + alignment[1]) ]
86
+ }
87
+
88
+ /**
89
+ * The layout band a canvas is wide enough for: the widest that fits, else the
90
+ * narrowest, which then scales down.
91
+ *
92
+ * @param {any[] | undefined} bands a bundle's `bands`, narrowest first
93
+ * @param {number} width the canvas's width in CSS pixels
94
+ * @returns {any | null} null without bands
95
+ */
96
+ export function pickBand(bands, width) {
97
+ if (!bands || !bands.length) return null
98
+ let picked = bands[0]
99
+ for (let n = 1; n < bands.length; n++) if (bands[n].width <= width) picked = bands[n]
100
+ return picked
101
+ }