capture-flow 0.0.0-stage → 0.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 (104) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +238 -2
  3. package/bin/capture-flow.mjs +12 -0
  4. package/package.json +55 -4
  5. package/src/build.ts +186 -0
  6. package/src/capture.ts +77 -0
  7. package/src/cli.ts +139 -0
  8. package/src/index.ts +11 -0
  9. package/src/manifest.ts +98 -0
  10. package/src/render.ts +75 -0
  11. package/src/verify.ts +90 -0
  12. package/vendor/wajha/LICENSE +21 -0
  13. package/vendor/wajha/NOTICE +21 -0
  14. package/vendor/wajha/engine/background.ts +55 -0
  15. package/vendor/wajha/engine/bidi.ts +589 -0
  16. package/vendor/wajha/engine/cache.ts +96 -0
  17. package/vendor/wajha/engine/color.ts +125 -0
  18. package/vendor/wajha/engine/devices/android.ts +53 -0
  19. package/vendor/wajha/engine/devices/browser.ts +170 -0
  20. package/vendor/wajha/engine/devices/finish.ts +154 -0
  21. package/vendor/wajha/engine/devices/geometry.ts +326 -0
  22. package/vendor/wajha/engine/devices/index.ts +143 -0
  23. package/vendor/wajha/engine/devices/ipad.ts +21 -0
  24. package/vendor/wajha/engine/devices/iphone.ts +55 -0
  25. package/vendor/wajha/engine/devices/macbook.ts +187 -0
  26. package/vendor/wajha/engine/devices/mock/apps/calendar.ts +145 -0
  27. package/vendor/wajha/engine/devices/mock/apps/chat.ts +213 -0
  28. package/vendor/wajha/engine/devices/mock/apps/finance.ts +164 -0
  29. package/vendor/wajha/engine/devices/mock/apps/fitness.ts +145 -0
  30. package/vendor/wajha/engine/devices/mock/apps/index.ts +14 -0
  31. package/vendor/wajha/engine/devices/mock/apps/meditation.ts +167 -0
  32. package/vendor/wajha/engine/devices/mock/apps/music.ts +152 -0
  33. package/vendor/wajha/engine/devices/mock/apps/shop.ts +116 -0
  34. package/vendor/wajha/engine/devices/mock/apps/social.ts +152 -0
  35. package/vendor/wajha/engine/devices/mock/apps/tasks.ts +131 -0
  36. package/vendor/wajha/engine/devices/mock/apps/travel.ts +121 -0
  37. package/vendor/wajha/engine/devices/mock/art.ts +557 -0
  38. package/vendor/wajha/engine/devices/mock/chrome.ts +337 -0
  39. package/vendor/wajha/engine/devices/mock/icons.ts +614 -0
  40. package/vendor/wajha/engine/devices/mock/kit.ts +213 -0
  41. package/vendor/wajha/engine/devices/mock/rng.ts +40 -0
  42. package/vendor/wajha/engine/devices/mock/theme.ts +133 -0
  43. package/vendor/wajha/engine/devices/mock/widgets.ts +231 -0
  44. package/vendor/wajha/engine/devices/paths.ts +113 -0
  45. package/vendor/wajha/engine/devices/screen.ts +75 -0
  46. package/vendor/wajha/engine/devices/shadow.ts +124 -0
  47. package/vendor/wajha/engine/devices/slab.ts +200 -0
  48. package/vendor/wajha/engine/devices/vision.ts +43 -0
  49. package/vendor/wajha/engine/devices/watch.ts +14 -0
  50. package/vendor/wajha/engine/geometry.ts +152 -0
  51. package/vendor/wajha/engine/images.ts +96 -0
  52. package/vendor/wajha/engine/index.ts +20 -0
  53. package/vendor/wajha/engine/linebreak.ts +226 -0
  54. package/vendor/wajha/engine/markup.ts +84 -0
  55. package/vendor/wajha/engine/mock.ts +158 -0
  56. package/vendor/wajha/engine/paint.ts +474 -0
  57. package/vendor/wajha/engine/patterns.ts +263 -0
  58. package/vendor/wajha/engine/render.ts +364 -0
  59. package/vendor/wajha/engine/shapes.ts +260 -0
  60. package/vendor/wajha/engine/testing/canvasNode.ts +121 -0
  61. package/vendor/wajha/engine/testing/fakeDevices.ts +85 -0
  62. package/vendor/wajha/engine/text.ts +694 -0
  63. package/vendor/wajha/engine/types.ts +190 -0
  64. package/vendor/wajha/export/paths.ts +197 -0
  65. package/vendor/wajha/export/png.ts +223 -0
  66. package/vendor/wajha/export/types.ts +60 -0
  67. package/vendor/wajha/model/adapt.ts +889 -0
  68. package/vendor/wajha/model/catalog.ts +369 -0
  69. package/vendor/wajha/model/clone.ts +58 -0
  70. package/vendor/wajha/model/colorMath.ts +128 -0
  71. package/vendor/wajha/model/defaults.ts +386 -0
  72. package/vendor/wajha/model/geometry.ts +95 -0
  73. package/vendor/wajha/model/ids.ts +13 -0
  74. package/vendor/wajha/model/instantiate.ts +280 -0
  75. package/vendor/wajha/model/mirror.ts +43 -0
  76. package/vendor/wajha/model/resolve.ts +87 -0
  77. package/vendor/wajha/model/rules.ts +249 -0
  78. package/vendor/wajha/model/stub.ts +4 -0
  79. package/vendor/wajha/model/types.ts +456 -0
  80. package/vendor/wajha/model/typography.ts +28 -0
  81. package/vendor/wajha/model/validate.ts +917 -0
  82. package/vendor/wajha/templates/builtin/aurora.ts +163 -0
  83. package/vendor/wajha/templates/builtin/bubblegum.ts +132 -0
  84. package/vendor/wajha/templates/builtin/citrusPop.ts +124 -0
  85. package/vendor/wajha/templates/builtin/clayGarden.ts +114 -0
  86. package/vendor/wajha/templates/builtin/desertDune.ts +97 -0
  87. package/vendor/wajha/templates/builtin/glassPrism.ts +139 -0
  88. package/vendor/wajha/templates/builtin/launchpad.ts +140 -0
  89. package/vendor/wajha/templates/builtin/loudAndClear.ts +109 -0
  90. package/vendor/wajha/templates/builtin/midnightNeon.ts +108 -0
  91. package/vendor/wajha/templates/builtin/monoSwiss.ts +149 -0
  92. package/vendor/wajha/templates/builtin/noirGold.ts +149 -0
  93. package/vendor/wajha/templates/builtin/noor.ts +107 -0
  94. package/vendor/wajha/templates/builtin/obsidian.ts +137 -0
  95. package/vendor/wajha/templates/builtin/panoramaPeaks.ts +145 -0
  96. package/vendor/wajha/templates/builtin/panoramaRibbon.ts +138 -0
  97. package/vendor/wajha/templates/builtin/panoramaWaves.ts +138 -0
  98. package/vendor/wajha/templates/builtin/paperAndInk.ts +98 -0
  99. package/vendor/wajha/templates/builtin/sage.ts +94 -0
  100. package/vendor/wajha/templates/builtin/splitTone.ts +129 -0
  101. package/vendor/wajha/templates/builtin/sunsetBoulevard.ts +127 -0
  102. package/vendor/wajha/templates/index.ts +55 -0
  103. package/vendor/wajha/templates/kit.ts +761 -0
  104. package/vendor/wajha/ui/sortShots.ts +239 -0
