@clipkit/runtime 1.0.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 (219) hide show
  1. package/LICENSE +54 -0
  2. package/README.md +98 -0
  3. package/dist/animation/easings.d.ts +9 -0
  4. package/dist/animation/easings.d.ts.map +1 -0
  5. package/dist/animation/easings.js +230 -0
  6. package/dist/animation/easings.js.map +1 -0
  7. package/dist/animation/expr.d.ts +44 -0
  8. package/dist/animation/expr.d.ts.map +1 -0
  9. package/dist/animation/expr.js +236 -0
  10. package/dist/animation/expr.js.map +1 -0
  11. package/dist/animation/keyframes.d.ts +23 -0
  12. package/dist/animation/keyframes.d.ts.map +1 -0
  13. package/dist/animation/keyframes.js +117 -0
  14. package/dist/animation/keyframes.js.map +1 -0
  15. package/dist/animation/motion-path.d.ts +18 -0
  16. package/dist/animation/motion-path.d.ts.map +1 -0
  17. package/dist/animation/motion-path.js +269 -0
  18. package/dist/animation/motion-path.js.map +1 -0
  19. package/dist/animation/noise1d.d.ts +5 -0
  20. package/dist/animation/noise1d.d.ts.map +1 -0
  21. package/dist/animation/noise1d.js +27 -0
  22. package/dist/animation/noise1d.js.map +1 -0
  23. package/dist/animation/presets.d.ts +60 -0
  24. package/dist/animation/presets.d.ts.map +1 -0
  25. package/dist/animation/presets.js +221 -0
  26. package/dist/animation/presets.js.map +1 -0
  27. package/dist/assets/cache.d.ts +18 -0
  28. package/dist/assets/cache.d.ts.map +1 -0
  29. package/dist/assets/cache.js +56 -0
  30. package/dist/assets/cache.js.map +1 -0
  31. package/dist/assets/fonts.d.ts +20 -0
  32. package/dist/assets/fonts.d.ts.map +1 -0
  33. package/dist/assets/fonts.js +127 -0
  34. package/dist/assets/fonts.js.map +1 -0
  35. package/dist/assets/loader.d.ts +18 -0
  36. package/dist/assets/loader.d.ts.map +1 -0
  37. package/dist/assets/loader.js +87 -0
  38. package/dist/assets/loader.js.map +1 -0
  39. package/dist/assets/lut.d.ts +5 -0
  40. package/dist/assets/lut.d.ts.map +1 -0
  41. package/dist/assets/lut.js +77 -0
  42. package/dist/assets/lut.js.map +1 -0
  43. package/dist/assets/media-time.d.ts +31 -0
  44. package/dist/assets/media-time.d.ts.map +1 -0
  45. package/dist/assets/media-time.js +65 -0
  46. package/dist/assets/media-time.js.map +1 -0
  47. package/dist/assets/mp4-frame-source.d.ts +44 -0
  48. package/dist/assets/mp4-frame-source.d.ts.map +1 -0
  49. package/dist/assets/mp4-frame-source.js +387 -0
  50. package/dist/assets/mp4-frame-source.js.map +1 -0
  51. package/dist/audio/encoder.d.ts +31 -0
  52. package/dist/audio/encoder.d.ts.map +1 -0
  53. package/dist/audio/encoder.js +96 -0
  54. package/dist/audio/encoder.js.map +1 -0
  55. package/dist/audio/fades.d.ts +16 -0
  56. package/dist/audio/fades.d.ts.map +1 -0
  57. package/dist/audio/fades.js +43 -0
  58. package/dist/audio/fades.js.map +1 -0
  59. package/dist/audio/limiter.d.ts +8 -0
  60. package/dist/audio/limiter.d.ts.map +1 -0
  61. package/dist/audio/limiter.js +39 -0
  62. package/dist/audio/limiter.js.map +1 -0
  63. package/dist/audio/loader.d.ts +6 -0
  64. package/dist/audio/loader.d.ts.map +1 -0
  65. package/dist/audio/loader.js +42 -0
  66. package/dist/audio/loader.js.map +1 -0
  67. package/dist/audio/mixer.d.ts +17 -0
  68. package/dist/audio/mixer.d.ts.map +1 -0
  69. package/dist/audio/mixer.js +204 -0
  70. package/dist/audio/mixer.js.map +1 -0
  71. package/dist/audio/varispeed.d.ts +24 -0
  72. package/dist/audio/varispeed.d.ts.map +1 -0
  73. package/dist/audio/varispeed.js +114 -0
  74. package/dist/audio/varispeed.js.map +1 -0
  75. package/dist/audio/wav.d.ts +6 -0
  76. package/dist/audio/wav.d.ts.map +1 -0
  77. package/dist/audio/wav.js +62 -0
  78. package/dist/audio/wav.js.map +1 -0
  79. package/dist/backend/backend.d.ts +579 -0
  80. package/dist/backend/backend.d.ts.map +1 -0
  81. package/dist/backend/backend.js +17 -0
  82. package/dist/backend/backend.js.map +1 -0
  83. package/dist/backend/webgl-backend.d.ts +97 -0
  84. package/dist/backend/webgl-backend.d.ts.map +1 -0
  85. package/dist/backend/webgl-backend.js +2142 -0
  86. package/dist/backend/webgl-backend.js.map +1 -0
  87. package/dist/backend/webgpu-backend.d.ts +121 -0
  88. package/dist/backend/webgpu-backend.d.ts.map +1 -0
  89. package/dist/backend/webgpu-backend.js +2481 -0
  90. package/dist/backend/webgpu-backend.js.map +1 -0
  91. package/dist/compositor/bitfont.d.ts +8 -0
  92. package/dist/compositor/bitfont.d.ts.map +1 -0
  93. package/dist/compositor/bitfont.js +52 -0
  94. package/dist/compositor/bitfont.js.map +1 -0
  95. package/dist/compositor/camera.d.ts +5 -0
  96. package/dist/compositor/camera.d.ts.map +1 -0
  97. package/dist/compositor/camera.js +114 -0
  98. package/dist/compositor/camera.js.map +1 -0
  99. package/dist/compositor/color.d.ts +26 -0
  100. package/dist/compositor/color.d.ts.map +1 -0
  101. package/dist/compositor/color.js +189 -0
  102. package/dist/compositor/color.js.map +1 -0
  103. package/dist/compositor/element-renderers/caption.d.ts +4 -0
  104. package/dist/compositor/element-renderers/caption.d.ts.map +1 -0
  105. package/dist/compositor/element-renderers/caption.js +376 -0
  106. package/dist/compositor/element-renderers/caption.js.map +1 -0
  107. package/dist/compositor/element-renderers/group.d.ts +12 -0
  108. package/dist/compositor/element-renderers/group.d.ts.map +1 -0
  109. package/dist/compositor/element-renderers/group.js +259 -0
  110. package/dist/compositor/element-renderers/group.js.map +1 -0
  111. package/dist/compositor/element-renderers/image.d.ts +4 -0
  112. package/dist/compositor/element-renderers/image.d.ts.map +1 -0
  113. package/dist/compositor/element-renderers/image.js +97 -0
  114. package/dist/compositor/element-renderers/image.js.map +1 -0
  115. package/dist/compositor/element-renderers/lit.d.ts +6 -0
  116. package/dist/compositor/element-renderers/lit.d.ts.map +1 -0
  117. package/dist/compositor/element-renderers/lit.js +82 -0
  118. package/dist/compositor/element-renderers/lit.js.map +1 -0
  119. package/dist/compositor/element-renderers/particles.d.ts +4 -0
  120. package/dist/compositor/element-renderers/particles.d.ts.map +1 -0
  121. package/dist/compositor/element-renderers/particles.js +212 -0
  122. package/dist/compositor/element-renderers/particles.js.map +1 -0
  123. package/dist/compositor/element-renderers/shape.d.ts +4 -0
  124. package/dist/compositor/element-renderers/shape.d.ts.map +1 -0
  125. package/dist/compositor/element-renderers/shape.js +171 -0
  126. package/dist/compositor/element-renderers/shape.js.map +1 -0
  127. package/dist/compositor/element-renderers/svg.d.ts +4 -0
  128. package/dist/compositor/element-renderers/svg.d.ts.map +1 -0
  129. package/dist/compositor/element-renderers/svg.js +210 -0
  130. package/dist/compositor/element-renderers/svg.js.map +1 -0
  131. package/dist/compositor/element-renderers/text.d.ts +25 -0
  132. package/dist/compositor/element-renderers/text.d.ts.map +1 -0
  133. package/dist/compositor/element-renderers/text.js +1358 -0
  134. package/dist/compositor/element-renderers/text.js.map +1 -0
  135. package/dist/compositor/element-renderers/video.d.ts +12 -0
  136. package/dist/compositor/element-renderers/video.d.ts.map +1 -0
  137. package/dist/compositor/element-renderers/video.js +109 -0
  138. package/dist/compositor/element-renderers/video.js.map +1 -0
  139. package/dist/compositor/fit.d.ts +18 -0
  140. package/dist/compositor/fit.d.ts.map +1 -0
  141. package/dist/compositor/fit.js +106 -0
  142. package/dist/compositor/fit.js.map +1 -0
  143. package/dist/compositor/lighting.d.ts +63 -0
  144. package/dist/compositor/lighting.d.ts.map +1 -0
  145. package/dist/compositor/lighting.js +141 -0
  146. package/dist/compositor/lighting.js.map +1 -0
  147. package/dist/compositor/mat4.d.ts +88 -0
  148. package/dist/compositor/mat4.d.ts.map +1 -0
  149. package/dist/compositor/mat4.js +245 -0
  150. package/dist/compositor/mat4.js.map +1 -0
  151. package/dist/compositor/project.d.ts +24 -0
  152. package/dist/compositor/project.d.ts.map +1 -0
  153. package/dist/compositor/project.js +105 -0
  154. package/dist/compositor/project.js.map +1 -0
  155. package/dist/compositor/render-context.d.ts +194 -0
  156. package/dist/compositor/render-context.d.ts.map +1 -0
  157. package/dist/compositor/render-context.js +10 -0
  158. package/dist/compositor/render-context.js.map +1 -0
  159. package/dist/compositor/resolve.d.ts +80 -0
  160. package/dist/compositor/resolve.d.ts.map +1 -0
  161. package/dist/compositor/resolve.js +276 -0
  162. package/dist/compositor/resolve.js.map +1 -0
  163. package/dist/compositor/scene.d.ts +10 -0
  164. package/dist/compositor/scene.d.ts.map +1 -0
  165. package/dist/compositor/scene.js +658 -0
  166. package/dist/compositor/scene.js.map +1 -0
  167. package/dist/compositor/transform.d.ts +73 -0
  168. package/dist/compositor/transform.d.ts.map +1 -0
  169. package/dist/compositor/transform.js +229 -0
  170. package/dist/compositor/transform.js.map +1 -0
  171. package/dist/compositor/unit.d.ts +27 -0
  172. package/dist/compositor/unit.d.ts.map +1 -0
  173. package/dist/compositor/unit.js +74 -0
  174. package/dist/compositor/unit.js.map +1 -0
  175. package/dist/encoder/exporter.d.ts +95 -0
  176. package/dist/encoder/exporter.d.ts.map +1 -0
  177. package/dist/encoder/exporter.js +341 -0
  178. package/dist/encoder/exporter.js.map +1 -0
  179. package/dist/encoder/index.d.ts +3 -0
  180. package/dist/encoder/index.d.ts.map +1 -0
  181. package/dist/encoder/index.js +2 -0
  182. package/dist/encoder/index.js.map +1 -0
  183. package/dist/index.d.ts +12 -0
  184. package/dist/index.d.ts.map +1 -0
  185. package/dist/index.js +27 -0
  186. package/dist/index.js.map +1 -0
  187. package/dist/logger.d.ts +13 -0
  188. package/dist/logger.d.ts.map +1 -0
  189. package/dist/logger.js +32 -0
  190. package/dist/logger.js.map +1 -0
  191. package/dist/runtime.d.ts +216 -0
  192. package/dist/runtime.d.ts.map +1 -0
  193. package/dist/runtime.js +1012 -0
  194. package/dist/runtime.js.map +1 -0
  195. package/dist/svg/morph.d.ts +6 -0
  196. package/dist/svg/morph.d.ts.map +1 -0
  197. package/dist/svg/morph.js +62 -0
  198. package/dist/svg/morph.js.map +1 -0
  199. package/dist/svg/svg-renderer.d.ts +18 -0
  200. package/dist/svg/svg-renderer.d.ts.map +1 -0
  201. package/dist/svg/svg-renderer.js +142 -0
  202. package/dist/svg/svg-renderer.js.map +1 -0
  203. package/dist/text/caption-chunk.d.ts +17 -0
  204. package/dist/text/caption-chunk.d.ts.map +1 -0
  205. package/dist/text/caption-chunk.js +76 -0
  206. package/dist/text/caption-chunk.js.map +1 -0
  207. package/dist/text/font-atlas.d.ts +63 -0
  208. package/dist/text/font-atlas.d.ts.map +1 -0
  209. package/dist/text/font-atlas.js +225 -0
  210. package/dist/text/font-atlas.js.map +1 -0
  211. package/dist/text/measure.d.ts +38 -0
  212. package/dist/text/measure.d.ts.map +1 -0
  213. package/dist/text/measure.js +164 -0
  214. package/dist/text/measure.js.map +1 -0
  215. package/dist/text/text-animation.d.ts +52 -0
  216. package/dist/text/text-animation.d.ts.map +1 -0
  217. package/dist/text/text-animation.js +133 -0
  218. package/dist/text/text-animation.js.map +1 -0
  219. package/package.json +47 -0
@@ -0,0 +1,1358 @@
1
+ import { isExpr, evalExpr } from '../../animation/expr.js';
2
+ import { parseColorPremultiplied } from '../color.js';
3
+ import { mat4ApplyToPoint, mat4Multiply, mat4PlaneAt, mat4Rotation, quadWorldTransform } from '../mat4.js';
4
+ import { resolveAnchor, resolveLength } from '../unit.js';
5
+ import { applyAnimation, resolve3D, resolveColorProperty, resolveScalePair, resolveSkewPair } from '../resolve.js';
6
+ import { compileTextAnimations, evaluateUnitEffect, hasUnitRotations, } from '../../text/text-animation.js';
7
+ import { interpolateKeyframes } from '../../animation/keyframes.js';
8
+ import { atlasKey, generateFontAtlas } from '../../text/font-atlas.js';
9
+ import { autoFitFontSize, autoFitFontSizeBox, withFontFallback } from '../../text/measure.js';
10
+ // Coverage exponent approximating Chrome's gamma-corrected text AA.
11
+ // Linear alpha blending over-darkens the AA fringe of dark glyphs
12
+ // (reads ~half a weight step bold at 12-16px) and under-fills light
13
+ // ones. Dark tints thin (g > 1), light tints fill (g < 1); the curve
14
+ // is calibrated against the import-fidelity probe: swept 1.3/1.45/
15
+ // 1.6/1.8/2.1 base exponents; the metric kept (weakly) improving past
16
+ // 1.6 but glyphs visibly erode from ~1.8, so 1.6 is the keeper. tint
17
+ // is premultiplied — un-premultiply before taking luminance.
18
+ function textAlphaGamma(tint) {
19
+ const a = tint[3];
20
+ if (a <= 0.001)
21
+ return 1;
22
+ const lum = Math.min(1, Math.max(0, (0.2126 * tint[0] + 0.7152 * tint[1] + 0.0722 * tint[2]) / a));
23
+ return 1.6 - 0.75 * lum;
24
+ }
25
+ export function renderTextElement(element, ctx) {
26
+ // If the element has a reveal mask, take the offscreen-canvas path: we
27
+ // rasterize the text + apply the mask via Canvas2D destination-in
28
+ // compositing, then upload as a single texture. The mask animates per
29
+ // frame, so this re-rasterizes every render.
30
+ if (element.mask) {
31
+ renderMaskedTextElement(element, ctx);
32
+ return;
33
+ }
34
+ // Inline-styled spans take a separate path: each span has its own
35
+ // family/size/weight/color and may carry a background_color.
36
+ if (element.spans && element.spans.length > 0) {
37
+ renderSpannedTextElement(element, ctx);
38
+ return;
39
+ }
40
+ const { canvas, backend } = ctx;
41
+ const elementStart = ctx.timeOffset + numberOr(element.time, 0);
42
+ const localTime = ctx.time - elementStart;
43
+ const text = resolveText(element, localTime);
44
+ if (text.length === 0)
45
+ return;
46
+ const fontFamily = withFontFallback(String(element.font_family ?? 'sans-serif'));
47
+ const fontWeight = element.font_weight ?? 'normal';
48
+ // font_size accepts:
49
+ // number — pixels
50
+ // "auto" — fit to element.width (or canvas width if not set)
51
+ // "Npx" / "Nvh" / "Nvw" / "Nvmin" / "Nvmax" / "N%" (% = % of canvas height)
52
+ let fontSize;
53
+ if (element.font_size === 'auto') {
54
+ const constraintWidth = (element.width !== undefined
55
+ ? resolveLength(element.width, canvas.width, canvas, canvas.width)
56
+ : canvas.width) - 2 * resolveLength(element.x_padding, canvas.width, canvas, 0);
57
+ const autoMin = resolveLength(element.font_size_minimum, canvas.height, canvas, 8);
58
+ const autoMax = resolveLength(element.font_size_maximum, canvas.height, canvas, 400);
59
+ if (element.height !== undefined) {
60
+ // 2D fill mode: with BOTH width and height authored, the text
61
+ // wraps and takes the largest size whose wrapped lines fit the
62
+ // box. Resizing the box in the editor refits live.
63
+ const boxH = resolveLength(element.height, canvas.height, canvas, 0) -
64
+ 2 * resolveLength(element.y_padding, canvas.height, canvas, 0);
65
+ fontSize = autoFitFontSizeBox(text, fontFamily, fontWeight, Math.max(1, constraintWidth), Math.max(1, boxH), numberOr(element.line_height, 1), autoMin, autoMax);
66
+ }
67
+ else {
68
+ fontSize = autoFitFontSize(text, fontFamily, fontWeight, Math.max(1, constraintWidth), 48, autoMin, autoMax);
69
+ }
70
+ }
71
+ else if (typeof element.font_size === 'number') {
72
+ fontSize = element.font_size;
73
+ }
74
+ else if (typeof element.font_size === 'string') {
75
+ fontSize = resolveLength(element.font_size, canvas.height, canvas, 48);
76
+ }
77
+ else {
78
+ fontSize = 48;
79
+ }
80
+ // Resolve atlas (cached per family/size/weight).
81
+ const key = atlasKey({ family: fontFamily, size: fontSize, weight: fontWeight });
82
+ let atlas;
83
+ if (ctx.fontAtlases.has(key)) {
84
+ atlas = ctx.fontAtlases.get(key);
85
+ }
86
+ else {
87
+ atlas = generateFontAtlas({ family: fontFamily, size: fontSize, weight: fontWeight }, backend);
88
+ ctx.fontAtlases.set(key, atlas);
89
+ }
90
+ const localX = applyAnimation(element, 'x', resolveLength(element.x, canvas.width, canvas), ctx);
91
+ const localY = applyAnimation(element, 'y', resolveLength(element.y, canvas.height, canvas), ctx);
92
+ const localRotation = applyAnimation(element, 'rotation', numberOr(element.rotation ?? element.z_rotation, 0), ctx);
93
+ const localOpacity01 = applyAnimation(element, 'opacity', numberOr(element.opacity, 1), ctx);
94
+ // Element scale (uniform + per-axis). Layout happens at natural size;
95
+ // glyph quads scale geometrically around the pivot at draw time, so
96
+ // the font atlas is untouched (no per-frame atlas regeneration).
97
+ const { sx, sy } = resolveScalePair(element, ctx);
98
+ // Shear: glyph offsets shear around the pivot AND each glyph quad
99
+ // shears, so the block reads as one solid skewed object.
100
+ const { skewX, skewY } = resolveSkewPair(element, ctx);
101
+ const tanSkewX = Math.tan((skewX * Math.PI) / 180);
102
+ const tanSkewY = Math.tan((skewY * Math.PI) / 180);
103
+ const xAnchor = resolveAnchor(element.x_anchor);
104
+ const yAnchor = resolveAnchor(element.y_anchor);
105
+ // Apply group transform stack to the element pivot + rotation. Glyphs
106
+ // orbit around the world pivot by the cumulative rotation. (Group
107
+ // scale doesn't propagate to font_size — accept as a small limitation.)
108
+ //
109
+ // CKP/1.0 3D (§4.4): under 3D — or any non-affine chain — the glyph
110
+ // math runs in the element's LOCAL frame (pivot = authored position,
111
+ // block rotation 0) and every quad projects through the element's
112
+ // plane matrix, which carries Rz·Ry·Rx around the pivot so the block
113
+ // tilts as one plane in the same order as leaf quads.
114
+ //
115
+ // text-flip (§6.5) rotates units out of the element's plane, so an
116
+ // active flip forces the matrix path even on otherwise-2D elements.
117
+ const textAnims = compileTextAnimations(element);
118
+ const unitRotations = textAnims !== null && hasUnitRotations(textAnims);
119
+ const t3d = resolve3D(element, ctx);
120
+ const matrixPath = t3d !== null || !ctx.modelMatrix.aff || unitRotations;
121
+ let x, y, rotation;
122
+ let glyphChain = null;
123
+ if (matrixPath) {
124
+ glyphChain = mat4Multiply(ctx.modelMatrix, mat4PlaneAt(localX, localY, t3d?.z ?? 0, localRotation, t3d?.yRot ?? 0, t3d?.xRot ?? 0));
125
+ x = localX;
126
+ y = localY;
127
+ rotation = 0;
128
+ }
129
+ else {
130
+ [x, y] = mat4ApplyToPoint(ctx.modelMatrix, localX, localY);
131
+ rotation = localRotation + mat4Rotation(ctx.modelMatrix);
132
+ }
133
+ const opacity01 = localOpacity01 * ctx.opacityFactor;
134
+ // Content padding insets the text box on each side.
135
+ const xPad = resolveLength(element.x_padding, canvas.width, canvas, 0);
136
+ const yPad = resolveLength(element.y_padding, canvas.height, canvas, 0);
137
+ // Split into lines: explicit "\n" always breaks; soft wrap engages
138
+ // when the element has a width and text_wrap !== false.
139
+ //
140
+ // letter_spacing follows Chrome: added after EVERY character,
141
+ // including the last. The importer captures boxes that fit text WITH
142
+ // its tracking (negative on most modern UI type) — measuring without
143
+ // it overshoots the box and wraps lines the browser kept whole.
144
+ const letterSpacing = numberOr(element.letter_spacing, 0);
145
+ // Kerning bookkeeping mirrors the draw loops below exactly — prev
146
+ // only updates on chars that HAVE a glyph, so measure and draw can
147
+ // never disagree about a line's width.
148
+ const advanceOf = (s) => {
149
+ let w = 0;
150
+ let prev = '';
151
+ for (const ch of s) {
152
+ const g = atlas.glyphs.get(ch);
153
+ if (g) {
154
+ w += g.advance + letterSpacing + atlas.kern(prev, ch);
155
+ prev = ch;
156
+ }
157
+ }
158
+ return w;
159
+ };
160
+ const explicitWidth = typeof element.width === 'number'
161
+ ? element.width
162
+ : typeof element.width === 'string'
163
+ ? resolveLength(element.width, canvas.width, canvas, Number.NaN)
164
+ : Number.NaN;
165
+ const wrapLimit = Number.isFinite(explicitWidth)
166
+ ? Math.max(0, explicitWidth - 2 * xPad)
167
+ : Number.POSITIVE_INFINITY;
168
+ // Auto-size and wrap interaction:
169
+ // auto + width only → sized to fit ONE line; never soft-wraps
170
+ // (explicit "\n" still breaks).
171
+ // auto + width+height → 2D fill mode; wrapping is intrinsic to
172
+ // the fit, so soft wrap stays on.
173
+ const autoFillsBox = element.font_size === 'auto' && element.height !== undefined;
174
+ const wrapEnabled = element.text_wrap !== false &&
175
+ (element.font_size !== 'auto' || autoFillsBox) &&
176
+ Number.isFinite(wrapLimit);
177
+ // Boxes captured from a browser fit their text EXACTLY, so a wrap
178
+ // decision at "> limit" sits on a knife edge of float rounding and
179
+ // sub-pixel metric differences. Half a pixel of slack keeps lines
180
+ // the browser kept, and is invisible when the text really is long.
181
+ const WRAP_EPS = 0.5;
182
+ const lines = [];
183
+ for (const raw of text.split('\n')) {
184
+ if (!wrapEnabled || advanceOf(raw) <= wrapLimit + WRAP_EPS) {
185
+ lines.push(raw);
186
+ continue;
187
+ }
188
+ // Greedy word wrap. An over-wide single word stays on its own line
189
+ // and overflows (CSS word-wrap: normal).
190
+ let current = '';
191
+ for (const word of raw.split(' ')) {
192
+ const candidate = current.length > 0 ? `${current} ${word}` : word;
193
+ if (current.length > 0 && advanceOf(candidate) > wrapLimit + WRAP_EPS) {
194
+ lines.push(current);
195
+ current = word;
196
+ }
197
+ else {
198
+ current = candidate;
199
+ }
200
+ }
201
+ if (current.length > 0)
202
+ lines.push(current);
203
+ }
204
+ const lineWidths = lines.map(advanceOf);
205
+ const textWidth = lineWidths.length > 0 ? Math.max(...lineWidths) : 0;
206
+ // CSS-style line-box vertical positioning. line_height is a ratio of
207
+ // font_size — line box = font_size × line_height. The font's natural
208
+ // glyph block (ascent + descent) sits CENTERED in the line box, with
209
+ // the surplus split evenly between top and bottom leading. When
210
+ // line_height < 1, leadingTop goes negative and adjacent lines
211
+ // overlap visually — same as CSS. Without this, headlines with
212
+ // tight line-heights (0.98, 0.95) take more vertical space than
213
+ // CSS does and eat into the gap before whatever sits below.
214
+ const glyphBlock = atlas.ascent + atlas.descent;
215
+ const lineHeightRatio = numberOr(element.line_height, 1);
216
+ const lineBoxHeight = fontSize * lineHeightRatio;
217
+ const leadingTop = (lineBoxHeight - glyphBlock) / 2;
218
+ // Box width: explicit element.width, else natural text width plus
219
+ // padding. Content (the text block) lives inside the padding insets;
220
+ // per-line horizontal alignment distributes each line's slack by
221
+ // alignFrac (x_alignment overrides text_align when present).
222
+ const boxWidth = Number.isFinite(explicitWidth)
223
+ ? explicitWidth
224
+ : textWidth + 2 * xPad;
225
+ const contentWidth = Math.max(0, boxWidth - 2 * xPad);
226
+ const textAlign = element.text_align ?? 'left';
227
+ const alignFrac = alignmentFraction(element.x_alignment, textAlign === 'center' ? 0.5 : textAlign === 'right' ? 1 : 0);
228
+ // Box height: explicit element.height, else all line boxes plus
229
+ // padding. y_alignment (or vertical_align) places the text block
230
+ // inside the content area.
231
+ const totalTextHeight = lineBoxHeight * lines.length;
232
+ let boxHeight = totalTextHeight + 2 * yPad;
233
+ if (typeof element.height === 'number')
234
+ boxHeight = element.height;
235
+ else if (typeof element.height === 'string') {
236
+ boxHeight = resolveLength(element.height, canvas.height, canvas, boxHeight);
237
+ }
238
+ const verticalAlign = element.vertical_align ?? 'top';
239
+ const vFrac = alignmentFraction(element.y_alignment, verticalAlign === 'middle' ? 0.5 : verticalAlign === 'bottom' ? 1 : 0);
240
+ const vSlack = Math.max(0, boxHeight - 2 * yPad - totalTextHeight);
241
+ // Anchor on the BOX; content starts inside the padding.
242
+ const blockLeft = x - boxWidth * xAnchor + xPad;
243
+ const contentTop = y - boxHeight * yAnchor + yPad + vSlack * vFrac;
244
+ // Color, with element opacity baked into alpha. fill_color is
245
+ // animatable via color-valued keyframe_animations.
246
+ const baseColor = parseColorPremultiplied(resolveColorProperty(element, 'fill_color', typeof element.fill_color === 'string' ? element.fill_color : '#ffffff', ctx));
247
+ const opacityFactor = Math.max(0, Math.min(1, opacity01));
248
+ const tint = [
249
+ baseColor[0] * opacityFactor,
250
+ baseColor[1] * opacityFactor,
251
+ baseColor[2] * opacityFactor,
252
+ baseColor[3] * opacityFactor,
253
+ ];
254
+ // Walk characters and emit one textured-quad per visible glyph.
255
+ // Each glyph stores tight bounds (with AA margin baked in) plus offsets
256
+ // from the cursor position; no UV inset needed because the AA margin
257
+ // itself isolates the glyph's quad from neighboring cells.
258
+ //
259
+ // Rotation: each glyph quad rotates around its OWN center via the
260
+ // backend transform. For block-level rotation (the user expects
261
+ // "rotate the whole word"), we additionally orbit each glyph's
262
+ // center around the element pivot (x, y) by the same angle, so
263
+ // glyphs both reposition relative to the pivot AND orient with it.
264
+ // Without the orbit step, each letter spins in place while the
265
+ // baseline stayed horizontal — the "every letter rotated separately"
266
+ // bug.
267
+ //
268
+ // Convention matches composeQuadTransform: CW in screen-pixel space
269
+ // (Y-down), so positive `rotation` rotates the same direction CSS
270
+ // `rotate(...)` does.
271
+ const rotRad = (rotation * Math.PI) / 180;
272
+ const rotCos = Math.cos(rotRad);
273
+ const rotSin = Math.sin(rotRad);
274
+ const rotateAroundPivot = rotation !== 0;
275
+ // Map an element-local layout point through the same scale → shear →
276
+ // pivot-orbit chain the glyph cell centers go through (used for
277
+ // text-flip unit pivots; rotation is 0 on the matrix path).
278
+ const mapCell = (px, py) => {
279
+ const sdx = (px - x) * sx;
280
+ const sdy = (py - y) * sy;
281
+ const ddx = sdx + tanSkewX * sdy;
282
+ const ddy = sdy + tanSkewY * sdx;
283
+ if (!rotateAroundPivot)
284
+ return [x + ddx, y + ddy];
285
+ return [x + ddx * rotCos - ddy * rotSin, y + ddx * rotSin + ddy * rotCos];
286
+ };
287
+ // Per-unit text animations (compiled above for the matrix-path
288
+ // decision). Null for the common case — zero overhead.
289
+ const animLocalTime = ctx.time - (ctx.timeOffset + numberOr(element.time, 0));
290
+ // text-flip pivot pre-pass (§6.5): unit rotations pivot at the
291
+ // unit's REST-layout center (letter = glyph cell, word = bounding
292
+ // box of the word's glyphs). Mirrors the draw loop's walk exactly.
293
+ let letterPivots = null;
294
+ let wordPivots = null;
295
+ if (unitRotations) {
296
+ letterPivots = [];
297
+ const wordBoxes = [];
298
+ let pl = 0, pw = 0, pInWord = false;
299
+ for (let li = 0; li < lines.length; li++) {
300
+ let cursorX = blockLeft + Math.max(0, contentWidth - lineWidths[li]) * alignFrac;
301
+ const baselineY = contentTop + li * lineBoxHeight + leadingTop + atlas.ascent;
302
+ let pPrev = '';
303
+ for (const ch of lines[li]) {
304
+ const isSpace = /\s/.test(ch);
305
+ if (isSpace && pInWord) {
306
+ pw += 1;
307
+ pInWord = false;
308
+ }
309
+ else if (!isSpace)
310
+ pInWord = true;
311
+ const g = atlas.glyphs.get(ch);
312
+ if (!g)
313
+ continue;
314
+ cursorX += atlas.kern(pPrev, ch);
315
+ pPrev = ch;
316
+ if (g.width === 0 || g.height === 0) {
317
+ cursorX += g.advance + letterSpacing;
318
+ continue;
319
+ }
320
+ const l = cursorX + g.offsetX;
321
+ const t = baselineY + g.offsetY;
322
+ letterPivots[pl] = [l + g.width / 2, t + g.height / 2];
323
+ const wb = (wordBoxes[pw] ??= { l: Infinity, r: -Infinity, t: Infinity, b: -Infinity });
324
+ wb.l = Math.min(wb.l, l);
325
+ wb.r = Math.max(wb.r, l + g.width);
326
+ wb.t = Math.min(wb.t, t);
327
+ wb.b = Math.max(wb.b, t + g.height);
328
+ pl += 1;
329
+ cursorX += g.advance + letterSpacing;
330
+ }
331
+ if (pInWord) {
332
+ pw += 1;
333
+ pInWord = false;
334
+ }
335
+ }
336
+ wordPivots = wordBoxes.map((b) => [(b.l + b.r) / 2, (b.t + b.b) / 2]);
337
+ }
338
+ // Shrink-wrapped background behind the text — ONE band PER LINE, each
339
+ // hugging that line's glyphs (width × the ascent/descent box) rather
340
+ // than one box around the whole block, so centered/ragged multi-line
341
+ // text gets the social-caption look. Opt-in: absent background_color ⇒
342
+ // nothing drawn (byte-identical).
343
+ if (typeof element.background_color === 'string' && textWidth > 0 && lines.length > 0) {
344
+ const bgC = (() => {
345
+ const c = parseColorPremultiplied(element.background_color);
346
+ return [c[0] * opacityFactor, c[1] * opacityFactor, c[2] * opacityFactor, c[3] * opacityFactor];
347
+ })();
348
+ const [padX, padY] = resolveBgPadding(element.background_padding);
349
+ const cr = numberOr(element.background_border_radius, 0);
350
+ for (let li = 0; li < lines.length; li++) {
351
+ const lw = lineWidths[li];
352
+ if (lw <= 0)
353
+ continue; // blank line — no band
354
+ const lineLeft = blockLeft + Math.max(0, contentWidth - lw) * alignFrac;
355
+ const cxLocal = lineLeft + lw / 2;
356
+ const cyLocal = contentTop + li * lineBoxHeight + leadingTop + glyphBlock / 2;
357
+ const [bgCx, bgCy] = mapCell(cxLocal, cyLocal);
358
+ const bgW = (lw + 2 * padX) * sx;
359
+ const bgH = (glyphBlock + 2 * padY) * sy;
360
+ backend.drawShape({
361
+ cx: bgCx, cy: bgCy, width: bgW, height: bgH, rotation, skewX, skewY,
362
+ transform: glyphChain
363
+ ? quadWorldTransform(glyphChain, bgCx, bgCy, bgW, bgH, 0, skewX, skewY, null)
364
+ : undefined,
365
+ color: bgC,
366
+ cornerRadius: cr,
367
+ shape: 'rectangle',
368
+ blend: element.blend_mode,
369
+ });
370
+ }
371
+ }
372
+ const textShadows = resolveTextShadows(element.text_shadow, opacityFactor);
373
+ // Two passes when shadows exist: pass 0 paints every glyph's shadow (so
374
+ // shadows sit behind ALL glyphs — stacked extrusions read cleanly),
375
+ // pass 1 paints the fills. No shadows ⇒ one fill pass (byte-identical).
376
+ const shadowPasses = textShadows.length > 0 ? 2 : 1;
377
+ let letterIdx = 0;
378
+ let wordIdx = 0;
379
+ let inWord = false;
380
+ for (let pass = 0; pass < shadowPasses; pass++) {
381
+ const drawShadowPass = textShadows.length > 0 && pass === 0;
382
+ const drawFillPass = textShadows.length === 0 || pass === 1;
383
+ letterIdx = 0;
384
+ wordIdx = 0;
385
+ inWord = false;
386
+ for (let li = 0; li < lines.length; li++) {
387
+ const lineText = lines[li];
388
+ const lineSlack = Math.max(0, contentWidth - lineWidths[li]);
389
+ let cursorX = blockLeft + lineSlack * alignFrac;
390
+ const baselineY = contentTop + li * lineBoxHeight + leadingTop + atlas.ascent;
391
+ let prevCh = '';
392
+ for (const ch of lineText) {
393
+ const isSpace = /\s/.test(ch);
394
+ if (isSpace && inWord) {
395
+ wordIdx += 1;
396
+ inWord = false;
397
+ }
398
+ else if (!isSpace) {
399
+ inWord = true;
400
+ }
401
+ const g = atlas.glyphs.get(ch);
402
+ if (!g)
403
+ continue;
404
+ cursorX += atlas.kern(prevCh, ch);
405
+ prevCh = ch;
406
+ if (g.width === 0 || g.height === 0) {
407
+ // Whitespace / zero-ink glyph — nothing to draw, just advance.
408
+ cursorX += g.advance + letterSpacing;
409
+ continue;
410
+ }
411
+ const unitLetterIdx = letterIdx;
412
+ const fx = textAnims
413
+ ? evaluateUnitEffect(textAnims, animLocalTime, letterIdx, wordIdx)
414
+ : null;
415
+ letterIdx += 1;
416
+ if (fx && fx.opacity <= 0) {
417
+ cursorX += g.advance + letterSpacing;
418
+ continue;
419
+ }
420
+ // text-flip (§6.5): conjugate the unit rotations about the unit
421
+ // centers (word OUTSIDE letter) into this glyph's chain. Pivots
422
+ // ride the unit's current dx/dy so the unit stays rigid while it
423
+ // slides + flips.
424
+ let chain = glyphChain;
425
+ if (chain && fx?.flips) {
426
+ const conjugate = (pivot, rot) => {
427
+ if (!pivot || !rot)
428
+ return;
429
+ const [ux, uy] = mapCell(pivot[0] + fx.dx, pivot[1] + fx.dy);
430
+ chain = mat4Multiply(chain, mat4PlaneAt(ux, uy, 0, rot[2], rot[1], rot[0]));
431
+ };
432
+ conjugate(wordPivots?.[wordIdx], fx.flips.word);
433
+ conjugate(letterPivots?.[unitLetterIdx], fx.flips.letter);
434
+ }
435
+ const quadLeft = cursorX + g.offsetX + (fx ? fx.dx : 0);
436
+ const quadTop = baselineY + g.offsetY + (fx ? fx.dy : 0);
437
+ // Scale, then shear, then orbit the glyph's offset from the pivot.
438
+ const sdx = (quadLeft + g.width / 2 - x) * sx;
439
+ const sdy = (quadTop + g.height / 2 - y) * sy;
440
+ const dx = sdx + tanSkewX * sdy;
441
+ const dy = sdy + tanSkewY * sdx;
442
+ let cellCx = x + dx;
443
+ let cellCy = y + dy;
444
+ if (rotateAroundPivot) {
445
+ cellCx = x + dx * rotCos - dy * rotSin;
446
+ cellCy = y + dx * rotSin + dy * rotCos;
447
+ }
448
+ // Pixel-snap glyph quads in the plain axis-aligned case so the
449
+ // atlas texels map 1:1 onto the framebuffer grid — otherwise a glyph
450
+ // landing on a fractional position is bilinear-sampled across two
451
+ // pixels, widening its AA fringe (text reads heavier/softer than
452
+ // Chrome's pixel-snapped DOM text). Skipped when rotated/skewed/
453
+ // scaled/3D or animated, where a fixed grid doesn't apply. Snapping
454
+ // the quad's top-left keeps width integral (atlas bounds are
455
+ // integer), so the right edge lands on the grid too.
456
+ if (!chain && rotation === 0 && skewX === 0 && skewY === 0 && sx === 1 && sy === 1 && !fx) {
457
+ cellCx = Math.round(cellCx - g.width / 2) + g.width / 2;
458
+ cellCy = Math.round(cellCy - g.height / 2) + g.height / 2;
459
+ }
460
+ const u0 = g.x / atlas.width;
461
+ const v0 = g.y / atlas.height;
462
+ const u1 = (g.x + g.width) / atlas.width;
463
+ const v1 = (g.y + g.height) / atlas.height;
464
+ const glyphTint = fx && fx.opacity < 1
465
+ ? [tint[0] * fx.opacity, tint[1] * fx.opacity, tint[2] * fx.opacity, tint[3] * fx.opacity]
466
+ : tint;
467
+ // Per-glyph text shadows, painted under every glyph (pass 0).
468
+ if (drawShadowPass) {
469
+ const sw = g.width * sx, sh = g.height * sy;
470
+ paintTextShadows(textShadows, fx && fx.opacity < 1 ? fx.opacity : 1, (ox, oy, col) => {
471
+ const [scx, scy] = mapCell(quadLeft + ox + g.width / 2, quadTop + oy + g.height / 2);
472
+ backend.drawTexturedQuad({
473
+ cx: scx, cy: scy, width: sw, height: sh, rotation, skewX, skewY,
474
+ transform: chain ? quadWorldTransform(chain, scx, scy, sw, sh, 0, skewX, skewY, null) : undefined,
475
+ texture: atlas.texture, uvRect: [u0, v0, u1, v1], tint: col,
476
+ blend: element.blend_mode, alphaGamma: textAlphaGamma(col),
477
+ });
478
+ });
479
+ }
480
+ if (drawFillPass) {
481
+ backend.drawTexturedQuad({
482
+ cx: cellCx,
483
+ cy: cellCy,
484
+ width: g.width * sx,
485
+ height: g.height * sy,
486
+ rotation,
487
+ skewX,
488
+ skewY,
489
+ transform: chain
490
+ ? quadWorldTransform(chain, cellCx, cellCy, g.width * sx, g.height * sy, 0, skewX, skewY, null)
491
+ : undefined,
492
+ texture: atlas.texture,
493
+ uvRect: [u0, v0, u1, v1],
494
+ tint: glyphTint,
495
+ blend: element.blend_mode,
496
+ alphaGamma: textAlphaGamma(glyphTint),
497
+ });
498
+ }
499
+ cursorX += g.advance + letterSpacing;
500
+ }
501
+ // A line break is a word boundary for text-* word splits.
502
+ if (inWord) {
503
+ wordIdx += 1;
504
+ inWord = false;
505
+ }
506
+ }
507
+ }
508
+ }
509
+ function renderSpannedTextElement(element, ctx) {
510
+ const { canvas, backend } = ctx;
511
+ const elementStart = ctx.timeOffset + numberOr(element.time, 0);
512
+ const _localTime = ctx.time - elementStart;
513
+ const elementFamily = String(element.font_family ?? 'sans-serif');
514
+ const elementWeight = element.font_weight ?? 'normal';
515
+ const elementSize = resolveFontSize(element, ctx);
516
+ const elementColorStr = typeof element.fill_color === 'string' ? element.fill_color : '#ffffff';
517
+ const textAlign = element.text_align ?? 'left';
518
+ // Resolve every span — measure widths and harvest atlases. Hard line
519
+ // breaks (span with text === '\n') start a new line at this stage;
520
+ // soft breaks from word-wrap come in the next pass.
521
+ const lines = [];
522
+ let current = { spans: [], width: 0, ascent: 0, descent: 0 };
523
+ const tt = element.text_transform;
524
+ for (const span of element.spans) {
525
+ if (span.text === '\n') {
526
+ lines.push(current);
527
+ current = { spans: [], width: 0, ascent: 0, descent: 0 };
528
+ continue;
529
+ }
530
+ const inputSpan = tt && tt !== 'none'
531
+ ? { ...span, text: applyTextTransform(span.text, tt) }
532
+ : span;
533
+ const resolved = resolveSpan(inputSpan, {
534
+ family: elementFamily,
535
+ size: elementSize,
536
+ weight: elementWeight,
537
+ color: elementColorStr,
538
+ letterSpacing: numberOr(element.letter_spacing, 0),
539
+ }, ctx);
540
+ current.spans.push(resolved);
541
+ current.width += resolved.width;
542
+ current.ascent = Math.max(current.ascent, resolved.atlas.ascent);
543
+ current.descent = Math.max(current.descent, resolved.atlas.descent);
544
+ }
545
+ lines.push(current);
546
+ // Word-wrap pass: if element.width is set, break any line that
547
+ // exceeds it at the nearest word boundary.
548
+ //
549
+ // Exceptions where we DON'T re-wrap:
550
+ // - Spans contain explicit hard breaks (lines.length > 1) — the
551
+ // importer already captured CSS's exact wrap.
552
+ // - Any span carries layout-relevant styling (nowrap, background,
553
+ // fill_color, font_*) — the source was hand-tuned and our atlas
554
+ // measurement disagrees with CSS by a few pixels (no kerning),
555
+ // so re-wrapping turns a clean 1-line "Start your <highlight>"
556
+ // into a 2-line break that overlaps the CTA box below.
557
+ const maxWidth = typeof element.width === 'number'
558
+ ? element.width
559
+ : typeof element.width === 'string'
560
+ ? resolveLength(element.width, canvas.width, canvas, Infinity)
561
+ : Infinity;
562
+ // When the importer emits ANY spans for a text element it has
563
+ // already captured CSS's exact wrap (via Range API). Re-wrapping
564
+ // based on our kerning-free atlas measurement just creates phantom
565
+ // breaks like the "8.1s" case where "s" ends up on its own line
566
+ // because our width measurement of "8.1" + "s" exceeds the box
567
+ // by a few px. So: always trust the input for spanned text.
568
+ const respectAuthorBreaks = true;
569
+ const wrappedLines = [];
570
+ for (const line of lines) {
571
+ if (respectAuthorBreaks || line.width <= maxWidth || !Number.isFinite(maxWidth)) {
572
+ wrappedLines.push(line);
573
+ }
574
+ else {
575
+ wrappedLines.push(...wrapLine(line, maxWidth));
576
+ }
577
+ }
578
+ // Box metrics for anchor math (use post-wrap measurements). Each line
579
+ // takes line_height × font_size of vertical space (matching CSS); the
580
+ // tight glyph block sits centered within that line box.
581
+ const lineHeightRatio = numberOr(element.line_height, 1);
582
+ let totalWidth = 0;
583
+ let totalHeight = 0;
584
+ for (const line of wrappedLines) {
585
+ totalWidth = Math.max(totalWidth, line.width);
586
+ const lineBoxHeight = elementSize * lineHeightRatio;
587
+ totalHeight += lineBoxHeight;
588
+ }
589
+ const localX = applyAnimation(element, 'x', resolveLength(element.x, canvas.width, canvas), ctx);
590
+ const localY = applyAnimation(element, 'y', resolveLength(element.y, canvas.height, canvas), ctx);
591
+ const localRotation = applyAnimation(element, 'rotation', numberOr(element.rotation ?? element.z_rotation, 0), ctx);
592
+ const localOpacity01 = applyAnimation(element, 'opacity', numberOr(element.opacity, 1), ctx);
593
+ // Element scale — applied geometrically around the pivot at draw time
594
+ // (layout stays at natural size; atlases untouched).
595
+ const { sx, sy } = resolveScalePair(element, ctx);
596
+ // Element shear — same treatment as scale (see orbit()).
597
+ const { skewX, skewY } = resolveSkewPair(element, ctx);
598
+ const tanSkewX = Math.tan((skewX * Math.PI) / 180);
599
+ const tanSkewY = Math.tan((skewY * Math.PI) / 180);
600
+ // Apply group transform stack: translate pivot, add ambient rotation,
601
+ // multiply opacity. Glyphs orbit the pivot below. Under 3D the same
602
+ // local-frame + plane-matrix treatment as the plain-text path; an
603
+ // active text-flip forces the matrix path (§6.5).
604
+ const textAnims = compileTextAnimations(element);
605
+ const unitRotations = textAnims !== null && hasUnitRotations(textAnims);
606
+ const t3d = resolve3D(element, ctx);
607
+ const matrixPath = t3d !== null || !ctx.modelMatrix.aff || unitRotations;
608
+ let x, y, rotation;
609
+ let glyphChain = null;
610
+ if (matrixPath) {
611
+ glyphChain = mat4Multiply(ctx.modelMatrix, mat4PlaneAt(localX, localY, t3d?.z ?? 0, localRotation, t3d?.yRot ?? 0, t3d?.xRot ?? 0));
612
+ x = localX;
613
+ y = localY;
614
+ rotation = 0;
615
+ }
616
+ else {
617
+ [x, y] = mat4ApplyToPoint(ctx.modelMatrix, localX, localY);
618
+ rotation = localRotation + mat4Rotation(ctx.modelMatrix);
619
+ }
620
+ const opacity01 = localOpacity01 * ctx.opacityFactor;
621
+ const opacityFactor = Math.max(0, Math.min(1, opacity01));
622
+ const xAnchor = resolveAnchor(element.x_anchor);
623
+ const yAnchor = resolveAnchor(element.y_anchor);
624
+ // Anchor against element.width / element.height when set (matches
625
+ // the importer's box-based layout); fall back to natural text bounds
626
+ // otherwise. vertical_align places the text block inside the box.
627
+ // Padding insets the content; percent alignments override the named
628
+ // align fields when present (same rules as the plain-text path).
629
+ const xPad = resolveLength(element.x_padding, canvas.width, canvas, 0);
630
+ const yPad = resolveLength(element.y_padding, canvas.height, canvas, 0);
631
+ const blockBoxWidth = Number.isFinite(maxWidth) ? maxWidth : totalWidth + 2 * xPad;
632
+ const contentWidth = Math.max(0, blockBoxWidth - 2 * xPad);
633
+ const alignFrac = alignmentFraction(element.x_alignment, textAlign === 'center' ? 0.5 : textAlign === 'right' ? 1 : 0);
634
+ let blockBoxHeight = totalHeight + 2 * yPad;
635
+ if (typeof element.height === 'number')
636
+ blockBoxHeight = element.height;
637
+ else if (typeof element.height === 'string') {
638
+ blockBoxHeight = resolveLength(element.height, canvas.height, canvas, blockBoxHeight);
639
+ }
640
+ const verticalAlign = element.vertical_align ?? 'top';
641
+ const vFrac = alignmentFraction(element.y_alignment, verticalAlign === 'middle' ? 0.5 : verticalAlign === 'bottom' ? 1 : 0);
642
+ const vSlack = Math.max(0, blockBoxHeight - 2 * yPad - totalHeight);
643
+ const blockLeft = x - blockBoxWidth * xAnchor + xPad;
644
+ const blockTop = y - blockBoxHeight * yAnchor + yPad + vSlack * vFrac;
645
+ // Block-level rotation: orbit each glyph (and bg rect) around the
646
+ // pivot (x, y) by the cumulative rotation. Same approach as the
647
+ // single-text path; matches CSS rotate() with transform-origin: center.
648
+ const rotRad = (rotation * Math.PI) / 180;
649
+ const rotCos = Math.cos(rotRad);
650
+ const rotSin = Math.sin(rotRad);
651
+ const rotateAroundPivot = Math.abs(rotation) > 0.01;
652
+ // Scale, then shear, then orbit the point's offset from the pivot.
653
+ function orbit(px, py) {
654
+ const sdx = (px - x) * sx;
655
+ const sdy = (py - y) * sy;
656
+ const dx = sdx + tanSkewX * sdy;
657
+ const dy = sdy + tanSkewY * sdx;
658
+ if (!rotateAroundPivot)
659
+ return [x + dx, y + dy];
660
+ return [x + dx * rotCos - dy * rotSin, y + dx * rotSin + dy * rotCos];
661
+ }
662
+ // Per-unit text animations — indices run continuously across spans
663
+ // and lines so a multi-line headline staggers as one sequence.
664
+ // (Compiled above for the matrix-path decision.)
665
+ const animLocalTime = ctx.time - (ctx.timeOffset + numberOr(element.time, 0));
666
+ // text-flip pivot pre-pass (§6.5) — REST-layout unit centers,
667
+ // mirroring the draw walk below.
668
+ let letterPivots = null;
669
+ let wordPivots = null;
670
+ if (unitRotations) {
671
+ letterPivots = [];
672
+ const wordBoxes = [];
673
+ let pl = 0, pw = 0, pInWord = false;
674
+ let preLineTop = blockTop;
675
+ for (const line of wrappedLines) {
676
+ const glyphBlock = line.ascent + line.descent;
677
+ const lineBoxHeight = elementSize * lineHeightRatio;
678
+ const baselineY = preLineTop + (lineBoxHeight - glyphBlock) / 2 + line.ascent;
679
+ let cursorX = blockLeft + Math.max(0, contentWidth - line.width) * alignFrac;
680
+ for (const sp of line.spans) {
681
+ let pPrev = '';
682
+ for (const ch of sp.text) {
683
+ const isSpace = /\s/.test(ch);
684
+ if (isSpace && pInWord) {
685
+ pw += 1;
686
+ pInWord = false;
687
+ }
688
+ else if (!isSpace)
689
+ pInWord = true;
690
+ const g = sp.atlas.glyphs.get(ch);
691
+ if (!g)
692
+ continue;
693
+ cursorX += sp.atlas.kern(pPrev, ch);
694
+ pPrev = ch;
695
+ if (g.width === 0 || g.height === 0) {
696
+ cursorX += g.advance + sp.letterSpacing;
697
+ continue;
698
+ }
699
+ const l = cursorX + g.offsetX;
700
+ const t = baselineY + g.offsetY;
701
+ letterPivots[pl] = [l + g.width / 2, t + g.height / 2];
702
+ const wb = (wordBoxes[pw] ??= { l: Infinity, r: -Infinity, t: Infinity, b: -Infinity });
703
+ wb.l = Math.min(wb.l, l);
704
+ wb.r = Math.max(wb.r, l + g.width);
705
+ wb.t = Math.min(wb.t, t);
706
+ wb.b = Math.max(wb.b, t + g.height);
707
+ pl += 1;
708
+ cursorX += g.advance + sp.letterSpacing;
709
+ }
710
+ }
711
+ preLineTop += lineBoxHeight;
712
+ }
713
+ wordPivots = wordBoxes.map((b) => [(b.l + b.r) / 2, (b.t + b.b) / 2]);
714
+ }
715
+ let letterIdx = 0;
716
+ let wordIdx = 0;
717
+ let inWord = false;
718
+ // Shrink-wrapped background — ONE band PER LINE, each hugging that
719
+ // line's glyphs (same rule as the plain-text path). Opt-in; absent ⇒
720
+ // byte-identical.
721
+ if (typeof element.background_color === 'string' && totalWidth > 0 && wrappedLines.length > 0) {
722
+ const c = parseColorPremultiplied(element.background_color);
723
+ const bgC = [c[0] * opacityFactor, c[1] * opacityFactor, c[2] * opacityFactor, c[3] * opacityFactor];
724
+ const [padX, padY] = resolveBgPadding(element.background_padding);
725
+ const cr = numberOr(element.background_border_radius, 0);
726
+ const lbh = elementSize * lineHeightRatio;
727
+ let preTop = blockTop;
728
+ for (const line of wrappedLines) {
729
+ const lw = line.width;
730
+ if (lw > 0) {
731
+ const gblk = line.ascent + line.descent;
732
+ const lineLeft = blockLeft + Math.max(0, contentWidth - lw) * alignFrac;
733
+ const cxLocal = lineLeft + lw / 2;
734
+ const cyLocal = preTop + (lbh - gblk) / 2 + gblk / 2;
735
+ const [bgCx, bgCy] = orbit(cxLocal, cyLocal);
736
+ const bgW = (lw + 2 * padX) * sx;
737
+ const bgH = (gblk + 2 * padY) * sy;
738
+ backend.drawShape({
739
+ cx: bgCx, cy: bgCy, width: bgW, height: bgH, rotation, skewX, skewY,
740
+ transform: glyphChain
741
+ ? quadWorldTransform(glyphChain, bgCx, bgCy, bgW, bgH, 0, skewX, skewY, null)
742
+ : undefined,
743
+ color: bgC,
744
+ cornerRadius: cr,
745
+ shape: 'rectangle',
746
+ blend: element.blend_mode,
747
+ });
748
+ }
749
+ preTop += lbh;
750
+ }
751
+ }
752
+ // Draw each line. Two passes when shadows exist (pass 0 = all glyph
753
+ // shadows behind everything incl. spans, pass 1 = fills); see the
754
+ // plain-text path. No shadows ⇒ one fill pass (byte-identical).
755
+ const textShadows = resolveTextShadows(element.text_shadow, opacityFactor);
756
+ const shadowPasses = textShadows.length > 0 ? 2 : 1;
757
+ for (let pass = 0; pass < shadowPasses; pass++) {
758
+ const drawShadowPass = textShadows.length > 0 && pass === 0;
759
+ const drawFillPass = textShadows.length === 0 || pass === 1;
760
+ letterIdx = 0;
761
+ wordIdx = 0;
762
+ inWord = false;
763
+ let lineTop = blockTop;
764
+ for (const line of wrappedLines) {
765
+ const glyphBlock = line.ascent + line.descent;
766
+ const lineBoxHeight = elementSize * lineHeightRatio;
767
+ const leadingTop = (lineBoxHeight - glyphBlock) / 2;
768
+ const baselineY = lineTop + leadingTop + line.ascent;
769
+ // Per-line alignment within the box bounds.
770
+ const slack = Math.max(0, contentWidth - line.width);
771
+ const lineLeft = blockLeft + slack * alignFrac;
772
+ let cursorX = lineLeft;
773
+ for (const sp of line.spans) {
774
+ // Background fill behind the span. height_ratio shrinks the band
775
+ // inside the line box; inset_y_ratio shifts it down; padding_x
776
+ // extends past the text glyphs on each side; skew_x shears it.
777
+ // Orbits the element pivot for rotated blocks.
778
+ if (sp.background && drawFillPass) {
779
+ const bg = sp.background;
780
+ const bgHeight = Math.max(0, lineBoxHeight * bg.heightRatio);
781
+ const bgTop = lineTop + lineBoxHeight * bg.insetYRatio;
782
+ const bgWidth = sp.width + bg.paddingX * 2;
783
+ const bgCxLocal = cursorX + sp.width / 2;
784
+ const bgCyLocal = bgTop + bgHeight / 2;
785
+ const [bgCx, bgCy] = orbit(bgCxLocal, bgCyLocal);
786
+ backend.drawShape({
787
+ cx: bgCx,
788
+ cy: bgCy,
789
+ width: bgWidth * sx,
790
+ height: bgHeight * sy,
791
+ rotation,
792
+ // Band's own decorative skew composes with the element shear.
793
+ skewX: bg.skewX + skewX,
794
+ skewY,
795
+ transform: glyphChain
796
+ ? quadWorldTransform(glyphChain, bgCx, bgCy, bgWidth * sx, bgHeight * sy, 0, bg.skewX + skewX, skewY, null)
797
+ : undefined,
798
+ color: [
799
+ bg.color[0] * opacityFactor,
800
+ bg.color[1] * opacityFactor,
801
+ bg.color[2] * opacityFactor,
802
+ bg.color[3] * opacityFactor,
803
+ ],
804
+ cornerRadius: bg.borderRadius,
805
+ shape: 'rectangle',
806
+ blend: element.blend_mode,
807
+ });
808
+ }
809
+ // Glyphs.
810
+ const tint = [
811
+ sp.tint[0] * opacityFactor,
812
+ sp.tint[1] * opacityFactor,
813
+ sp.tint[2] * opacityFactor,
814
+ sp.tint[3] * opacityFactor,
815
+ ];
816
+ let prevCh = '';
817
+ for (const ch of sp.text) {
818
+ const isSpace = /\s/.test(ch);
819
+ if (isSpace && inWord) {
820
+ wordIdx += 1;
821
+ inWord = false;
822
+ }
823
+ else if (!isSpace) {
824
+ inWord = true;
825
+ }
826
+ const g = sp.atlas.glyphs.get(ch);
827
+ if (!g)
828
+ continue;
829
+ cursorX += sp.atlas.kern(prevCh, ch);
830
+ prevCh = ch;
831
+ if (g.width === 0 || g.height === 0) {
832
+ cursorX += g.advance + sp.letterSpacing;
833
+ continue;
834
+ }
835
+ const unitLetterIdx = letterIdx;
836
+ const fx = textAnims
837
+ ? evaluateUnitEffect(textAnims, animLocalTime, letterIdx, wordIdx)
838
+ : null;
839
+ letterIdx += 1;
840
+ if (fx && fx.opacity <= 0) {
841
+ cursorX += g.advance + sp.letterSpacing;
842
+ continue;
843
+ }
844
+ // text-flip (§6.5): unit rotations conjugated about the unit
845
+ // centers (word OUTSIDE letter), pivots riding the unit's dx/dy.
846
+ let chain = glyphChain;
847
+ if (chain && fx?.flips) {
848
+ const conjugate = (pivot, rot) => {
849
+ if (!pivot || !rot)
850
+ return;
851
+ const [ux, uy] = orbit(pivot[0] + fx.dx, pivot[1] + fx.dy);
852
+ chain = mat4Multiply(chain, mat4PlaneAt(ux, uy, 0, rot[2], rot[1], rot[0]));
853
+ };
854
+ conjugate(wordPivots?.[wordIdx], fx.flips.word);
855
+ conjugate(letterPivots?.[unitLetterIdx], fx.flips.letter);
856
+ }
857
+ const quadLeft = cursorX + g.offsetX + (fx ? fx.dx : 0);
858
+ const quadTop = baselineY + g.offsetY + (fx ? fx.dy : 0);
859
+ let [cellCx, cellCy] = orbit(quadLeft + g.width / 2, quadTop + g.height / 2);
860
+ // Pixel-snap in the plain axis-aligned case (see plain-text path)
861
+ // so glyphs aren't bilinear-blurred across the framebuffer grid.
862
+ if (!chain && rotation === 0 && skewX === 0 && skewY === 0 && sx === 1 && sy === 1 && !fx) {
863
+ cellCx = Math.round(cellCx - g.width / 2) + g.width / 2;
864
+ cellCy = Math.round(cellCy - g.height / 2) + g.height / 2;
865
+ }
866
+ const u0 = g.x / sp.atlas.width;
867
+ const v0 = g.y / sp.atlas.height;
868
+ const u1 = (g.x + g.width) / sp.atlas.width;
869
+ const v1 = (g.y + g.height) / sp.atlas.height;
870
+ const glyphTint = fx && fx.opacity < 1
871
+ ? [tint[0] * fx.opacity, tint[1] * fx.opacity, tint[2] * fx.opacity, tint[3] * fx.opacity]
872
+ : tint;
873
+ if (drawShadowPass) {
874
+ const sw = g.width * sx, shh = g.height * sy;
875
+ paintTextShadows(textShadows, fx && fx.opacity < 1 ? fx.opacity : 1, (ox, oy, col) => {
876
+ const [scx, scy] = orbit(quadLeft + ox + g.width / 2, quadTop + oy + g.height / 2);
877
+ backend.drawTexturedQuad({
878
+ cx: scx, cy: scy, width: sw, height: shh, rotation, skewX, skewY,
879
+ transform: chain ? quadWorldTransform(chain, scx, scy, sw, shh, 0, skewX, skewY, null) : undefined,
880
+ texture: sp.atlas.texture, uvRect: [u0, v0, u1, v1], tint: col,
881
+ blend: element.blend_mode, alphaGamma: textAlphaGamma(col),
882
+ });
883
+ });
884
+ }
885
+ if (drawFillPass) {
886
+ backend.drawTexturedQuad({
887
+ cx: cellCx,
888
+ cy: cellCy,
889
+ width: g.width * sx,
890
+ height: g.height * sy,
891
+ rotation,
892
+ skewX,
893
+ skewY,
894
+ transform: chain
895
+ ? quadWorldTransform(chain, cellCx, cellCy, g.width * sx, g.height * sy, 0, skewX, skewY, null)
896
+ : undefined,
897
+ texture: sp.atlas.texture,
898
+ uvRect: [u0, v0, u1, v1],
899
+ tint: glyphTint,
900
+ blend: element.blend_mode,
901
+ alphaGamma: textAlphaGamma(glyphTint),
902
+ });
903
+ }
904
+ cursorX += g.advance + sp.letterSpacing;
905
+ }
906
+ }
907
+ lineTop += lineBoxHeight;
908
+ }
909
+ }
910
+ }
911
+ /**
912
+ * Greedy word-wrap a single line into N lines whose widths fit under
913
+ * maxWidth. Splits each span's text on whitespace boundaries; words
914
+ * inherit their original span's atlas + tint + background. A span that
915
+ * straddles a break is represented as two resolved spans on two lines.
916
+ *
917
+ * Doesn't break inside a word — if a single word is wider than
918
+ * maxWidth, it stays on its own line and overflows (matches CSS
919
+ * word-wrap: normal behavior).
920
+ */
921
+ function wrapLine(line, maxWidth) {
922
+ const chunks = [];
923
+ for (const sp of line.spans) {
924
+ // nowrap spans (CSS `white-space: nowrap` / `display: inline-block`)
925
+ // are atomic — emit a single chunk for the whole span so the wrap
926
+ // algorithm treats it as one indivisible word. Without this, a
927
+ // highlighted phrase like "before you leave" would split mid-band.
928
+ if (sp.nowrap) {
929
+ chunks.push({ word: sp.text, resolved: sp, width: sp.width });
930
+ continue;
931
+ }
932
+ // Split on whitespace runs but KEEP the whitespace (so word + space
933
+ // are emitted as separate chunks; the trailing space is dropped if
934
+ // it lands at a line break, which is what we want).
935
+ const parts = sp.text.split(/(\s+)/).filter((p) => p !== '');
936
+ for (const part of parts) {
937
+ let w = 0;
938
+ let prev = '';
939
+ for (const ch of part) {
940
+ const g = sp.atlas.glyphs.get(ch);
941
+ if (g) {
942
+ w += g.advance + sp.letterSpacing + sp.atlas.kern(prev, ch);
943
+ prev = ch;
944
+ }
945
+ }
946
+ chunks.push({ word: part, resolved: sp, width: w });
947
+ }
948
+ }
949
+ const result = [];
950
+ let current = { spans: [], width: 0, ascent: 0, descent: 0 };
951
+ for (const chunk of chunks) {
952
+ const isWhitespace = chunk.word.trim() === '';
953
+ // Wrap point: adding this chunk would overflow, AND the current line
954
+ // isn't empty. (Empty line + huge word still emits the word; we let
955
+ // it overflow rather than infinite-loop.)
956
+ if (current.width + chunk.width > maxWidth && current.spans.length > 0) {
957
+ result.push(current);
958
+ current = { spans: [], width: 0, ascent: 0, descent: 0 };
959
+ // Drop a whitespace chunk that lands at the start of a new line.
960
+ if (isWhitespace)
961
+ continue;
962
+ }
963
+ current.spans.push({
964
+ ...chunk.resolved,
965
+ text: chunk.word,
966
+ width: chunk.width,
967
+ });
968
+ current.width += chunk.width;
969
+ current.ascent = Math.max(current.ascent, chunk.resolved.atlas.ascent);
970
+ current.descent = Math.max(current.descent, chunk.resolved.atlas.descent);
971
+ }
972
+ if (current.spans.length > 0)
973
+ result.push(current);
974
+ return result;
975
+ }
976
+ function resolveSpan(span, defaults, ctx) {
977
+ const family = span.font_family ?? defaults.family;
978
+ const weight = span.font_weight ?? defaults.weight;
979
+ let size;
980
+ if (typeof span.font_size === 'number')
981
+ size = span.font_size;
982
+ else if (typeof span.font_size === 'string') {
983
+ size = resolveLength(span.font_size, ctx.canvas.height, ctx.canvas, defaults.size);
984
+ }
985
+ else {
986
+ size = defaults.size;
987
+ }
988
+ const key = atlasKey({ family, size, weight });
989
+ let atlas = ctx.fontAtlases.get(key);
990
+ if (!atlas) {
991
+ atlas = generateFontAtlas({ family, size, weight }, ctx.backend);
992
+ ctx.fontAtlases.set(key, atlas);
993
+ }
994
+ const letterSpacing = numberOr(span.letter_spacing, defaults.letterSpacing);
995
+ // Kern within the span only (resets at span boundaries) — the draw
996
+ // walk resets its pair state per span too, so widths stay exact.
997
+ let width = 0;
998
+ let prev = '';
999
+ for (const ch of span.text) {
1000
+ const g = atlas.glyphs.get(ch);
1001
+ if (g) {
1002
+ width += g.advance + letterSpacing + atlas.kern(prev, ch);
1003
+ prev = ch;
1004
+ }
1005
+ }
1006
+ const fillColor = span.fill_color ?? defaults.color;
1007
+ const tint = parseColorPremultiplied(fillColor);
1008
+ // span.background (rich) takes precedence over span.background_color
1009
+ // (shortcut for a flat full-line-box rectangle).
1010
+ let background;
1011
+ if (span.background) {
1012
+ const opacityFactor = span.background.opacity !== undefined
1013
+ ? Math.max(0, Math.min(1, span.background.opacity))
1014
+ : 1;
1015
+ const baseColor = parseColorPremultiplied(span.background.color);
1016
+ background = {
1017
+ color: [
1018
+ baseColor[0] * opacityFactor,
1019
+ baseColor[1] * opacityFactor,
1020
+ baseColor[2] * opacityFactor,
1021
+ baseColor[3] * opacityFactor,
1022
+ ],
1023
+ heightRatio: span.background.height_ratio ?? 1,
1024
+ insetYRatio: span.background.inset_y_ratio ?? 0,
1025
+ paddingX: span.background.padding_x ?? 0,
1026
+ skewX: span.background.skew_x ?? 0,
1027
+ borderRadius: span.background.border_radius ?? 0,
1028
+ };
1029
+ }
1030
+ else if (span.background_color) {
1031
+ background = {
1032
+ color: parseColorPremultiplied(span.background_color),
1033
+ heightRatio: 1,
1034
+ insetYRatio: 0,
1035
+ paddingX: 0,
1036
+ skewX: 0,
1037
+ borderRadius: 0,
1038
+ };
1039
+ }
1040
+ return { text: span.text, atlas, tint, background, width, letterSpacing, nowrap: span.nowrap === true };
1041
+ }
1042
+ function resolveFontSize(element, ctx) {
1043
+ if (element.font_size === 'auto')
1044
+ return 48;
1045
+ if (typeof element.font_size === 'number')
1046
+ return element.font_size;
1047
+ if (typeof element.font_size === 'string') {
1048
+ return resolveLength(element.font_size, ctx.canvas.height, ctx.canvas, 48);
1049
+ }
1050
+ return 48;
1051
+ }
1052
+ function numberOr(value, fallback) {
1053
+ if (typeof value === 'number' && Number.isFinite(value))
1054
+ return value;
1055
+ if (typeof value === 'string') {
1056
+ const n = parseFloat(value);
1057
+ if (Number.isFinite(n))
1058
+ return n;
1059
+ }
1060
+ return fallback;
1061
+ }
1062
+ /** Resolve background_padding (number | [x, y]) to [padX, padY] px. */
1063
+ function resolveBgPadding(p) {
1064
+ if (Array.isArray(p))
1065
+ return [numberOr(p[0], 0), numberOr(p[1], 0)];
1066
+ const v = numberOr(p, 0);
1067
+ return [v, v];
1068
+ }
1069
+ /**
1070
+ * Resolve `text_shadow` (TextShadow | TextShadow[]) to a back-to-front
1071
+ * list. `elementOpacity` (the element's resolved 0..1 opacity) is baked
1072
+ * into each shadow's alpha so shadows fade with the element.
1073
+ */
1074
+ export function resolveTextShadows(raw, elementOpacity = 1) {
1075
+ if (!raw)
1076
+ return [];
1077
+ const arr = Array.isArray(raw) ? raw : [raw];
1078
+ const out = [];
1079
+ for (const s of arr) {
1080
+ if (!s || typeof s !== 'object')
1081
+ continue;
1082
+ const sh = s;
1083
+ if (typeof sh.color !== 'string')
1084
+ continue;
1085
+ const c = parseColorPremultiplied(sh.color);
1086
+ const op = Math.max(0, Math.min(1, numberOr(sh.opacity, 1))) * elementOpacity;
1087
+ out.push({
1088
+ color: [c[0] * op, c[1] * op, c[2] * op, c[3] * op],
1089
+ dx: numberOr(sh.offset_x, 0),
1090
+ dy: numberOr(sh.offset_y, 0),
1091
+ blur: Math.max(0, numberOr(sh.blur, 0)),
1092
+ });
1093
+ }
1094
+ return out;
1095
+ }
1096
+ // Unit-disk Gaussian taps (offset × blurσ, weight; weights sum to 1).
1097
+ // Per-glyph soft shadow without a blur shader: each tap re-draws the
1098
+ // glyph faintly. Two rings + center is enough for a smooth shadow at the
1099
+ // blur radii people use (it's low-frequency, very forgiving).
1100
+ const SHADOW_TAPS = (() => {
1101
+ const raw = [[0, 0, 1]];
1102
+ const rings = [{ r: 0.62, n: 6 }, { r: 1.18, n: 8 }];
1103
+ for (const ring of rings) {
1104
+ for (let i = 0; i < ring.n; i++) {
1105
+ const a = (i / ring.n) * Math.PI * 2 + ring.r;
1106
+ raw.push([Math.cos(a) * ring.r, Math.sin(a) * ring.r, Math.exp(-(ring.r * ring.r) / 0.7)]);
1107
+ }
1108
+ }
1109
+ const total = raw.reduce((s, t) => s + t[2], 0);
1110
+ return raw.map(([x, y, w]) => [x, y, w / total]);
1111
+ })();
1112
+ const scaleRGBA = (c, k) => [c[0] * k, c[1] * k, c[2] * k, c[3] * k];
1113
+ /**
1114
+ * Paint one glyph's text shadows via `drawTinted(localOffsetX, offsetY,
1115
+ * premultipliedColor)` — the caller supplies the per-path quad draw at a
1116
+ * local offset. Hard shadows are one tap; soft shadows spread the tap
1117
+ * kernel by `blur`. `glyphAlpha` fades the shadow with a fading glyph.
1118
+ */
1119
+ export function paintTextShadows(shadows, glyphAlpha, drawTinted) {
1120
+ for (const sh of shadows) {
1121
+ if (sh.blur <= 0.001) {
1122
+ drawTinted(sh.dx, sh.dy, glyphAlpha < 1 ? scaleRGBA(sh.color, glyphAlpha) : sh.color);
1123
+ }
1124
+ else {
1125
+ for (const [tx, ty, w] of SHADOW_TAPS) {
1126
+ drawTinted(sh.dx + tx * sh.blur, sh.dy + ty * sh.blur, scaleRGBA(sh.color, w * glyphAlpha));
1127
+ }
1128
+ }
1129
+ }
1130
+ }
1131
+ // ────────────────────────────────────────────────────────────────────────────
1132
+ // Masked-text rendering path
1133
+ // ────────────────────────────────────────────────────────────────────────────
1134
+ //
1135
+ // For elements with a `mask` field, we bypass the font atlas and render the
1136
+ // text into an OffscreenCanvas with Canvas2D's fillText, then apply a
1137
+ // linear-gradient alpha mask via destination-in compositing, then upload
1138
+ // as a single texture and draw one textured quad.
1139
+ //
1140
+ // Cost: O(text length × glyph rasterization) per frame. Fine for short
1141
+ // title text; would be wasteful for long copy.
1142
+ // 2× supersampling — keeps text crisp when the textured quad is scaled up
1143
+ // by the backend.
1144
+ const TEXT_SUPERSAMPLE = 2;
1145
+ function renderMaskedTextElement(element, ctx) {
1146
+ const { canvas, backend, maskedTexts } = ctx;
1147
+ const elementStart = ctx.timeOffset + numberOr(element.time, 0);
1148
+ const localTime = ctx.time - elementStart;
1149
+ const text = resolveText(element, localTime);
1150
+ if (text.length === 0)
1151
+ return;
1152
+ const fontFamily = withFontFallback(String(element.font_family ?? 'sans-serif'));
1153
+ const fontWeight = String(element.font_weight ?? 'normal');
1154
+ const fontStyle = element.font_style === 'italic' ? 'italic' : 'normal';
1155
+ // Resolve font size (number / "auto" / unit string).
1156
+ let fontSize;
1157
+ if (element.font_size === 'auto') {
1158
+ const constraintWidth = (element.width !== undefined
1159
+ ? resolveLength(element.width, canvas.width, canvas, canvas.width)
1160
+ : canvas.width) - 2 * resolveLength(element.x_padding, canvas.width, canvas, 0);
1161
+ const autoMin = resolveLength(element.font_size_minimum, canvas.height, canvas, 8);
1162
+ const autoMax = resolveLength(element.font_size_maximum, canvas.height, canvas, 400);
1163
+ fontSize = autoFitFontSize(text, fontFamily, fontWeight, Math.max(1, constraintWidth), 48, autoMin, autoMax);
1164
+ }
1165
+ else if (typeof element.font_size === 'number') {
1166
+ fontSize = element.font_size;
1167
+ }
1168
+ else if (typeof element.font_size === 'string') {
1169
+ fontSize = resolveLength(element.font_size, canvas.height, canvas, 48);
1170
+ }
1171
+ else {
1172
+ fontSize = 48;
1173
+ }
1174
+ // Measure text once with a probe context. We need both the bounding box
1175
+ // and the actual ascent/descent so the canvas is sized correctly.
1176
+ const probe = getProbeContext();
1177
+ const fontSpec = `${fontStyle} ${fontWeight} ${fontSize * TEXT_SUPERSAMPLE}px ${fontFamily}`;
1178
+ probe.font = fontSpec;
1179
+ const metrics = probe.measureText(text);
1180
+ const ascent = metrics.actualBoundingBoxAscent;
1181
+ const descent = metrics.actualBoundingBoxDescent;
1182
+ const textW = Math.max(1, Math.ceil(metrics.width));
1183
+ const textH = Math.max(1, Math.ceil(ascent + descent));
1184
+ // Add a small padding to avoid clipping AA fringes at the edges.
1185
+ const PAD = Math.max(4, Math.ceil(fontSize * TEXT_SUPERSAMPLE * 0.1));
1186
+ const canvasW = textW + PAD * 2;
1187
+ const canvasH = textH + PAD * 2;
1188
+ // Display-space sizes (un-supersampled).
1189
+ const displayW = canvasW / TEXT_SUPERSAMPLE;
1190
+ const displayH = canvasH / TEXT_SUPERSAMPLE;
1191
+ // Get-or-create the cached OffscreenCanvas + Texture. Re-allocate if the
1192
+ // required dimensions changed (font_size, text content, or DPI shift).
1193
+ const cacheKey = typeof element.id === 'string' ? element.id : `__masked_text_${text}`;
1194
+ let asset = maskedTexts.get(cacheKey);
1195
+ if (!asset || asset.canvas.width !== canvasW || asset.canvas.height !== canvasH) {
1196
+ const off = new OffscreenCanvas(canvasW, canvasH);
1197
+ const offCtx = off.getContext('2d');
1198
+ if (!offCtx)
1199
+ return;
1200
+ const texture = backend.createTexture(off);
1201
+ asset = { canvas: off, ctx: offCtx, texture };
1202
+ maskedTexts.set(cacheKey, asset);
1203
+ }
1204
+ // ── Pass 1: draw the text ─────────────────────────────────────────────
1205
+ const offCtx = asset.ctx;
1206
+ offCtx.save();
1207
+ offCtx.setTransform(1, 0, 0, 1, 0, 0);
1208
+ offCtx.clearRect(0, 0, canvasW, canvasH);
1209
+ offCtx.font = fontSpec;
1210
+ offCtx.fillStyle = typeof element.fill_color === 'string' ? element.fill_color : '#ffffff';
1211
+ offCtx.textBaseline = 'alphabetic';
1212
+ offCtx.textAlign = 'left';
1213
+ offCtx.fillText(text, PAD, PAD + ascent);
1214
+ offCtx.restore();
1215
+ // ── Pass 2: apply the mask ────────────────────────────────────────────
1216
+ const mask = element.mask;
1217
+ const progress = clamp01(resolveMaskProgress(mask.progress, localTime));
1218
+ applyLinearWipeMask(offCtx, canvasW, canvasH, mask.angle ?? -45, progress, mask.softness ?? 0.3);
1219
+ // ── Pass 3: upload + draw ─────────────────────────────────────────────
1220
+ backend.updateTexture(asset.texture, asset.canvas);
1221
+ // Element transform — placement uses display-space dimensions.
1222
+ const x = applyAnimation(element, 'x', resolveLength(element.x, canvas.width, canvas), ctx);
1223
+ const y = applyAnimation(element, 'y', resolveLength(element.y, canvas.height, canvas), ctx);
1224
+ const rotation = applyAnimation(element, 'rotation', numberOr(element.rotation ?? element.z_rotation, 0), ctx);
1225
+ const opacity01 = applyAnimation(element, 'opacity', numberOr(element.opacity, 1), ctx);
1226
+ const xAnchor = resolveAnchor(element.x_anchor);
1227
+ const yAnchor = resolveAnchor(element.y_anchor);
1228
+ // Quad center: place top-left at (x - displayW * xAnchor, y - displayH * yAnchor),
1229
+ // then offset by half display size to get the center.
1230
+ const left = x - displayW * xAnchor;
1231
+ const top = y - displayH * yAnchor;
1232
+ const cx = left + displayW / 2;
1233
+ const cy = top + displayH / 2;
1234
+ const opacity = clamp01(opacity01);
1235
+ if (opacity <= 0)
1236
+ return;
1237
+ // CKP/1.0 3D (§4.4): masked text is a single flattened quad — the
1238
+ // same block-level matrix hand-off as svg.
1239
+ const t3d = resolve3D(element, ctx);
1240
+ const matrixPath = t3d !== null || !ctx.modelMatrix.aff;
1241
+ backend.drawTexturedQuad({
1242
+ cx,
1243
+ cy,
1244
+ width: displayW,
1245
+ height: displayH,
1246
+ rotation,
1247
+ transform: matrixPath
1248
+ ? quadWorldTransform(ctx.modelMatrix, cx, cy, displayW, displayH, rotation, 0, 0, t3d)
1249
+ : undefined,
1250
+ texture: asset.texture,
1251
+ tint: [opacity, opacity, opacity, opacity],
1252
+ blend: element.blend_mode,
1253
+ });
1254
+ }
1255
+ function resolveMaskProgress(value, localTime) {
1256
+ if (value === undefined)
1257
+ return 1;
1258
+ if (typeof value === 'number')
1259
+ return value;
1260
+ if (isExpr(value))
1261
+ return evalExpr(value, { t: localTime, dur: 0, i: 0, n: 1, value: 1 });
1262
+ if (Array.isArray(value))
1263
+ return interpolateKeyframes(value, localTime);
1264
+ return 1;
1265
+ }
1266
+ /**
1267
+ * Apply a linear-gradient alpha mask. The gradient line passes through the
1268
+ * canvas center at `angleDeg` (CSS convention: 0° = top, clockwise). As
1269
+ * `progress` goes 0 → 1, the opaque-stop sweeps across, revealing the
1270
+ * underlying pixels. `softness` controls the width of the wipe edge.
1271
+ *
1272
+ * Uses `globalCompositeOperation = 'destination-in'`: existing pixels are
1273
+ * multiplied by the gradient's alpha, with transparent regions of the
1274
+ * gradient erasing the underlying text.
1275
+ */
1276
+ function applyLinearWipeMask(ctx, width, height, angleDeg, progress, softness) {
1277
+ // CSS angle θ: 0° = "to top" (gradient direction = (0, -1)); +90° = "to right".
1278
+ // Convert to a unit vector.
1279
+ const theta = (angleDeg * Math.PI) / 180;
1280
+ const dx = Math.sin(theta);
1281
+ const dy = -Math.cos(theta);
1282
+ // Projection of the canvas's diagonal onto the gradient direction — this
1283
+ // is the gradient line length per the CSS spec.
1284
+ const lineLen = Math.abs(width * dx) + Math.abs(height * dy);
1285
+ if (lineLen === 0)
1286
+ return;
1287
+ const ccx = width / 2;
1288
+ const ccy = height / 2;
1289
+ const x0 = ccx - (lineLen / 2) * dx;
1290
+ const y0 = ccy - (lineLen / 2) * dy;
1291
+ const x1 = ccx + (lineLen / 2) * dx;
1292
+ const y1 = ccy + (lineLen / 2) * dy;
1293
+ // Stops in [0, 1]:
1294
+ // - rightStop = (1 - progress) * (1 + softness)
1295
+ // - leftStop = max(0, rightStop - softness)
1296
+ // Maps so that progress=0 → fully transparent, progress=1 → fully opaque,
1297
+ // and intermediate values produce a soft wipe edge of width `softness`.
1298
+ const soft = clamp01(softness);
1299
+ const rightStop = (1 - progress) * (1 + soft);
1300
+ const leftStop = Math.max(0, rightStop - soft);
1301
+ const lClamp = clamp01(leftStop);
1302
+ const rClamp = Math.max(lClamp, clamp01(rightStop));
1303
+ const grad = ctx.createLinearGradient(x0, y0, x1, y1);
1304
+ grad.addColorStop(0, 'rgba(0,0,0,0)');
1305
+ grad.addColorStop(lClamp, 'rgba(0,0,0,0)');
1306
+ grad.addColorStop(rClamp, 'rgba(0,0,0,1)');
1307
+ grad.addColorStop(1, 'rgba(0,0,0,1)');
1308
+ ctx.save();
1309
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
1310
+ ctx.globalCompositeOperation = 'destination-in';
1311
+ ctx.fillStyle = grad;
1312
+ ctx.fillRect(0, 0, width, height);
1313
+ ctx.globalCompositeOperation = 'source-over';
1314
+ ctx.restore();
1315
+ }
1316
+ // Lazy-initialized 1×1 OffscreenCanvas just for text measurement.
1317
+ let _probeCanvas = null;
1318
+ let _probeCtx = null;
1319
+ function getProbeContext() {
1320
+ if (!_probeCtx) {
1321
+ _probeCanvas = new OffscreenCanvas(1, 1);
1322
+ _probeCtx = _probeCanvas.getContext('2d');
1323
+ }
1324
+ return _probeCtx;
1325
+ }
1326
+ function clamp01(n) {
1327
+ return n < 0 ? 0 : n > 1 ? 1 : n;
1328
+ }
1329
+ /** Resolve the rendered text for an element. */
1330
+ function resolveText(element, _localTime) {
1331
+ return applyTextTransform(String(element.text ?? ''), element.text_transform);
1332
+ }
1333
+ /** Case transform applied before layout (text_transform). */
1334
+ function applyTextTransform(text, transform) {
1335
+ switch (transform) {
1336
+ case 'uppercase': return text.toUpperCase();
1337
+ case 'lowercase': return text.toLowerCase();
1338
+ case 'capitalize':
1339
+ return text.replace(/(^|\s)(\S)/g, (_, pre, ch) => pre + ch.toUpperCase());
1340
+ default: return text;
1341
+ }
1342
+ }
1343
+ /**
1344
+ * Percentage-based content alignment: "0%" → 0, "50%" → 0.5, numbers
1345
+ * are fractions. Falls back when absent/unparseable.
1346
+ */
1347
+ function alignmentFraction(v, fallback) {
1348
+ if (typeof v === 'number' && Number.isFinite(v))
1349
+ return Math.max(0, Math.min(1, v));
1350
+ if (typeof v === 'string') {
1351
+ const n = parseFloat(v);
1352
+ if (Number.isFinite(n)) {
1353
+ return Math.max(0, Math.min(1, v.trim().endsWith('%') ? n / 100 : n));
1354
+ }
1355
+ }
1356
+ return fallback;
1357
+ }
1358
+ //# sourceMappingURL=text.js.map