@@ -0,0 +1,889 @@
1
+ /**
2
+ * Converts a layout designed for one canvas shape into another (for example
3
+ * an iPhone layout into an iPad or Mac layout). Pure function, no canvas.
4
+ * Owned by the model/store work package.
5
+ *
6
+ * Three arrangements, chosen by canvas shape:
7
+ * - relative (portrait <-> portrait, wide <-> wide): positions stay
8
+ * relative and lengths scale with u. Copy keeps its internal spacing in u
9
+ * (font sizes are in u, so lines never collide) and devices re-fit into
10
+ * the space between the copy and the canvas edges.
11
+ * - to wide (portrait -> Mac / feature graphic, with a device): the copy
12
+ * becomes a centered column on the left half, the devices move to the
13
+ * right half.
14
+ * - from wide (with a device): the copy stacks at the top, devices below.
15
+ * Shapes and images keep their relative position and u size, except in the
16
+ * last two arrangements: decorations centred on the devices (a ring, disc
17
+ * or glow behind a phone) move and scale with them and stay clear of the
18
+ * copy, so they never end up across the text.
19
+ *
20
+ * Device heights are estimates (see geometry.ts). A device that was fully
21
+ * visible stays fully visible with ~6 % margins; one that bled off the bottom
22
+ * keeps the same share of itself hidden below the edge.
23
+ */
24
+ import { FAMILIES } from './catalog';
25
+ import { deepClone } from './clone';
26
+ import { canvasMetrics, deviceHeightFactor, familyForCanvas, isFamilyDefaultFrame, type CanvasMetrics } from './geometry';
27
+ import { RANGES, clampToRange, type Range } from './rules';
28
+ import type { DeviceElement, FamilyId, SlideElement, SlideLayout, TextElement } from './types';
29
+
30
+ export interface CanvasShape {
31
+ width: number;
32
+ height: number;
33
+ }
34
+
35
+ /** Free space kept around fitted devices, fraction of the canvas. */
36
+ const MARGIN = 0.06;
37
+ /** Text boxes never get wider than this fraction of the canvas width. */
38
+ export const TEXT_MAX_WIDTH = 0.92;
39
+ /** Tolerance for "touches the edge" tests, fraction of the canvas. */
40
+ const EPS = 0.002;
41
+ /** Texts closer than this (u) belong to one copy group. */
42
+ const JOIN_GAP_U = 0.15;
43
+ /** Copy closer to an edge than NEAR_EDGE_U (u) keeps its u margin; beyond FAR_EDGE_U its relative position. */
44
+ const NEAR_EDGE_U = 0.15;
45
+ const FAR_EDGE_U = 0.3;
46
+ /** Gap between stacked copy groups (u). */
47
+ const STACK_GAP_U = 0.05;
48
+ /** Device scale limits relative to the u-scaled size. */
49
+ const K_MIN = 0.15;
50
+ const K_MAX = 3;
51
+ /** A decoration belongs to the device group when its centre lies in the group's box grown by this share of its size. */
52
+ const GROUP_REACH = 0.1;
53
+ /** Decorations wider or taller than this many canvas sides are backdrops (dunes, swells) and never move with devices. */
54
+ const BACKDROP = 1.5;
55
+ /** Clear space kept between the copy and the decorations that moved with the devices (u). */
56
+ const DECOR_GAP_U = 0.03;
57
+ /** Those decorations shrink towards the devices to clear the copy, but never below this share. */
58
+ const DECOR_MIN_SCALE = 0.5;
59
+ /** Shapes blurred by at least this share of their size are soft light, which may sit behind text. */
60
+ const SOFT_BLUR = 0.1;
61
+ /** The side-by-side copy column keeps at least this share of the canvas height when it avoids edge decorations. */
62
+ const MIN_COPY_BAND = 0.4;
63
+ /** A decoration belongs to a copy group when it sits within this distance (u) of the group's box. */
64
+ const COPY_REACH_U = 0.06;
65
+
66
+ const WIDE_COPY = { x: 0.28, w: 0.44 };
67
+ const WIDE_DEVICES = { x: 0.74, w: 0.42, bleedTop: 0.12 };
68
+ const TALL_COPY = { top: 0.11, w: 0.86, gap: 0.07 };
69
+
70
+ interface Ctx {
71
+ F: CanvasMetrics;
72
+ T: CanvasMetrics;
73
+ /** Default screen aspect of the source and target families. */
74
+ srcAspect: number;
75
+ dstAspect: number;
76
+ }
77
+
78
+ /** Device region for the side-by-side and stacked arrangements, fractions of the canvas. */
79
+ interface DeviceRegion {
80
+ cx: number;
81
+ maxW: number;
82
+ top: number;
83
+ bottom: number;
84
+ /** Where a bleeding device group starts. */
85
+ bleedTop: number;
86
+ }
87
+
88
+ /** Copy group shift, fractions of the canvas height. */
89
+ interface GroupShift {
90
+ srcTop: number;
91
+ srcBottom: number;
92
+ dstTop: number;
93
+ dstBottom: number;
94
+ }
95
+
96
+ interface TextGroup {
97
+ members: number[];
98
+ /** Estimated extents in u from the top of the source canvas. */
99
+ top: number;
100
+ bottom: number;
101
+ }
102
+
103
+ /** An axis-aligned box in u. */
104
+ interface Box {
105
+ left: number;
106
+ right: number;
107
+ top: number;
108
+ bottom: number;
109
+ }
110
+
111
+ /**
112
+ * How placeDeviceGroup moved the devices, so decorations can follow: the
113
+ * group's box before (source u, source frames) and after (target u, target
114
+ * frames), and the scale applied to the devices.
115
+ */
116
+ interface GroupMove {
117
+ from: Box;
118
+ to: Box;
119
+ scale: number;
120
+ }
121
+
122
+ /** How relative() moved one copy group: its source box (u), its centre before and after (u), its vertical map (u -> u) and scale. */
123
+ interface CopyMove {
124
+ box: Box;
125
+ fromX: number;
126
+ toX: number;
127
+ y(u: number): number;
128
+ scale: number;
129
+ }
130
+
131
+ const num = (v: unknown, fallback: number): number => (typeof v === 'number' && Number.isFinite(v) ? v : fallback);
132
+ const clamp = (v: number, lo: number, hi: number) => (v < lo ? lo : v > hi ? hi : v);
133
+ /** Like clamp, but centers when the interval is empty. */
134
+ const clampInto = (v: number, lo: number, hi: number) => (lo > hi ? (lo + hi) / 2 : clamp(v, lo, hi));
135
+
136
+ function isElementLike(e: unknown): e is SlideElement {
137
+ return typeof e === 'object' && e !== null && typeof (e as { kind?: unknown }).kind === 'string';
138
+ }
139
+
140
+ export function adaptLayout(layout: SlideLayout, from: CanvasShape, to: CanvasShape, toFamily: FamilyId): SlideLayout {
141
+ const fam = FAMILIES[toFamily] ?? FAMILIES.iphone;
142
+ const F = canvasMetrics(from);
143
+ const T = canvasMetrics(to);
144
+ const c: Ctx = { F, T, srcAspect: FAMILIES[familyForCanvas(F)].screenAspect, dstAspect: fam.screenAspect };
145
+
146
+ const src: SlideElement[] = Array.isArray(layout?.elements) ? layout.elements.filter(isElementLike) : [];
147
+ const out: SlideElement[] = src.map((el) => deepClone(el));
148
+ for (const el of out) {
149
+ if (el.kind === 'device' && isFamilyDefaultFrame(el.frame)) el.frame = fam.defaultFrame;
150
+ }
151
+
152
+ const hasDevice = src.some((e) => e.kind === 'device');
153
+ if (hasDevice && !F.wide && T.wide) toWide(src, out, c);
154
+ else if (hasDevice && F.wide && !T.wide) fromWide(src, out, c);
155
+ else relative(src, out, c);
156
+
157
+ const maxTextW = TEXT_MAX_WIDTH * T.wu;
158
+ for (const el of out) if (el.kind === 'text') capTextWidth(el, maxTextW);
159
+
160
+ return {
161
+ background: layout?.background ? deepClone(layout.background) : null,
162
+ elements: out.map(finalize),
163
+ };
164
+ }
165
+
166
+ /* Estimates --------------------------------------------------------- */
167
+
168
+ /** Text block extents in u, assuming two lines (one when maxLines is 1). */
169
+ function textExtent(el: TextElement, M: CanvasMetrics): { top: number; bottom: number } {
170
+ const size = num(el.style?.size, 0.05);
171
+ const lineHeight = num(el.style?.lineHeight, 1.2);
172
+ const lines = num(el.style?.maxLines, 0) === 1 ? 1 : 2;
173
+ const h = Math.max(0, lines * size * lineHeight);
174
+ const y = num(el.y, 0.5) * M.hu;
175
+ const top = el.valign === 'middle' ? y - h / 2 : el.valign === 'bottom' ? y - h : y;
176
+ return { top, bottom: top + h };
177
+ }
178
+
179
+ /** Bounding box (u) of a device `w` u wide, rotated by `rotation` degrees. */
180
+ function deviceBox(w: number, factor: number, rotation: number): { bw: number; bh: number } {
181
+ const r = (rotation * Math.PI) / 180;
182
+ const cos = Math.abs(Math.cos(r));
183
+ const sin = Math.abs(Math.sin(r));
184
+ const h = w * factor;
185
+ return { bw: w * cos + h * sin, bh: w * sin + h * cos };
186
+ }
187
+
188
+ /** Texts sorted top to bottom and merged into groups of nearby lines. */
189
+ function clusterTexts(src: SlideElement[], idx: number[], F: CanvasMetrics): TextGroup[] {
190
+ const items = idx
191
+ .map((i) => ({ i, ...textExtent(src[i] as TextElement, F) }))
192
+ .sort((a, b) => a.top - b.top || a.i - b.i);
193
+ const groups: TextGroup[] = [];
194
+ for (const it of items) {
195
+ const g = groups.length ? groups[groups.length - 1] : undefined;
196
+ if (g && it.top <= g.bottom + JOIN_GAP_U) {
197
+ g.members.push(it.i);
198
+ g.bottom = Math.max(g.bottom, it.bottom);
199
+ } else {
200
+ groups.push({ members: [it.i], top: it.top, bottom: it.bottom });
201
+ }
202
+ }
203
+ return groups;
204
+ }
205
+
206
+ const indicesOf = (src: SlideElement[], kind: SlideElement['kind']) =>
207
+ src.reduce<number[]>((acc, e, i) => (e.kind === kind ? (acc.push(i), acc) : acc), []);
208
+
209
+ /** True when a device's outline keeps roughly its proportions through the adaptation (its frame may change). */
210
+ function keepsShape(s: DeviceElement, o: DeviceElement, c: Ctx): boolean {
211
+ const ratio = deviceHeightFactor(o.frame, c.dstAspect) / deviceHeightFactor(s.frame, c.srcAspect);
212
+ return ratio > 0.6 && ratio < 1.6;
213
+ }
214
+
215
+ /* Relative arrangement --------------------------------------------- */
216
+
217
+ function relative(src: SlideElement[], out: SlideElement[], c: Ctx): void {
218
+ const { F, T } = c;
219
+ const textIdx = indicesOf(src, 'text');
220
+ const shifts: GroupShift[] = [];
221
+ const copyMoves: CopyMove[] = [];
222
+ // Wide canvases measure u by their height, so a copy column keeps its share
223
+ // of the canvas width (and its type scales with it) between Mac and banner.
224
+ // It grows by a fifth at most: on a very wide, short canvas (an iPhone on its
225
+ // side) taller copy would run into whatever rises from the bottom edge.
226
+ const scale = F.wide && T.wide ? clamp(T.wu / F.wu, 0.5, 1.2) : 1;
227
+ // Text-only slides switching between portrait and wide: re-center the copy.
228
+ const recenter = F.wide !== T.wide;
229
+
230
+ for (const g of clusterTexts(src, textIdx, F)) {
231
+ const center = (g.top + g.bottom) / 2;
232
+ const rel = center / F.hu;
233
+ // Copy near an edge stays pinned to that edge and grows away from it.
234
+ let anchorSrc: number;
235
+ let anchorDst: number;
236
+ if (rel < 0.4) {
237
+ anchorSrc = g.top;
238
+ anchorDst = edgeDistance(g.top, F, T, scale);
239
+ } else if (rel > 0.6) {
240
+ anchorSrc = g.bottom;
241
+ anchorDst = T.hu - edgeDistance(F.hu - g.bottom, F, T, scale);
242
+ } else {
243
+ anchorSrc = center;
244
+ anchorDst = (center / F.hu) * T.hu;
245
+ }
246
+ const map = (u: number) => anchorDst + (u - anchorSrc) * scale;
247
+ let shift = 0;
248
+ const top = map(g.top);
249
+ const bottom = map(g.bottom);
250
+ if (g.top >= -EPS * F.hu && g.bottom <= F.hu * (1 + EPS)) {
251
+ const lo = 0.02 * T.hu;
252
+ const hi = 0.98 * T.hu;
253
+ if (bottom - top > hi - lo) shift = (lo + hi) / 2 - (top + bottom) / 2;
254
+ else if (top < lo) shift = lo - top;
255
+ else if (bottom > hi) shift = hi - bottom;
256
+ }
257
+
258
+ let left = Infinity;
259
+ let right = -Infinity;
260
+ for (const i of g.members) {
261
+ const s = src[i] as TextElement;
262
+ const w = Math.max(0, num(s.width, 0.8));
263
+ left = Math.min(left, num(s.x, 0.5) * F.wu - w / 2);
264
+ right = Math.max(right, num(s.x, 0.5) * F.wu + w / 2);
265
+ }
266
+ const groupCenter = (left + right) / 2;
267
+
268
+ let placedLeft = Infinity;
269
+ let placedRight = -Infinity;
270
+ for (const i of g.members) {
271
+ const s = src[i] as TextElement;
272
+ const o = out[i] as TextElement;
273
+ o.y = (map(num(s.y, 0.5) * F.hu) + shift) / T.hu;
274
+ o.width = num(s.width, 0.8) * scale;
275
+ if (o.style) o.style.size = num(s.style?.size, 0.05) * scale;
276
+ const xU = recenter ? T.wu / 2 + (num(s.x, 0.5) * F.wu - groupCenter) * scale : num(s.x, 0.5) * T.wu;
277
+ placeTextX(s, o, c, xU);
278
+ placedLeft = Math.min(placedLeft, o.x * T.wu - o.width / 2);
279
+ placedRight = Math.max(placedRight, o.x * T.wu + o.width / 2);
280
+ }
281
+ shifts.push({ srcTop: g.top / F.hu, srcBottom: g.bottom / F.hu, dstTop: (top + shift) / T.hu, dstBottom: (bottom + shift) / T.hu });
282
+ copyMoves.push({ box: { left, right, top: g.top, bottom: g.bottom }, fromX: groupCenter, toX: (placedLeft + placedRight) / 2, y: (u) => map(u) + shift, scale });
283
+ }
284
+
285
+ const deviceIdx = indicesOf(src, 'device');
286
+ for (const i of deviceIdx) fitDevice(src[i] as DeviceElement, out[i] as DeviceElement, c, shifts);
287
+ // Decorations around the devices follow them as they re-fit, between portrait canvases and
288
+ // between wide ones whose devices keep their shape (a laptop that becomes a TV, a Vision Pro
289
+ // window or a phone on its side). A laptop that becomes an upright phone keeps its
290
+ // decorations in place instead: they would not fit around it.
291
+ const withDevices = new Set<number>();
292
+ const follow = !F.wide && !T.wide ? true : F.wide && T.wide && deviceIdx.every((i) => keepsShape(src[i] as DeviceElement, out[i] as DeviceElement, c));
293
+ if (deviceIdx.length && follow) {
294
+ const { box: from } = sourceDevices(src, deviceIdx, c);
295
+ const decor = groupDecor(src, from, c);
296
+ for (const i of decor) withDevices.add(i);
297
+ moveDecor(src, out, decor, fittedMove(src, out, deviceIdx, from, c), copyBox(out, textIdx, T), c, copyBox(src, textIdx, F));
298
+ }
299
+ moveCopyDecor(src, out, copyMoves, withDevices, c);
300
+ }
301
+
302
+ /**
303
+ * Decorations that belong to a copy group: small enough to sit around it
304
+ * and centred on its box grown by COPY_REACH, like a rule under an eyebrow,
305
+ * an emblem above a headline or a panel behind the text. They move (and
306
+ * scale) with their group, so they keep their place in the copy when it
307
+ * keeps its distance to an edge on a canvas of another height.
308
+ */
309
+ function moveCopyDecor(src: SlideElement[], out: SlideElement[], moves: CopyMove[], skip: ReadonlySet<number>, c: Ctx): void {
310
+ if (!moves.length) return;
311
+ const { F, T } = c;
312
+ src.forEach((el, i) => {
313
+ if ((el.kind !== 'shape' && el.kind !== 'image') || skip.has(i)) return;
314
+ const size = decorExtent(el);
315
+ const x = num(el.x, 0.5) * F.wu;
316
+ const y = num(el.y, 0.5) * F.hu;
317
+ const move = moves.find(({ box: b }) => {
318
+ const w = b.right - b.left;
319
+ const h = b.bottom - b.top;
320
+ return size.w <= w + 2 * COPY_REACH_U && size.h <= h + 2 * COPY_REACH_U && x > b.left - COPY_REACH_U && x < b.right + COPY_REACH_U && y > b.top - COPY_REACH_U && y < b.bottom + COPY_REACH_U;
321
+ });
322
+ if (!move) return;
323
+ const o = out[i];
324
+ o.x = (move.toX + (x - move.fromX) * move.scale) / T.wu;
325
+ o.y = move.y(y) / T.hu;
326
+ if (move.scale === 1) return;
327
+ if (o.kind === 'shape') {
328
+ o.width = num(o.width, 0.4) * move.scale;
329
+ o.height = num(o.height, 0.4) * move.scale;
330
+ o.stroke = num(o.stroke, 0) * move.scale;
331
+ o.blur = num(o.blur, 0) * move.scale;
332
+ } else if (o.kind === 'image') {
333
+ o.width = num(o.width, 0.4) * move.scale;
334
+ }
335
+ });
336
+ }
337
+
338
+ /** The move of devices fitted one by one (fitDevice): their box before and after, and their overall scale. */
339
+ function fittedMove(src: SlideElement[], out: SlideElement[], idx: number[], from: Box, c: Ctx): GroupMove {
340
+ const { T } = c;
341
+ const to: Box = { left: Infinity, right: -Infinity, top: Infinity, bottom: -Infinity };
342
+ let before = 0;
343
+ let after = 0;
344
+ for (const i of idx) {
345
+ const o = out[i] as DeviceElement;
346
+ const w = num(o.width, 0.6);
347
+ const b = deviceBox(w, deviceHeightFactor(o.frame, c.dstAspect), num(o.rotation, 0));
348
+ const x = num(o.x, 0.5) * T.wu;
349
+ const y = num(o.y, 0.5) * T.hu;
350
+ to.left = Math.min(to.left, x - b.bw / 2);
351
+ to.right = Math.max(to.right, x + b.bw / 2);
352
+ to.top = Math.min(to.top, y - b.bh / 2);
353
+ to.bottom = Math.max(to.bottom, y + b.bh / 2);
354
+ before += Math.max(RANGES.deviceWidth.min, num((src[i] as DeviceElement).width, 0.6));
355
+ after += w;
356
+ }
357
+ return { from, to, scale: after / Math.max(1e-6, before) };
358
+ }
359
+
360
+ /**
361
+ * Target distance (u) between copy and the canvas edge it is pinned to. A
362
+ * small margin is typographic and kept in u (it scales with the type); copy
363
+ * further in keeps its relative position. Between the two the rules blend,
364
+ * so nothing jumps and a round trip lands where it started.
365
+ */
366
+ function edgeDistance(distU: number, F: CanvasMetrics, T: CanvasMetrics, scale: number): number {
367
+ const relative = (distU / F.hu) * T.hu;
368
+ if (distU < 0) return relative;
369
+ const w = clamp((distU - NEAR_EDGE_U) / (FAR_EDGE_U - NEAR_EDGE_U), 0, 1);
370
+ return (1 - w) * distU * scale + w * relative;
371
+ }
372
+
373
+ /** Shrinks a too-wide text box (and its font, so line breaks stay the same). */
374
+ function capTextWidth(o: TextElement, maxW: number): void {
375
+ const w = num(o.width, 0.8);
376
+ if (w <= maxW) return;
377
+ o.width = maxW;
378
+ if (o.style) o.style.size = num(o.style.size, 0.05) * (maxW / w);
379
+ }
380
+
381
+ /** Sets x from `xU` (u); a text box that was inside the source canvas stays inside. */
382
+ function placeTextX(s: TextElement, o: TextElement, c: Ctx, xU: number): void {
383
+ const { F, T } = c;
384
+ capTextWidth(o, TEXT_MAX_WIDTH * T.wu);
385
+ const x = num(s.x, 0.5);
386
+ const w = num(s.width, 0.8);
387
+ const inside = x * F.wu - w / 2 >= -EPS * F.wu && x * F.wu + w / 2 <= F.wu * (1 + EPS);
388
+ const half = num(o.width, w) / 2;
389
+ o.x = (inside ? clampInto(xU, half, T.wu - half) : xU) / T.wu;
390
+ }
391
+
392
+ /** Margin kept on one side: the source's own margin up to MARGIN; MARGIN where it bled. */
393
+ const sideMargin = (free: number) => (free < -EPS ? MARGIN : Math.min(MARGIN, Math.max(0, free)));
394
+
395
+ /**
396
+ * Re-fits one device. Fully visible devices fill the band they occupied
397
+ * (between the copy above and below them, keeping the gap to the copy in u),
398
+ * keep their relative width when the frame shape changes, and never leave
399
+ * the canvas. Bleeding devices keep their top edge (relative to the copy)
400
+ * and the hidden share of their height. A device that turns from portrait
401
+ * into landscape (phone -> laptop) or back cannot keep its bleed and is
402
+ * fitted fully visible instead.
403
+ */
404
+ function fitDevice(s: DeviceElement, o: DeviceElement, c: Ctx, groups: GroupShift[]): void {
405
+ const { F, T } = c;
406
+ const rot = num(s.rotation, 0);
407
+ const wF = Math.max(RANGES.deviceWidth.min, num(s.width, 0.6));
408
+ const fF = deviceHeightFactor(s.frame, c.srcAspect);
409
+ const fT = deviceHeightFactor(o.frame, c.dstAspect);
410
+ const sb = deviceBox(wF, fF, rot);
411
+ const cx = num(s.x, 0.5);
412
+ const cy = num(s.y, 0.5);
413
+ const topF = cy - sb.bh / (2 * F.hu);
414
+ const bottomF = cy + sb.bh / (2 * F.hu);
415
+ const leftF = cx - sb.bw / (2 * F.wu);
416
+ const rightF = cx + sb.bw / (2 * F.wu);
417
+ const shapeChange = fF >= 1 !== fT >= 1;
418
+ const bleedT = !shapeChange && topF < -EPS;
419
+ const bleedB = !shapeChange && bottomF > 1 + EPS;
420
+ const fitsW = leftF >= -EPS && rightF <= 1 + EPS;
421
+ const srcH = Math.max(1e-6, bottomF - topF);
422
+ const srcW = sb.bw / F.wu;
423
+
424
+ let above: GroupShift | null = null;
425
+ let below: GroupShift | null = null;
426
+ for (const g of groups) {
427
+ if (g.srcBottom <= topF + 0.02 && (!above || g.srcBottom > above.srcBottom)) above = g;
428
+ if (g.srcTop >= bottomF - 0.02 && (!below || g.srcTop < below.srcTop)) below = g;
429
+ }
430
+ // Where the copy now ends, plus the original gap measured in u.
431
+ const topLimit = above ? above.dstBottom + ((topF - above.srcBottom) * F.hu) / T.hu : null;
432
+ const bottomLimit = below ? below.dstTop - ((below.srcTop - bottomF) * F.hu) / T.hu : null;
433
+
434
+ const tb = deviceBox(wF, fT, rot);
435
+ const bh1 = Math.max(1e-6, tb.bh / T.hu);
436
+ const bw1 = Math.max(1e-6, tb.bw / T.wu);
437
+
438
+ let kV: number;
439
+ let placeY: (bh: number) => number;
440
+ let band: { lo: number; hi: number } | null = null;
441
+
442
+ if (!bleedT && !bleedB) {
443
+ const mTop = sideMargin(topF);
444
+ const mBot = sideMargin(1 - bottomF);
445
+ let availTop = topLimit !== null ? Math.max(mTop, topLimit) : mTop;
446
+ let availBottom = bottomLimit !== null ? Math.min(1 - mBot, bottomLimit) : 1 - mBot;
447
+ if (availBottom - availTop < 0.08) {
448
+ // The copy now covers the device's space: use the canvas, overlap beats vanishing.
449
+ availTop = MARGIN;
450
+ availBottom = 1 - MARGIN;
451
+ }
452
+ let bandTop = Math.max(availTop, topLimit ?? topF);
453
+ let bandBottom = Math.min(availBottom, bottomLimit ?? bottomF);
454
+ if (bandBottom - bandTop < 0.08) {
455
+ bandTop = availTop;
456
+ bandBottom = availBottom;
457
+ }
458
+ const kFill = (bandBottom - bandTop) / bh1;
459
+ const kAvail = (availBottom - availTop) / bh1;
460
+ // A laptop turning into a phone would shrink to a sliver if it only kept its height.
461
+ const kKeepWidth = srcW / bw1;
462
+ kV = Math.min(kAvail, Math.max(kFill, kKeepWidth));
463
+ const mid = (bandTop + bandBottom) / 2;
464
+ placeY = () => mid;
465
+ band = { lo: availTop, hi: availBottom };
466
+ } else if (bleedB && !bleedT) {
467
+ const hidden = clamp((bottomF - 1) / srcH, 0, 0.75);
468
+ const top = clamp(topLimit ?? topF, 0, 0.75);
469
+ kV = (1 - top) / (1 - hidden) / bh1;
470
+ // The bottom edge sits below the canvas by the same share of the device.
471
+ placeY = (bh) => 1 + hidden * bh - bh / 2;
472
+ } else if (bleedT && !bleedB) {
473
+ const hidden = clamp(-topF / srcH, 0, 0.75);
474
+ const bottom = clamp(bottomLimit ?? bottomF, 0.25, 1);
475
+ kV = bottom / (1 - hidden) / bh1;
476
+ placeY = (bh) => -hidden * bh + bh / 2;
477
+ } else {
478
+ kV = srcH / bh1;
479
+ placeY = () => cy;
480
+ }
481
+
482
+ let kH = Infinity;
483
+ let mL = 0;
484
+ let mR = 0;
485
+ if (fitsW) {
486
+ mL = sideMargin(leftF);
487
+ mR = sideMargin(1 - rightF);
488
+ let maxW = 1 - mL - mR;
489
+ if (T.wide && Math.abs(cx - 0.5) > 0.1) {
490
+ // Side-by-side layouts: a device on one side stays on its side of the copy.
491
+ maxW = Math.min(maxW, Math.max(srcW, 2 * (Math.abs(cx - 0.5) - 0.02)));
492
+ }
493
+ kH = maxW / bw1;
494
+ }
495
+
496
+ const k = clamp(Math.min(kV, kH), K_MIN, K_MAX);
497
+ const w = clampToRange(wF * k, RANGES.deviceWidth);
498
+ const b = deviceBox(w, fT, rot);
499
+ const bh = b.bh / T.hu;
500
+ const bw = b.bw / T.wu;
501
+ let y = placeY(bh);
502
+ if (band) y = clampInto(y, band.lo + bh / 2, band.hi - bh / 2);
503
+ const x = fitsW ? clampInto(cx, mL + bw / 2, 1 - mR - bw / 2) : cx;
504
+ o.width = w;
505
+ o.x = x;
506
+ o.y = y;
507
+ }
508
+
509
+ /* Side-by-side and stacked arrangements ----------------------------- */
510
+
511
+ interface StackOptions {
512
+ /** Column center and maximum width, u. */
513
+ cx: number;
514
+ maxW: number;
515
+ /** Maximum block height, u. */
516
+ maxH: number;
517
+ /** Largest allowed scale-up. */
518
+ grow: number;
519
+ /** Block top (u); when absent the block is centered vertically. */
520
+ top?: number;
521
+ centerAlign: boolean;
522
+ }
523
+
524
+ /**
525
+ * Stacks the copy groups into one column: each group keeps its internal
526
+ * spacing, groups sit STACK_GAP_U apart, everything scales uniformly so the
527
+ * widest box fits the column. Returns the block's top and bottom in u.
528
+ */
529
+ function stackCopy(src: SlideElement[], out: SlideElement[], idx: number[], c: Ctx, o: StackOptions): { top: number; bottom: number } {
530
+ const { F, T } = c;
531
+ const groups = clusterTexts(src, idx, F);
532
+ const offsets = new Map<number, number>();
533
+ let cursor = 0;
534
+ let left = Infinity;
535
+ let right = -Infinity;
536
+ let widest = 0;
537
+ for (const g of groups) {
538
+ for (const i of g.members) {
539
+ const s = src[i] as TextElement;
540
+ offsets.set(i, cursor + (num(s.y, 0.5) * F.hu - g.top));
541
+ const w = Math.max(0, num(s.width, 0.8));
542
+ const xu = num(s.x, 0.5) * F.wu;
543
+ left = Math.min(left, xu - w / 2);
544
+ right = Math.max(right, xu + w / 2);
545
+ widest = Math.max(widest, w);
546
+ }
547
+ cursor += g.bottom - g.top + STACK_GAP_U;
548
+ }
549
+ const height = Math.max(1e-6, cursor - STACK_GAP_U);
550
+ const scale = clamp(Math.min(o.grow, o.maxW / Math.max(1e-6, widest), o.maxH / height), 0.3, 1.5);
551
+ const top = o.top ?? T.hu / 2 - (height * scale) / 2;
552
+ const centerX = Number.isFinite(left) && Number.isFinite(right) ? (left + right) / 2 : F.wu / 2;
553
+ for (const i of idx) {
554
+ const s = src[i] as TextElement;
555
+ const t = out[i] as TextElement;
556
+ const width = Math.max(0, num(s.width, 0.8)) * scale;
557
+ const xu = o.cx + (num(s.x, 0.5) * F.wu - centerX) * scale;
558
+ t.width = width;
559
+ t.x = clampInto(xu, width / 2 + 0.02 * T.wu, T.wu - width / 2 - 0.02 * T.wu) / T.wu;
560
+ t.y = (top + (offsets.get(i) ?? 0) * scale) / T.hu;
561
+ if (t.style) {
562
+ t.style.size = num(s.style?.size, 0.05) * scale;
563
+ if (o.centerAlign) t.style.align = 'center';
564
+ }
565
+ }
566
+ return { top, bottom: top + height * scale };
567
+ }
568
+
569
+ /** Width, rotation and centre (source u) of each device, plus the group's box (source frames). */
570
+ function sourceDevices(src: SlideElement[], idx: number[], c: Ctx): { items: { i: number; w: number; rot: number; cx: number; cy: number }[]; box: Box } {
571
+ const { F } = c;
572
+ const box: Box = { left: Infinity, right: -Infinity, top: Infinity, bottom: -Infinity };
573
+ const items = idx.map((i) => {
574
+ const s = src[i] as DeviceElement;
575
+ const w = Math.max(RANGES.deviceWidth.min, num(s.width, 0.6));
576
+ const rot = num(s.rotation, 0);
577
+ const b = deviceBox(w, deviceHeightFactor(s.frame, c.srcAspect), rot);
578
+ const cx = num(s.x, 0.5) * F.wu;
579
+ const cy = num(s.y, 0.5) * F.hu;
580
+ box.left = Math.min(box.left, cx - b.bw / 2);
581
+ box.right = Math.max(box.right, cx + b.bw / 2);
582
+ box.top = Math.min(box.top, cy - b.bh / 2);
583
+ box.bottom = Math.max(box.bottom, cy + b.bh / 2);
584
+ return { i, w, rot, cx, cy };
585
+ });
586
+ return { items, box };
587
+ }
588
+
589
+ /**
590
+ * Moves all devices as one group into `r`, keeping their relative offsets.
591
+ * A group that bled off the bottom keeps bleeding by the same share, unless
592
+ * the region is too narrow for that (laptops on a wide canvas), where a
593
+ * bottom-anchored device would leave the upper half empty: it is centered.
594
+ * Returns how the group moved, or null without devices.
595
+ */
596
+ function placeDeviceGroup(src: SlideElement[], out: SlideElement[], idx: number[], c: Ctx, r: DeviceRegion): GroupMove | null {
597
+ if (!idx.length) return null;
598
+ const { F, T } = c;
599
+ const { items, box } = sourceDevices(src, idx, c);
600
+ const { left: sl, right: sr, top: st, bottom: sbm } = box;
601
+ const gcx = (sl + sr) / 2;
602
+ const gcy = (st + sbm) / 2;
603
+ const bleedB = sbm > F.hu * (1 + EPS);
604
+ const bleedT = st < -EPS * F.hu;
605
+
606
+ let tl = Infinity;
607
+ let tr = -Infinity;
608
+ let tt = Infinity;
609
+ let tbm = -Infinity;
610
+ for (const it of items) {
611
+ const b = deviceBox(it.w, deviceHeightFactor((out[it.i] as DeviceElement).frame, c.dstAspect), it.rot);
612
+ const dx = it.cx - gcx;
613
+ const dy = it.cy - gcy;
614
+ tl = Math.min(tl, dx - b.bw / 2);
615
+ tr = Math.max(tr, dx + b.bw / 2);
616
+ tt = Math.min(tt, dy - b.bh / 2);
617
+ tbm = Math.max(tbm, dy + b.bh / 2);
618
+ }
619
+ const GW = Math.max(1e-6, tr - tl);
620
+ const GH = Math.max(1e-6, tbm - tt);
621
+ const ocx = (tl + tr) / 2;
622
+ const ocy = (tt + tbm) / 2;
623
+
624
+ const gW = (r.maxW * T.wu) / GW;
625
+ const regionH = Math.max(1e-6, (r.bottom - r.top) * T.hu);
626
+ let g = Math.min(gW, regionH / GH);
627
+ let cyU = ((r.top + r.bottom) / 2) * T.hu;
628
+ if (bleedB && !bleedT) {
629
+ const hidden = clamp((sbm - F.hu) / Math.max(1e-6, sbm - st), 0.02, 0.6);
630
+ const topU = r.bleedTop * T.hu;
631
+ const gV = (T.hu - topU) / (1 - hidden) / GH;
632
+ const gb = Math.min(gV, gW);
633
+ const gh = GH * gb;
634
+ const groupTop = T.hu + hidden * gh - gh;
635
+ if (!(gW < gV && groupTop > ((r.top + r.bottom) / 2) * T.hu)) {
636
+ g = gb;
637
+ cyU = groupTop + gh / 2;
638
+ }
639
+ }
640
+ g = clamp(g, K_MIN, K_MAX);
641
+ const cxU = r.cx * T.wu;
642
+ for (const it of items) {
643
+ const o = out[it.i] as DeviceElement;
644
+ o.width = clampToRange(it.w * g, RANGES.deviceWidth);
645
+ o.x = (cxU + (it.cx - gcx - ocx) * g) / T.wu;
646
+ o.y = (cyU + (it.cy - gcy - ocy) * g) / T.hu;
647
+ }
648
+ const to = { left: cxU - (GW * g) / 2, right: cxU + (GW * g) / 2, top: cyU - (GH * g) / 2, bottom: cyU + (GH * g) / 2 };
649
+ return { from: box, to, scale: g };
650
+ }
651
+
652
+ /** Painted size (u) of a decoration's bounding box: rotated, plus its blur. Images count as squares (their aspect is unknown here). */
653
+ function decorExtent(el: SlideElement): { w: number; h: number } {
654
+ const w = Math.max(0, num((el as { width?: unknown }).width, 0.4));
655
+ const h = el.kind === 'shape' ? Math.max(0, num(el.height, 0.4)) : w;
656
+ const r = (num(el.rotation, 0) * Math.PI) / 180;
657
+ const cos = Math.abs(Math.cos(r));
658
+ const sin = Math.abs(Math.sin(r));
659
+ const blur = el.kind === 'shape' ? 2 * Math.max(0, num(el.blur, 0)) : 0;
660
+ return { w: w * cos + h * sin + blur, h: w * sin + h * cos + blur };
661
+ }
662
+
663
+ /** Soft light (a blurred glow) rather than a drawn shape. */
664
+ const isSoft = (el: SlideElement) => el.kind === 'shape' && num(el.blur, 0) >= SOFT_BLUR * Math.min(num(el.width, 0.4), num(el.height, 0.4));
665
+
666
+ /**
667
+ * Largest k in [0, 1] for which [c + (lo - c) k, c + (hi - c) k] stays clear
668
+ * of [a, b]; 0 when the centre `c` itself lies inside [a, b].
669
+ */
670
+ function clearScale(c: number, lo: number, hi: number, a: number, b: number): number {
671
+ if (hi <= a || lo >= b) return 1;
672
+ if (c >= b) return lo < c ? Math.min(1, (c - b) / (c - lo)) : 1;
673
+ if (c <= a) return hi > c ? Math.min(1, (a - c) / (hi - c)) : 1;
674
+ return 0;
675
+ }
676
+
677
+ /** Union of the placed text boxes in target u, or null without texts. */
678
+ function copyBox(out: SlideElement[], idx: number[], T: CanvasMetrics): Box | null {
679
+ let box: Box | null = null;
680
+ for (const i of idx) {
681
+ const t = out[i] as TextElement;
682
+ const half = Math.max(0, num(t.width, 0.8)) / 2;
683
+ const x = num(t.x, 0.5) * T.wu;
684
+ const { top, bottom } = textExtent(t, T);
685
+ box = box
686
+ ? { left: Math.min(box.left, x - half), right: Math.max(box.right, x + half), top: Math.min(box.top, top), bottom: Math.max(box.bottom, bottom) }
687
+ : { left: x - half, right: x + half, top, bottom };
688
+ }
689
+ return box;
690
+ }
691
+
692
+ /**
693
+ * Decorations that belong to the device group: centred inside its box grown
694
+ * by GROUP_REACH, within the canvas, like a ring or a disc behind a phone.
695
+ * Backdrops (much larger than the canvas) and decorations elsewhere, such as
696
+ * panorama pieces on the seams, are not.
697
+ */
698
+ function groupDecor(src: SlideElement[], group: Box, c: Ctx): number[] {
699
+ const { F } = c;
700
+ const reachX = GROUP_REACH * (group.right - group.left);
701
+ const reachY = GROUP_REACH * (group.bottom - group.top);
702
+ const left = Math.max(0, group.left - reachX);
703
+ const right = Math.min(F.wu, group.right + reachX);
704
+ const top = Math.max(0, group.top - reachY);
705
+ const bottom = Math.min(F.hu, group.bottom + reachY);
706
+ return src.reduce<number[]>((acc, el, i) => {
707
+ if (el.kind !== 'shape' && el.kind !== 'image') return acc;
708
+ const size = decorExtent(el);
709
+ if (size.w > BACKDROP * F.wu || size.h > BACKDROP * F.hu) return acc;
710
+ const x = num(el.x, 0.5) * F.wu;
711
+ const y = num(el.y, 0.5) * F.hu;
712
+ if (x > left && x < right && y > top && y < bottom) acc.push(i);
713
+ return acc;
714
+ }, []);
715
+ }
716
+
717
+ /**
718
+ * Vertical room (u) for the side-by-side copy column: the canvas within its
719
+ * margins, minus drawn decorations that rise from the bottom edge or hang
720
+ * from the top edge across the column (dunes, hills, waves). Decorations
721
+ * that move with the devices do not count. Falls back to the whole canvas
722
+ * when that leaves too little room.
723
+ */
724
+ function copyBand(src: SlideElement[], out: SlideElement[], skip: ReadonlySet<number>, c: Ctx): { top: number; bottom: number } {
725
+ const { T } = c;
726
+ const full = { top: MARGIN * T.hu, bottom: (1 - MARGIN) * T.hu };
727
+ const band = { ...full };
728
+ const colLeft = (WIDE_COPY.x - WIDE_COPY.w / 2) * T.wu;
729
+ const colRight = (WIDE_COPY.x + WIDE_COPY.w / 2) * T.wu;
730
+ out.forEach((el, i) => {
731
+ if ((el.kind !== 'shape' && el.kind !== 'image') || skip.has(i) || isSoft(src[i])) return;
732
+ const size = decorExtent(el);
733
+ const x = num(el.x, 0.5) * T.wu;
734
+ const y = num(el.y, 0.5) * T.hu;
735
+ if (x + size.w / 2 <= colLeft || x - size.w / 2 >= colRight) return;
736
+ const top = y - size.h / 2;
737
+ const bottom = y + size.h / 2;
738
+ if (bottom >= T.hu * (1 - EPS) && top > T.hu / 2) band.bottom = Math.min(band.bottom, top - DECOR_GAP_U);
739
+ if (top <= T.hu * EPS && bottom < T.hu / 2) band.top = Math.max(band.top, bottom + DECOR_GAP_U);
740
+ });
741
+ return band.bottom - band.top < MIN_COPY_BAND * T.hu ? full : band;
742
+ }
743
+
744
+ /**
745
+ * Moves the decorations of the device group with it, keeping their place
746
+ * relative to its box (a ring at a phone's corner ends up at the laptop's
747
+ * corner), scaled like the devices. When a drawn one would still reach the
748
+ * copy, all of them shrink towards the devices until it clears (soft glows
749
+ * may light the text and never force that). With `before` (the copy on the
750
+ * source canvas), a decoration that already reached the copy there is left
751
+ * to: the design meant it.
752
+ */
753
+ function moveDecor(src: SlideElement[], out: SlideElement[], idx: number[], move: GroupMove, copy: Box | null, c: Ctx, before: Box | null = null): void {
754
+ if (!idx.length) return;
755
+ const { F, T } = c;
756
+ const { from, to } = move;
757
+ const fromX = (from.left + from.right) / 2;
758
+ const fromY = (from.top + from.bottom) / 2;
759
+ const toX = (to.left + to.right) / 2;
760
+ const toY = (to.top + to.bottom) / 2;
761
+ // Box to box, per axis: frames may change shape (phone -> laptop).
762
+ const sx = (to.right - to.left) / Math.max(1e-6, from.right - from.left);
763
+ const sy = (to.bottom - to.top) / Math.max(1e-6, from.bottom - from.top);
764
+ const attached = idx.map((i) => ({ i, x: toX + (num(src[i].x, 0.5) * F.wu - fromX) * sx, y: toY + (num(src[i].y, 0.5) * F.hu - fromY) * sy }));
765
+
766
+ let k = 1;
767
+ if (copy) {
768
+ const keep: Box = { left: copy.left - DECOR_GAP_U, right: copy.right + DECOR_GAP_U, top: copy.top - DECOR_GAP_U, bottom: copy.bottom + DECOR_GAP_U };
769
+ for (const a of attached) {
770
+ if (isSoft(src[a.i])) continue;
771
+ const size = decorExtent(src[a.i]);
772
+ if (before) {
773
+ const x = num(src[a.i].x, 0.5) * F.wu;
774
+ const y = num(src[a.i].y, 0.5) * F.hu;
775
+ if (x - size.w / 2 < before.right && x + size.w / 2 > before.left && y - size.h / 2 < before.bottom && y + size.h / 2 > before.top) continue;
776
+ }
777
+ const hw = (size.w * move.scale) / 2;
778
+ const hh = (size.h * move.scale) / 2;
779
+ const kx = clearScale(toX, a.x - hw, a.x + hw, keep.left, keep.right);
780
+ const ky = clearScale(toY, a.y - hh, a.y + hh, keep.top, keep.bottom);
781
+ k = Math.min(k, Math.max(kx, ky));
782
+ }
783
+ k = Math.max(DECOR_MIN_SCALE, k);
784
+ }
785
+
786
+ const s = move.scale * k;
787
+ for (const a of attached) {
788
+ const o = out[a.i];
789
+ o.x = (toX + (a.x - toX) * k) / T.wu;
790
+ o.y = (toY + (a.y - toY) * k) / T.hu;
791
+ if (o.kind === 'shape') {
792
+ o.width = num(o.width, 0.4) * s;
793
+ o.height = num(o.height, 0.4) * s;
794
+ o.stroke = num(o.stroke, 0) * s;
795
+ o.blur = num(o.blur, 0) * s;
796
+ } else if (o.kind === 'image') {
797
+ o.width = num(o.width, 0.4) * s;
798
+ if (o.shadow) o.shadow = { ...o.shadow, blur: num(o.shadow.blur, 0) * s, x: num(o.shadow.x, 0) * s, y: num(o.shadow.y, 0) * s };
799
+ }
800
+ }
801
+ }
802
+
803
+ function toWide(src: SlideElement[], out: SlideElement[], c: Ctx): void {
804
+ const { T } = c;
805
+ const textIdx = indicesOf(src, 'text');
806
+ const deviceIdx = indicesOf(src, 'device');
807
+ const decor = deviceIdx.length ? groupDecor(src, sourceDevices(src, deviceIdx, c).box, c) : [];
808
+ if (textIdx.length) {
809
+ const band = copyBand(src, out, new Set(decor), c);
810
+ const block = stackCopy(src, out, textIdx, c, {
811
+ cx: WIDE_COPY.x * T.wu,
812
+ maxW: WIDE_COPY.w * T.wu,
813
+ maxH: band.bottom - band.top,
814
+ grow: 1,
815
+ centerAlign: true,
816
+ });
817
+ // Centred on the canvas, unless that reaches into a decoration at an edge.
818
+ const shift = block.bottom > band.bottom ? band.bottom - block.bottom : block.top < band.top ? band.top - block.top : 0;
819
+ if (shift) for (const i of textIdx) out[i].y = num(out[i].y, 0.5) + shift / T.hu;
820
+ }
821
+ const region: DeviceRegion = textIdx.length
822
+ ? { cx: WIDE_DEVICES.x, maxW: WIDE_DEVICES.w, top: MARGIN, bottom: 1 - MARGIN, bleedTop: WIDE_DEVICES.bleedTop }
823
+ : { cx: 0.5, maxW: 1 - 2 * MARGIN, top: MARGIN, bottom: 1 - MARGIN, bleedTop: WIDE_DEVICES.bleedTop };
824
+ const move = placeDeviceGroup(src, out, deviceIdx, c, region);
825
+ if (move) moveDecor(src, out, decor, move, copyBox(out, textIdx, T), c);
826
+ }
827
+
828
+ function fromWide(src: SlideElement[], out: SlideElement[], c: Ctx): void {
829
+ const { T } = c;
830
+ const textIdx = indicesOf(src, 'text');
831
+ let regionTop = MARGIN;
832
+ if (textIdx.length) {
833
+ const { bottom } = stackCopy(src, out, textIdx, c, {
834
+ cx: T.wu / 2,
835
+ maxW: TALL_COPY.w * T.wu,
836
+ maxH: 0.4 * T.hu,
837
+ grow: 1.25,
838
+ top: TALL_COPY.top,
839
+ centerAlign: false,
840
+ });
841
+ regionTop = Math.min(0.6, (bottom + TALL_COPY.gap) / T.hu);
842
+ }
843
+ const deviceIdx = indicesOf(src, 'device');
844
+ const move = placeDeviceGroup(src, out, deviceIdx, c, {
845
+ cx: 0.5,
846
+ maxW: 1 - 2 * MARGIN,
847
+ top: regionTop,
848
+ bottom: 1 - MARGIN,
849
+ bleedTop: regionTop,
850
+ });
851
+ if (move) moveDecor(src, out, groupDecor(src, move.from, c), move, copyBox(out, textIdx, T), c);
852
+ }
853
+
854
+ /* Output hygiene ---------------------------------------------------- */
855
+
856
+ const tidy = (v: unknown, fallback: number, range: Range) => Math.round(clampToRange(num(v, fallback), range) * 1e6) / 1e6;
857
+
858
+ /** Clamps and rounds every number this module may have touched; NaN never leaves. */
859
+ function finalize(el: SlideElement): SlideElement {
860
+ el.x = tidy(el.x, 0.5, RANGES.position);
861
+ el.y = tidy(el.y, 0.5, RANGES.position);
862
+ el.rotation = tidy(el.rotation, 0, RANGES.rotation);
863
+ el.opacity = tidy(el.opacity, 1, RANGES.unit);
864
+ switch (el.kind) {
865
+ case 'device':
866
+ el.width = tidy(el.width, 0.6, RANGES.deviceWidth);
867
+ el.cornerRadius = tidy(el.cornerRadius, 0, RANGES.cornerRadius);
868
+ break;
869
+ case 'text':
870
+ el.width = tidy(el.width, 0.8, RANGES.textWidth);
871
+ if (el.style) {
872
+ el.style.size = tidy(el.style.size, 0.05, RANGES.fontSize);
873
+ el.style.lineHeight = tidy(el.style.lineHeight, 1.2, RANGES.lineHeight);
874
+ el.style.letterSpacing = tidy(el.style.letterSpacing, 0, RANGES.letterSpacing);
875
+ }
876
+ break;
877
+ case 'shape':
878
+ el.width = tidy(el.width, 0.4, RANGES.shapeSize);
879
+ el.height = tidy(el.height, 0.4, RANGES.shapeSize);
880
+ el.stroke = tidy(el.stroke, 0, RANGES.stroke);
881
+ el.blur = tidy(el.blur, 0, RANGES.blur);
882
+ break;
883
+ case 'image':
884
+ el.width = tidy(el.width, 0.4, RANGES.imageWidth);
885
+ if (el.shadow) el.shadow = { ...el.shadow, blur: tidy(el.shadow.blur, 0, RANGES.blur), x: tidy(el.shadow.x, 0, RANGES.shadowOffset), y: tidy(el.shadow.y, 0, RANGES.shadowOffset) };
886
+ break;
887
+ }
888
+ return el;
889
+ }