openfig-cli 0.3.42 → 0.4.1

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.
@@ -0,0 +1,1280 @@
1
+ export async function extractSlides(page, opts = {}) {
2
+ const flexAutoLayout = Boolean(opts.flexAutoLayout ?? process.env.OPENFIG_FLEX_AUTO_LAYOUT);
3
+ return await page.evaluate((extractOpts) => {
4
+ const flexAutoLayoutEnabled = !!extractOpts.flexAutoLayout;
5
+ const CANVAS_W = 1920;
6
+ const CANVAS_H = 1080;
7
+
8
+ function decodeSvgDataUrl(src) {
9
+ if (typeof src !== 'string') return null;
10
+ const m = src.match(/^data:image\/svg\+xml(?:;charset=[^;,]+)?(;base64)?,(.*)$/i);
11
+ if (!m) return null;
12
+ try {
13
+ return m[1] ? atob(m[2]) : decodeURIComponent(m[2]);
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+
19
+ const sections = Array.from(document.querySelectorAll('section'));
20
+ if (sections.length === 0) return { slides: [] };
21
+
22
+ const results = [];
23
+ for (let i = 0; i < sections.length; i++) {
24
+ const sec = sections[i];
25
+ const saved = sections.map((s) => ({
26
+ el: s,
27
+ display: s.style.display,
28
+ visibility: s.style.visibility,
29
+ position: s.style.position,
30
+ top: s.style.top,
31
+ left: s.style.left,
32
+ width: s.style.width,
33
+ height: s.style.height,
34
+ overflow: s.style.overflow,
35
+ }));
36
+ for (const s of sections) {
37
+ if (s === sec) {
38
+ s.style.display = 'block';
39
+ s.style.visibility = 'visible';
40
+ s.style.position = 'absolute';
41
+ s.style.top = '0px';
42
+ s.style.left = '0px';
43
+ s.style.width = CANVAS_W + 'px';
44
+ s.style.height = CANVAS_H + 'px';
45
+ s.style.overflow = 'visible';
46
+ } else {
47
+ s.style.display = 'none';
48
+ }
49
+ }
50
+ document.body.offsetHeight;
51
+ results.push(collectSection(sec, i));
52
+ for (const { el, display, visibility, position, top, left, width, height, overflow } of saved) {
53
+ el.style.display = display;
54
+ el.style.visibility = visibility;
55
+ el.style.position = position;
56
+ el.style.top = top;
57
+ el.style.left = left;
58
+ el.style.width = width;
59
+ el.style.height = height;
60
+ el.style.overflow = overflow;
61
+ }
62
+ }
63
+
64
+ // Collect CSS custom properties from :root so the Node side can
65
+ // resolve `var(--name)` references that Chromium leaves literal in
66
+ // inline SVG attributes (e.g. fill="var(--accent)"). getComputedStyle
67
+ // on the documentElement exposes every declared `--foo` in resolved
68
+ // form.
69
+ const cssVars = {};
70
+ const rootCs = getComputedStyle(document.documentElement);
71
+ for (let i = 0; i < rootCs.length; i++) {
72
+ const prop = rootCs[i];
73
+ if (prop.startsWith('--')) {
74
+ cssVars[prop] = rootCs.getPropertyValue(prop).trim();
75
+ }
76
+ }
77
+
78
+ return { slides: results, cssVars };
79
+
80
+ function collectSection(root, index) {
81
+ const secRect = root.getBoundingClientRect();
82
+ const off = { x: secRect.left, y: secRect.top };
83
+ const elements = [];
84
+ const warnings = [];
85
+
86
+ // currentTarget is the array that emissions push into. Defaults to the
87
+ // slide's top-level `elements`. When walk() enters a flex/grid element
88
+ // (and flexAutoLayoutEnabled is on), it creates a `layoutContainer` node,
89
+ // pushes it to currentTarget, then routes descendant emissions into the
90
+ // container's own `children` array. Elements with position:absolute/fixed
91
+ // escape back to slide top-level regardless of surrounding containers.
92
+ let currentTarget = elements;
93
+ function pushElement(e) { currentTarget.push(e); }
94
+
95
+ walk(root);
96
+ insetTextsBehindLeftMarkers(elements);
97
+
98
+ return {
99
+ index,
100
+ dataLabel: root.getAttribute('data-label'),
101
+ background: getComputedStyle(root).backgroundColor,
102
+ elements,
103
+ warnings,
104
+ };
105
+
106
+ // Walk the emitted tree (top-level + nested layoutContainer children)
107
+ // looking for bullet-marker ellipses whose left edge collides with a
108
+ // text element's left edge. When found, inset the text past the marker
109
+ // so the dot is not visually overlapped by the first word. We only move
110
+ // the left edge — preserving the text's right edge and width preserves
111
+ // the source's wrapping intent (e.g. an orphan <p> intended to span
112
+ // full slide width remains full-width).
113
+ function insetTextsBehindLeftMarkers(nodes) {
114
+ const BULLET_GAP = 10;
115
+ const inflow = (nodes ?? []).filter(Boolean);
116
+ for (const marker of inflow) {
117
+ if (!marker || !marker._leftMarker) continue;
118
+ const my0 = marker.y;
119
+ const my1 = marker.y + marker.height;
120
+ const mx0 = marker.x;
121
+ const mx1 = marker.x + marker.width;
122
+ for (const el of inflow) {
123
+ if (el === marker) continue;
124
+ if (el.type !== 'text' && el.type !== 'richText') continue;
125
+ if (typeof el.x !== 'number' || typeof el.width !== 'number') continue;
126
+ const ey0 = el.y;
127
+ const ey1 = el.y + (el.height ?? 0);
128
+ const verticalOverlap = Math.min(ey1, my1) - Math.max(ey0, my0);
129
+ if (verticalOverlap <= 0) continue;
130
+ if (Math.abs(el.x - mx0) > 2) continue;
131
+ const inset = mx1 - el.x + BULLET_GAP;
132
+ el.x = el.x + inset;
133
+ el.width = Math.max(0, el.width - inset);
134
+ }
135
+ }
136
+ for (const el of inflow) {
137
+ if (el && el.type === 'layoutContainer' && Array.isArray(el.children)) {
138
+ insetTextsBehindLeftMarkers(el.children);
139
+ }
140
+ if (el) delete el._leftMarker;
141
+ }
142
+ }
143
+
144
+ function toLocal(rect) {
145
+ return {
146
+ x: rect.left - off.x,
147
+ y: rect.top - off.y,
148
+ width: rect.width,
149
+ height: rect.height,
150
+ };
151
+ }
152
+
153
+ function walk(el) {
154
+ if (!el || el.nodeType !== 1) return;
155
+ const tag = el.tagName.toUpperCase();
156
+ if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'TEMPLATE' || tag === 'HEAD') return;
157
+
158
+ const cs = getComputedStyle(el);
159
+ if (cs.display === 'none' || cs.visibility === 'hidden') return;
160
+
161
+ const rect = toLocal(el.getBoundingClientRect());
162
+ if (rect.width === 0 && rect.height === 0 && tag !== 'SECTION') {
163
+ return;
164
+ }
165
+
166
+ // Absolute-positioned subtrees escape any surrounding Auto Layout
167
+ // container: their emissions (and descendants') route to slide root
168
+ // so their authored x/y coordinates are preserved verbatim.
169
+ const positionKind = cs.position;
170
+ const escapesContainer = positionKind === 'absolute' || positionKind === 'fixed';
171
+ const savedTargetEntry = currentTarget;
172
+ if (escapesContainer) currentTarget = elements;
173
+ try {
174
+
175
+ emitPseudo(el, cs, '::before', rect);
176
+
177
+ if (tag === 'IMG') {
178
+ const src = el.getAttribute('src');
179
+ if (src) {
180
+ // <img src="data:image/svg+xml;..."> holds vector wordmark/logo
181
+ // paths inline. Routing it through the raster image pipeline
182
+ // bakes it to pixels at the current display size, and any zoom
183
+ // in Figma shows aliasing. Decode the data URL and re-emit as
184
+ // a native SVG so the handoff turns it into Figma VECTOR nodes.
185
+ const svgInline = decodeSvgDataUrl(src);
186
+ if (svgInline) {
187
+ const vb = svgInline.match(/viewBox\s*=\s*(['"])([^'"]+)\1/i);
188
+ pushElement({
189
+ type: 'svg',
190
+ x: rect.x, y: rect.y, width: rect.width, height: rect.height,
191
+ viewBox: vb ? vb[2] : `0 0 ${rect.width} ${rect.height}`,
192
+ inline: svgInline,
193
+ ...(parseFloat(cs.opacity) < 1 ? { opacity: parseFloat(cs.opacity) } : {}),
194
+ });
195
+ } else {
196
+ pushElement({
197
+ type: 'image',
198
+ src,
199
+ x: rect.x, y: rect.y, width: rect.width, height: rect.height,
200
+ objectFit: cs.objectFit || 'contain',
201
+ opacity: cs.opacity,
202
+ });
203
+ }
204
+ } else {
205
+ warnings.push({ msg: '<img> without src — dropped', sample: elPath(el) });
206
+ }
207
+ } else if (tag === 'SVG' || tag === 'svg') {
208
+ // CSS `opacity` cascades as a group effect — the SVG's own computed
209
+ // opacity is 1, but an ancestor like `.s1-deco { opacity: 0.12 }`
210
+ // fades everything inside. Climb to the slide <section> multiplying
211
+ // opacities so decorative SVGs render at the right visual weight.
212
+ let eff = 1;
213
+ for (let a = el; a && a.tagName?.toUpperCase() !== 'SECTION'; a = a.parentElement) {
214
+ const ocs = getComputedStyle(a);
215
+ const op = parseFloat(ocs.opacity);
216
+ if (!Number.isNaN(op)) eff *= op;
217
+ }
218
+ pushElement({
219
+ type: 'svg',
220
+ x: rect.x, y: rect.y, width: rect.width, height: rect.height,
221
+ viewBox: el.getAttribute('viewBox') || `0 0 ${rect.width} ${rect.height}`,
222
+ inline: el.outerHTML,
223
+ ...(eff < 1 ? { opacity: eff } : {}),
224
+ });
225
+ } else {
226
+ if (tag !== 'SECTION') maybeEmitShape(el, cs, rect);
227
+ collectWarnings(el, cs);
228
+
229
+ if (tag === 'SECTION') {
230
+ for (const c of el.children) walk(c);
231
+ } else {
232
+ const role = classifyTextRole(el);
233
+ if (role === 'leaf') {
234
+ emitTextLeaf(el, cs, rect);
235
+ } else if (role === 'mixed-inline') {
236
+ emitInlineRuns(el, cs);
237
+ } else {
238
+ // role === 'container'. When enabled and the element lays out
239
+ // its children via flex or single-axis grid, emit a
240
+ // `layoutContainer` node and route descendant emissions into its
241
+ // own `children` array. The handoff renders this as a Figma
242
+ // Auto Layout frame; Chromium↔Figma glyph-metric divergence is
243
+ // absorbed by re-flow inside the frame instead of causing a
244
+ // child to cross a sibling's pre-computed coordinate.
245
+ const wrap = flexAutoLayoutEnabled
246
+ ? maybeBuildLayoutContainer(el, cs, rect)
247
+ : null;
248
+ if (wrap) {
249
+ pushElement(wrap);
250
+ const savedChildTarget = currentTarget;
251
+ currentTarget = wrap.children;
252
+ try {
253
+ for (const c of el.children) walk(c);
254
+ emitDirectTextInContainer(el, cs);
255
+ } finally {
256
+ currentTarget = savedChildTarget;
257
+ }
258
+ // Reconcile the declared CSS `gap` against the actual
259
+ // inter-emitted-child spacing. DOM children can be back-to-
260
+ // back with gap=0 while the text RUNS inside them are
261
+ // visually separated by padding / fixed-width / margins.
262
+ // Auto Layout would otherwise pack the emitted text runs
263
+ // with no space (e.g. "1Executive Summary"). Use the
264
+ // measured spacing between emitted siblings instead.
265
+ reconcileActualGap(wrap);
266
+ // Post-emit overlap check. The pre-emit childrenMatchDeclaredGap
267
+ // only sees the DOM children of `el`. But when a DOM child is
268
+ // itself a composite (e.g. an icon whose background ellipse +
269
+ // rotated bars both end up in our layoutContainer.children via
270
+ // the maybeEmitShape + child-walk path), the EMITTED children
271
+ // can overlap even if the DOM children don't. If so, unwrap.
272
+ if (emittedChildrenOverlap(wrap)) {
273
+ const idx = savedChildTarget.lastIndexOf(wrap);
274
+ if (idx !== -1) {
275
+ savedChildTarget.splice(idx, 1, ...wrap.children);
276
+ }
277
+ }
278
+ } else {
279
+ for (const c of el.children) walk(c);
280
+ // Flex/block containers can also hold direct text adjacent to
281
+ // block children (e.g. a legend row with a colored swatch div
282
+ // plus a plain "Measured demand" label). The for-children loop
283
+ // above only visits Element nodes, so direct text would otherwise
284
+ // be lost. Emit each non-empty text node as its own text leaf.
285
+ emitDirectTextInContainer(el, cs);
286
+ }
287
+ }
288
+ }
289
+ }
290
+
291
+ emitPseudo(el, cs, '::after', rect);
292
+
293
+ } finally {
294
+ currentTarget = savedTargetEntry;
295
+ }
296
+ }
297
+
298
+ // Returns a layoutContainer descriptor for a flex / single-axis-grid
299
+ // element, or null if the element should be emitted flat (2-D grid,
300
+ // unmapped justify-content). The returned object has an empty
301
+ // `children` array that the caller fills by recursion.
302
+ function maybeBuildLayoutContainer(el, cs, rect) {
303
+ const d = cs.display;
304
+ const isFlex = d === 'flex';
305
+ // Grids are intentionally NOT wrapped. CSS grid with
306
+ // `grid-template-columns: auto 1fr` and implicit rows is effectively
307
+ // 2-D — Figma Auto Layout has no 2-D equivalent. Wrapping flattens
308
+ // children into a single axis, which wrecks charts whose axis labels
309
+ // sit at authored x/y inside the grid cell. Flex is a direct
310
+ // analogue of Auto Layout; grid is not.
311
+ if (!isFlex) return null;
312
+
313
+ const direction = (cs.flexDirection || '').startsWith('column') ? 'COLUMN' : 'ROW';
314
+
315
+ const gap = parseFloat(cs.rowGap || cs.columnGap || cs.gap || '0') || 0;
316
+ const primary = mapJustifyContent(cs.justifyContent);
317
+ if (primary === null) return null; // unsupported; fall back to flat
318
+
319
+ // Only wrap when the authored flex layout is actually uniform. A flex
320
+ // container used as a positioning scaffold (e.g. a chart whose title,
321
+ // SVG, and legend sit at irregular y-offsets inside it) will have
322
+ // children whose inter-gaps diverge from the declared `gap` property.
323
+ // Auto Layout imposes uniform spacing, so wrapping these breaks
324
+ // visual composition. Fall back to flat emission in that case.
325
+ if (!childrenMatchDeclaredGap(el, direction, gap)) return null;
326
+
327
+ // Skip wrapping when the flex subtree contains an SVG. handleSvg
328
+ // decomposes the SVG into per-shape addText / addPath calls, so
329
+ // inside an Auto Layout frame each shape becomes its own Auto Layout
330
+ // sibling — chart axis labels end up stacked vertically instead of
331
+ // positioned inside the chart. These containers are almost always
332
+ // charts or diagrams that rely on internal absolute positioning.
333
+ if (containsSvgDescendant(el)) return null;
334
+
335
+ return {
336
+ type: 'layoutContainer',
337
+ direction,
338
+ gap,
339
+ x: rect.x, y: rect.y, width: rect.width, height: rect.height,
340
+ paddingTop: pxFromCs(cs.paddingTop),
341
+ paddingRight: pxFromCs(cs.paddingRight),
342
+ paddingBottom: pxFromCs(cs.paddingBottom),
343
+ paddingLeft: pxFromCs(cs.paddingLeft),
344
+ primaryAxisAlignItems: primary,
345
+ counterAxisAlignItems: mapAlignItems(cs.alignItems),
346
+ children: [],
347
+ };
348
+ }
349
+
350
+ // CSS align-items → Figma counterAxisAlignItems.
351
+ // Figma has MIN / CENTER / MAX / BASELINE. CSS `baseline` maps directly.
352
+ // CSS `stretch` has no Figma equivalent on counter-axis sizing mode; we
353
+ // fall back to MIN (top) which is the closest neutral.
354
+ function mapAlignItems(ai) {
355
+ switch (ai) {
356
+ case 'center': return 'CENTER';
357
+ case 'flex-end':
358
+ case 'end':
359
+ return 'MAX';
360
+ case 'baseline':
361
+ case 'first baseline':
362
+ case 'last baseline':
363
+ return 'BASELINE';
364
+ case 'flex-start':
365
+ case 'start':
366
+ case 'stretch':
367
+ default:
368
+ return 'MIN';
369
+ }
370
+ }
371
+
372
+ // Returns true iff the in-flow children of `el` are spaced along
373
+ // `direction` with a gap within 8px of the declared `gap`. Children
374
+ // with position:absolute/fixed are excluded — they escape to slide root
375
+ // in walk(). A single in-flow child trivially matches. Elements with
376
+ // no in-flow children are considered non-auto-layoutable (the wrapping
377
+ // adds no value and risks breaking other layers).
378
+ function childrenMatchDeclaredGap(el, direction, gap) {
379
+ const inflow = [];
380
+ for (const c of el.children) {
381
+ const ccs = getComputedStyle(c);
382
+ if (ccs.display === 'none' || ccs.visibility === 'hidden') continue;
383
+ if (ccs.position === 'absolute' || ccs.position === 'fixed') continue;
384
+ inflow.push(c);
385
+ }
386
+ // A flex container with fewer than 2 in-flow children serves no
387
+ // Auto Layout purpose — there is nothing to flow against. Wrapping
388
+ // the lone child adds a phantom parent frame whose hover-bounds are
389
+ // bigger than the child's select-bounds, which reads as a bug in
390
+ // Figma. Fall back to flat emission so the child is placed at its
391
+ // own rect.
392
+ if (inflow.length < 2) return false;
393
+ const axisStart = direction === 'ROW' ? 'left' : 'top';
394
+ const axisSize = direction === 'ROW' ? 'width' : 'height';
395
+ for (let i = 1; i < inflow.length; i++) {
396
+ const prev = inflow[i - 1].getBoundingClientRect();
397
+ const cur = inflow[i].getBoundingClientRect();
398
+ const prevEnd = prev[axisStart] + prev[axisSize];
399
+ const curStart = cur[axisStart];
400
+ const actual = curStart - prevEnd;
401
+ // Overlap on the flex axis means the container is being used as a
402
+ // positioning scaffold (e.g. an icon where a red dot has two
403
+ // rotated bars overlaid to form an X). Auto Layout would lay those
404
+ // overlapping children out as side-by-side siblings, splitting the
405
+ // icon into a scattered row. Fall back to flat emission.
406
+ if (actual < -2) return false;
407
+ if (Math.abs(actual - gap) > 8) return false;
408
+ }
409
+ return true;
410
+ }
411
+
412
+ // After children are emitted, replace the CSS-declared `gap` with
413
+ // the actual measured inter-child spacing along the flex axis. This
414
+ // handles the common pattern where a flex container has gap=0 but
415
+ // children have internal padding / min-width / fixed-width that
416
+ // creates visual spacing between the TEXT RUNS inside them. Without
417
+ // this step, Auto Layout packs text runs back-to-back and collapses
418
+ // authored visual gaps (e.g. TOC "1 Executive Summary" rendering
419
+ // as "1Executive Summary"). Uses the median of pairwise gaps so an
420
+ // odd outlier doesn't skew the result.
421
+ function reconcileActualGap(wrap) {
422
+ const kids = wrap.children ?? [];
423
+ if (kids.length < 2) return;
424
+ const axisStart = wrap.direction === 'ROW' ? 'x' : 'y';
425
+ const axisSize = wrap.direction === 'ROW' ? 'width' : 'height';
426
+ const gaps = [];
427
+ for (let i = 1; i < kids.length; i++) {
428
+ const prev = kids[i - 1];
429
+ const cur = kids[i];
430
+ if (typeof prev?.[axisStart] !== 'number') return;
431
+ if (typeof cur?.[axisStart] !== 'number') return;
432
+ const g = cur[axisStart] - (prev[axisStart] + (prev[axisSize] ?? 0));
433
+ if (g < 0) return; // overlap — let emittedChildrenOverlap unwrap
434
+ gaps.push(g);
435
+ }
436
+ if (gaps.length === 0) return;
437
+ gaps.sort((a, b) => a - b);
438
+ wrap.gap = Math.round(gaps[Math.floor(gaps.length / 2)]);
439
+ }
440
+
441
+ // After emission, verify no two children overlap on the flex axis.
442
+ // Auto Layout would lay overlapping children out as side-by-side
443
+ // siblings, which breaks composite icons (e.g. a red ✗ made from a
444
+ // circle + two rotated bars: the bars overlap the circle on the ROW
445
+ // axis). Nested layoutContainers are checked by their own x/width
446
+ // (not their descendants').
447
+ function emittedChildrenOverlap(wrap) {
448
+ const children = wrap.children ?? [];
449
+ if (children.length < 2) return false;
450
+ const axisStart = wrap.direction === 'ROW' ? 'x' : 'y';
451
+ const axisSize = wrap.direction === 'ROW' ? 'width' : 'height';
452
+ for (let i = 1; i < children.length; i++) {
453
+ const prev = children[i - 1];
454
+ const cur = children[i];
455
+ if (typeof prev?.[axisStart] !== 'number') continue;
456
+ if (typeof cur?.[axisStart] !== 'number') continue;
457
+ const prevEnd = prev[axisStart] + (prev[axisSize] ?? 0);
458
+ const curStart = cur[axisStart];
459
+ if (curStart - prevEnd < -2) return true;
460
+ }
461
+ return false;
462
+ }
463
+
464
+ function containsSvgDescendant(el) {
465
+ if (!el) return false;
466
+ if (el.querySelector && el.querySelector('svg')) return true;
467
+ return false;
468
+ }
469
+
470
+ function mapJustifyContent(jc) {
471
+ switch ((jc || 'flex-start').trim()) {
472
+ case 'flex-start':
473
+ case 'start':
474
+ case 'left':
475
+ case 'normal':
476
+ return 'MIN';
477
+ case 'center':
478
+ return 'CENTER';
479
+ case 'flex-end':
480
+ case 'end':
481
+ case 'right':
482
+ return 'MAX';
483
+ case 'space-between':
484
+ return 'SPACE_BETWEEN';
485
+ default:
486
+ return null; // space-around, space-evenly → fall back
487
+ }
488
+ }
489
+
490
+ function classifyTextRole(el) {
491
+ let hasDirectText = false;
492
+ let hasBlockChild = false;
493
+ let hasInlineChild = false;
494
+ let hasVisualChild = false;
495
+
496
+ for (const c of el.childNodes) {
497
+ if (c.nodeType === 3) {
498
+ if (c.textContent && c.textContent.trim()) hasDirectText = true;
499
+ } else if (c.nodeType === 1) {
500
+ const ct = c.tagName.toUpperCase();
501
+ if (ct === 'BR') continue;
502
+ if (ct === 'SCRIPT' || ct === 'STYLE' || ct === 'TEMPLATE') continue;
503
+ const ccs = getComputedStyle(c);
504
+ if (ccs.display === 'none') continue;
505
+ if (ct === 'SVG' || ct === 'IMG' || ct === 'CANVAS' || ct === 'VIDEO') {
506
+ hasVisualChild = true;
507
+ }
508
+ // Empty inline element whose geometry + fill/border come purely
509
+ // from CSS (e.g. a colour swatch <span>) is a visual child too.
510
+ // Without this, an ancestor like <td> classifies as a text "leaf"
511
+ // and emitTextLeaf silently drops the span without walking it,
512
+ // so the swatch never emits a rect.
513
+ if (isCssOnlyVisual(c, ccs)) hasVisualChild = true;
514
+ if (['inline', 'inline-block', 'inline-flex'].includes(ccs.display)) {
515
+ hasInlineChild = true;
516
+ } else {
517
+ hasBlockChild = true;
518
+ }
519
+ }
520
+ }
521
+
522
+ if (hasBlockChild) return 'container';
523
+ if (hasVisualChild) return 'container';
524
+ if (!hasDirectText && !hasInlineChild) return 'container';
525
+
526
+ if (hasInlineChild && divergent(el)) return 'mixed-inline';
527
+ return 'leaf';
528
+ }
529
+
530
+ function isCssOnlyVisual(c, ccs) {
531
+ const text = (c.textContent || '').trim();
532
+ if (text) return false;
533
+ // Has the element got any non-text children that would themselves
534
+ // render? If so leave classification to the normal path.
535
+ for (const cc of c.childNodes) {
536
+ if (cc.nodeType === 1) return false;
537
+ }
538
+ const w = pxFromCs(ccs.width);
539
+ const h = pxFromCs(ccs.height);
540
+ if (w <= 0 || h <= 0) {
541
+ const rect = c.getBoundingClientRect && c.getBoundingClientRect();
542
+ if (!rect || rect.width <= 0 || rect.height <= 0) return false;
543
+ }
544
+ if (isVisibleColor(ccs.backgroundColor)) return true;
545
+ const borderW = pxFromCs(ccs.borderTopWidth) + pxFromCs(ccs.borderRightWidth)
546
+ + pxFromCs(ccs.borderBottomWidth) + pxFromCs(ccs.borderLeftWidth);
547
+ if (borderW > 0) return true;
548
+ return false;
549
+ }
550
+
551
+ function divergent(el) {
552
+ const pcs = getComputedStyle(el);
553
+ const ref = {
554
+ fontSize: pcs.fontSize,
555
+ color: pcs.color,
556
+ fontWeight: pcs.fontWeight,
557
+ fontStyle: pcs.fontStyle,
558
+ };
559
+ for (const c of el.childNodes) {
560
+ if (c.nodeType !== 1) continue;
561
+ if (c.tagName.toUpperCase() === 'BR') continue;
562
+ const ccs = getComputedStyle(c);
563
+ if (ccs.display === 'none') continue;
564
+ if (
565
+ ccs.fontSize !== ref.fontSize ||
566
+ ccs.color !== ref.color ||
567
+ ccs.fontWeight !== ref.fontWeight ||
568
+ ccs.fontStyle !== ref.fontStyle
569
+ ) {
570
+ return true;
571
+ }
572
+ }
573
+ return false;
574
+ }
575
+
576
+ function countTextLines(el) {
577
+ const range = document.createRange();
578
+ range.selectNodeContents(el);
579
+ const rects = range.getClientRects();
580
+ if (rects.length <= 1) return rects.length;
581
+ const tops = new Set();
582
+ for (const rc of rects) tops.add(Math.round(rc.top));
583
+ return tops.size;
584
+ }
585
+
586
+ // For elements classified as 'container' that also carry direct text
587
+ // nodes in childNodes (flex row with an element sibling + a raw text
588
+ // label). Each non-empty direct text node becomes its own text leaf,
589
+ // positioned via a Range on that node so the rect is tight to the
590
+ // rendered glyphs. Whitespace-only text nodes are skipped.
591
+ function emitDirectTextInContainer(el, cs) {
592
+ for (const c of el.childNodes) {
593
+ if (c.nodeType !== 3) continue;
594
+ const text = collapseText(c.textContent);
595
+ if (!text) continue;
596
+ const range = document.createRange();
597
+ range.selectNode(c);
598
+ const tr = range.getBoundingClientRect();
599
+ if (tr.width === 0 && tr.height === 0) continue;
600
+ const style = textStyle(cs);
601
+ const el2 = {
602
+ type: 'text',
603
+ text,
604
+ x: tr.left - off.x,
605
+ y: tr.top - off.y,
606
+ width: tr.width,
607
+ height: tr.height,
608
+ ...style,
609
+ };
610
+ // Text next to a block sibling in a flex row is effectively
611
+ // single-line; prevent Figma from wrapping it.
612
+ const lineTops = new Set();
613
+ for (const lr of range.getClientRects()) lineTops.add(Math.round(lr.top));
614
+ if (lineTops.size <= 1 && !style.align) el2.noWrap = true;
615
+ pushElement(el2);
616
+ }
617
+ }
618
+
619
+ function emitTextLeaf(el, cs, rect) {
620
+ const hasHardBreak = [...el.childNodes].some(
621
+ (c) => c.nodeType === 1 && c.tagName?.toUpperCase?.() === 'BR',
622
+ );
623
+ const text = hasHardBreak
624
+ ? collapseTextPreservingBreaks(el.innerText ?? el.textContent ?? '')
625
+ : collapseText(el.innerText ?? el.textContent ?? '');
626
+ if (!text) return;
627
+ const lines = countTextLines(el);
628
+ const style = textStyle(cs);
629
+ const el2 = {
630
+ type: 'text',
631
+ text,
632
+ x: rect.x, y: rect.y, width: rect.width, height: rect.height,
633
+ ...style,
634
+ };
635
+ const range = document.createRange();
636
+ range.selectNodeContents(el);
637
+ const rangeRects = [...range.getClientRects()];
638
+ if (lines === 1 && !style.align) {
639
+ const tr = range.getBoundingClientRect();
640
+ const textW = tr.width;
641
+ if (textW > 0 && Math.abs(textW - rect.width) < 2) {
642
+ el2.noWrap = true;
643
+ } else if (textW > 0 && textW < rect.width) {
644
+ el2.x = tr.left - off.x;
645
+ el2.y = tr.top - off.y;
646
+ el2.width = tr.width;
647
+ el2.height = tr.height;
648
+ el2.noWrap = true;
649
+ }
650
+ } else if (hasHardBreak && !style.align) {
651
+ let widest = 0;
652
+ for (const r of rangeRects) if (r.width > widest) widest = r.width;
653
+ if (widest > 0) {
654
+ const padded = Math.ceil(widest * 1.08) + 4;
655
+ if (padded > el2.width) el2.width = padded;
656
+ }
657
+ // Preserve authored line breaks while preventing Figma from
658
+ // wrapping each line again with slightly different font metrics.
659
+ el2.noWrap = true;
660
+ }
661
+ // Deliberately no padding for multi-line leaves: if Figma's wider
662
+ // font metrics force one extra wrap, the paragraph grows by a line,
663
+ // which is acceptable. Widening the box past its measured rect
664
+ // breaks column layouts (box bleeds into the next column).
665
+ pushElement(el2);
666
+ }
667
+
668
+ function squishInline(s) {
669
+ return String(s || '').replace(/\s+/g, ' ');
670
+ }
671
+
672
+ function emitInlineRuns(el, parentCs) {
673
+ emitInlineAsRichText(el, parentCs);
674
+ }
675
+
676
+ function emitInlineAsRichText(el, parentCs) {
677
+ const runs = [];
678
+ let hasHardBreak = false;
679
+ // Track trailing margin-right of the previous element child so the
680
+ // next sibling's run can be prefixed with a separator space. Without
681
+ // this, e.g. `<span mr:8>•</span>Body` collapses to "•Body" with no
682
+ // visible gap, even though Chrome renders 8px of space.
683
+ let pendingGap = 0;
684
+ for (const c of el.childNodes) {
685
+ if (c.nodeType === 3) {
686
+ const raw = squishInline(c.textContent);
687
+ if (!raw) continue;
688
+ const text = pendingGap > 0 && raw[0] !== ' ' ? ' ' + raw : raw;
689
+ runs.push({
690
+ text,
691
+ ...runStyle(parentCs),
692
+ });
693
+ pendingGap = 0;
694
+ } else if (c.nodeType === 1) {
695
+ const ct = c.tagName.toUpperCase();
696
+ if (ct === 'BR') { runs.push({ text: '\n' }); hasHardBreak = true; pendingGap = 0; continue; }
697
+ if (ct === 'SCRIPT' || ct === 'STYLE' || ct === 'TEMPLATE') continue;
698
+ const ccs = getComputedStyle(c);
699
+ if (ccs.display === 'none' || ccs.visibility === 'hidden') continue;
700
+ const raw = squishInline(c.textContent ?? '');
701
+ if (!raw) continue;
702
+ const myLeftGap = parseFloat(ccs.marginLeft) || 0;
703
+ const totalGap = pendingGap + myLeftGap;
704
+ const text = totalGap > 0 && raw[0] !== ' ' ? ' ' + raw : raw;
705
+ runs.push({
706
+ text,
707
+ ...runStyle(ccs),
708
+ });
709
+ pendingGap = parseFloat(ccs.marginRight) || 0;
710
+ }
711
+ }
712
+ if (runs.length === 0) return;
713
+ const rect = toLocal(el.getBoundingClientRect());
714
+ const el2 = {
715
+ type: 'richText',
716
+ runs,
717
+ x: rect.x, y: rect.y, width: rect.width, height: rect.height,
718
+ ...textStyle(parentCs),
719
+ };
720
+ // Figma's font metrics often come out a few pixels wider than
721
+ // Chromium's for the same string. A box Chromium sized tight will
722
+ // then wrap a single line into two (e.g. "847TWh" → "847TW" + "h" on
723
+ // the next row). For single-line runs with no hard break, pad the
724
+ // box and mark noWrap so Figma never splits mid-word.
725
+ //
726
+ // Use range.getClientRects() (one rect per visual line) rather than
727
+ // el.getClientRects() — on a block element the latter returns one
728
+ // rect regardless of how many lines the text wraps to, which would
729
+ // misclassify a wrapped paragraph as a single line.
730
+ const range = document.createRange();
731
+ range.selectNodeContents(el);
732
+ const rangeRects = [...range.getClientRects()];
733
+ const tops = new Set();
734
+ for (const rc of rangeRects) tops.add(Math.round(rc.top));
735
+ const lineCount = tops.size;
736
+ if (lineCount === 1 && !hasHardBreak) {
737
+ let widest = 0;
738
+ for (const r of rangeRects) if (r.width > widest) widest = r.width;
739
+ if (widest > 0) {
740
+ const padded = Math.ceil(widest * 1.08) + 4;
741
+ if (padded > el2.width) el2.width = padded;
742
+ }
743
+ el2.noWrap = true;
744
+ }
745
+ pushElement(el2);
746
+ }
747
+
748
+ function runStyle(cs) {
749
+ const out = {};
750
+ const w = parseInt(cs.fontWeight, 10);
751
+ if (Number.isFinite(w)) out.weight = w;
752
+ if (cs.fontStyle === 'italic') out.style = 'italic';
753
+ if (cs.color) out.color = cs.color;
754
+ return out;
755
+ }
756
+
757
+ function maybeEmitShape(el, cs, rect) {
758
+ if (rect.width <= 0 || rect.height <= 0) return;
759
+ const bg = cs.backgroundColor;
760
+ const hasBg = isVisibleColor(bg);
761
+ const bgImage = cs.backgroundImage;
762
+ const bgLayers = parseBackgroundImage(bgImage, warnings, el);
763
+ const sides = ['top', 'right', 'bottom', 'left']
764
+ .map((side) => ({
765
+ side,
766
+ width: pxFromCs(cs.getPropertyValue(`border-${side}-width`)),
767
+ style: cs.getPropertyValue(`border-${side}-style`),
768
+ color: cs.getPropertyValue(`border-${side}-color`),
769
+ }))
770
+ .filter((b) => b.width > 0 && b.style !== 'none' && isVisibleColor(b.color));
771
+
772
+ if (!hasBg && !bgLayers.length && sides.length === 0) return;
773
+
774
+ const radius = pxFromCs(cs.borderRadius);
775
+ const minSide = Math.min(rect.width, rect.height);
776
+ const maxSide = Math.max(rect.width, rect.height);
777
+ const isEllipse =
778
+ minSide > 0 &&
779
+ minSide / maxSide >= 0.8 &&
780
+ radius > 0 &&
781
+ radius >= minSide / 2 - 0.5;
782
+
783
+ // All four borders identical → emit a single stroked shape instead of
784
+ // four axis-aligned side rects, which can't form a rounded outline.
785
+ const uniformBorder = sides.length === 4
786
+ && sides.every((b) => b.width === sides[0].width && b.color === sides[0].color)
787
+ ? sides[0] : null;
788
+
789
+ // Peel an alpha channel off an rgba() background and surface it as
790
+ // element.opacity so api.mjs's parseColor (6-hex-only) still accepts
791
+ // the fill. Skip hex inputs (already opaque).
792
+ let emitFill = hasBg ? bg : undefined;
793
+ let fillAlpha = 1;
794
+ if (hasBg) {
795
+ const m = /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/i.exec(bg);
796
+ if (m) {
797
+ const r = Math.round(parseFloat(m[1]));
798
+ const g = Math.round(parseFloat(m[2]));
799
+ const b = Math.round(parseFloat(m[3]));
800
+ fillAlpha = m[4] !== undefined ? parseFloat(m[4]) : 1;
801
+ emitFill = '#' + [r, g, b].map((n) => n.toString(16).padStart(2, '0')).join('').toUpperCase();
802
+ }
803
+ }
804
+
805
+ const blurRadius = parseBlurFilter(cs.filter);
806
+ if (hasBg || bgLayers.length || uniformBorder) {
807
+ const e = {
808
+ type: isEllipse ? 'ellipse' : 'rect',
809
+ x: rect.x, y: rect.y, width: rect.width, height: rect.height,
810
+ };
811
+ if (hasBg) e.fill = emitFill;
812
+ if (hasBg && fillAlpha < 1) e.opacity = fillAlpha;
813
+ if (bgLayers.length) e.backgroundLayers = bgLayers;
814
+ if (radius > 0 && !isEllipse) e.cornerRadius = radius;
815
+ if (uniformBorder) {
816
+ e.stroke = uniformBorder.color;
817
+ e.strokeWidth = uniformBorder.width;
818
+ }
819
+ if (blurRadius != null) e.filter = { blur: blurRadius };
820
+ pushElement(e);
821
+ }
822
+ if (!uniformBorder) {
823
+ for (const b of sides) {
824
+ let r;
825
+ if (b.side === 'top') r = { type: 'rect', x: rect.x, y: rect.y, width: rect.width, height: b.width, fill: b.color };
826
+ else if (b.side === 'bottom') r = { type: 'rect', x: rect.x, y: rect.y + rect.height - b.width, width: rect.width, height: b.width, fill: b.color };
827
+ else if (b.side === 'left') r = { type: 'rect', x: rect.x, y: rect.y, width: b.width, height: rect.height, fill: b.color };
828
+ else if (b.side === 'right') r = { type: 'rect', x: rect.x + rect.width - b.width, y: rect.y, width: b.width, height: rect.height, fill: b.color };
829
+ if (r) pushElement(r);
830
+ }
831
+ }
832
+ }
833
+
834
+ function emitPseudo(host, hostCs, which, hostRect) {
835
+ const cs = getComputedStyle(host, which);
836
+ const content = cs.content;
837
+ if (!content || content === 'none' || content === 'normal') return;
838
+
839
+ let text = content;
840
+ const q = text.match(/^(['"])([\s\S]*)\1$/);
841
+ if (q) text = q[2];
842
+ else if (/^(attr|counter|counters|url|var)\s*\(/.test(text)) {
843
+ warnings.push({ msg: `pseudo content uses ${text.match(/^(\w+)/)[1]}() — not rendered`, sample: elPath(host) });
844
+ return;
845
+ }
846
+
847
+ const left = pxFromCs(cs.left);
848
+ const top = pxFromCs(cs.top);
849
+ const right = pxFromCs(cs.right);
850
+ const bottom = pxFromCs(cs.bottom);
851
+ const w = pxFromCs(cs.width);
852
+ const h = pxFromCs(cs.height);
853
+ const pos = cs.position;
854
+
855
+ let px = hostRect.x;
856
+ let py = hostRect.y;
857
+ if (pos === 'absolute') {
858
+ if (left > 0 || cs.left !== 'auto') px = hostRect.x + left;
859
+ else if (cs.right !== 'auto' && w > 0) px = hostRect.x + hostRect.width - right - w;
860
+ if (top > 0 || cs.top !== 'auto') py = hostRect.y + top;
861
+ else if (cs.bottom !== 'auto' && h > 0) py = hostRect.y + hostRect.height - bottom - h;
862
+ }
863
+
864
+ const fontSize = pxFromCs(cs.fontSize) || 16;
865
+ const pw = w > 0 ? w : (text === '' ? 0 : Math.ceil(text.length * fontSize * 0.6));
866
+ const ph = h > 0 ? h : (text === '' ? 0 : Math.ceil(fontSize * 1.2));
867
+
868
+ if (text === '') {
869
+ if (pw <= 0 || ph <= 0) return;
870
+ const bg = cs.backgroundColor;
871
+ if (!isVisibleColor(bg)) return;
872
+ const minSide = Math.min(pw, ph);
873
+ const maxSide = Math.max(pw, ph);
874
+ const radius = pxFromCs(cs.borderRadius);
875
+ const isEllipse =
876
+ minSide / maxSide >= 0.8 &&
877
+ radius >= minSide / 2 - 0.5;
878
+ // Mark absolute-positioned ::before dots at the host's left edge as
879
+ // "bullet markers" so a later pass can inset any host text whose box
880
+ // collides with them. Source HTML sometimes drops a <p> outside its
881
+ // <ul> — the author's CSS positions a • via ::before but forgets
882
+ // padding-left on that paragraph, so the dot overlaps the first word.
883
+ const isMarkerCandidate =
884
+ which === '::before' &&
885
+ pos === 'absolute' &&
886
+ isEllipse &&
887
+ maxSide <= 16 &&
888
+ Math.abs(px - hostRect.x) <= 2;
889
+ const node = {
890
+ type: isEllipse ? 'ellipse' : 'rect',
891
+ x: px, y: py, width: pw, height: ph,
892
+ fill: bg,
893
+ };
894
+ if (isMarkerCandidate) node._leftMarker = true;
895
+ pushElement(node);
896
+ return;
897
+ }
898
+
899
+ const pstyle = textStyle(cs);
900
+ const pel = {
901
+ type: 'text',
902
+ text,
903
+ x: px, y: py, width: pw, height: ph,
904
+ ...pstyle,
905
+ };
906
+ if (!pstyle.align) pel.noWrap = true;
907
+ pushElement(pel);
908
+ }
909
+
910
+ function collectWarnings(el, cs) {
911
+ if (cs.transform && cs.transform !== 'none') {
912
+ // getBoundingClientRect() already reflects CSS transforms, so a pure
913
+ // translation is effectively already baked into the rect we
914
+ // extracted. Only warn for rotate/scale/skew, which we can't yet
915
+ // represent in the deck.
916
+ if (!isPureTranslateTransform(cs.transform)) {
917
+ warnings.push({ msg: `transform:${cs.transform} (non-translate) ignored`, sample: elPath(el) });
918
+ }
919
+ }
920
+ if (cs.clipPath && cs.clipPath !== 'none') {
921
+ warnings.push({ msg: `clip-path ignored`, sample: elPath(el) });
922
+ }
923
+ if (cs.filter && cs.filter !== 'none') {
924
+ // blur() is now supported (mapped to Figma FOREGROUND_BLUR at
925
+ // dispatch time). Every other filter value still warns.
926
+ if (parseBlurFilter(cs.filter) == null) {
927
+ warnings.push({ msg: `filter:${cs.filter} ignored`, sample: elPath(el) });
928
+ }
929
+ }
930
+ if (cs.mixBlendMode && cs.mixBlendMode !== 'normal') {
931
+ warnings.push({ msg: `mix-blend-mode:${cs.mixBlendMode} ignored`, sample: elPath(el) });
932
+ }
933
+ }
934
+
935
+ function textStyle(cs) {
936
+ const size = pxFromCs(cs.fontSize);
937
+ const lh = parseLineHeight(cs.lineHeight, size);
938
+ const out = {
939
+ font: cs.fontFamily,
940
+ size,
941
+ weight: parseInt(cs.fontWeight, 10) || undefined,
942
+ };
943
+ if (cs.fontStyle === 'italic') out.style = 'italic';
944
+ if (cs.color) out.color = cs.color;
945
+ const ls = pxFromCs(cs.letterSpacing);
946
+ if (ls) out.letterSpacing = ls;
947
+ if (lh != null) out.lineHeight = lh;
948
+ const al = cs.textAlign;
949
+ if (al && al !== 'start' && al !== 'left' && al !== 'auto') out.align = al;
950
+ // CSS vertical-align on table cells / flex / inline-block maps to
951
+ // Figma's textAlignVertical. Only emit for the block-valign values
952
+ // Figma supports; baseline/sub/super are inline-box adjustments that
953
+ // don't translate to Figma's text alignment.
954
+ const va = cs.verticalAlign;
955
+ if (va === 'middle') out.verticalAlign = 'middle';
956
+ else if (va === 'bottom') out.verticalAlign = 'bottom';
957
+ const op = parseFloat(cs.opacity);
958
+ if (!Number.isNaN(op) && op < 1) out.opacity = op;
959
+ return out;
960
+ }
961
+
962
+ function parseLineHeight(v, fontSize) {
963
+ if (!v || v === 'normal') return undefined;
964
+ const n = pxFromCs(v);
965
+ return Number.isFinite(n) ? n : undefined;
966
+ }
967
+
968
+ // True iff the computed `transform` value is equivalent to a pure
969
+ // 2D translation. getBoundingClientRect already includes it.
970
+ function isPureTranslateTransform(tr) {
971
+ if (!tr || tr === 'none') return true;
972
+ const s = String(tr).trim();
973
+ if (/^translate(X|Y|3d)?\s*\(/.test(s)) {
974
+ if (/^translate3d\s*\(/.test(s)) {
975
+ const parts = s.replace(/^translate3d\s*\(/, '').replace(/\)\s*$/, '').split(',');
976
+ const z = parts[2] ? parseFloat(parts[2]) : 0;
977
+ return Math.abs(z) < 1e-6;
978
+ }
979
+ return true;
980
+ }
981
+ const m2 = s.match(/^matrix\s*\(\s*([^)]+)\)\s*$/);
982
+ if (m2) {
983
+ const p = m2[1].split(',').map((v) => parseFloat(v.trim()));
984
+ if (p.length !== 6 || p.some((v) => !Number.isFinite(v))) return false;
985
+ const [a, b, c, d] = p;
986
+ return (
987
+ Math.abs(a - 1) < 1e-6 &&
988
+ Math.abs(b) < 1e-6 &&
989
+ Math.abs(c) < 1e-6 &&
990
+ Math.abs(d - 1) < 1e-6
991
+ );
992
+ }
993
+ const m3 = s.match(/^matrix3d\s*\(\s*([^)]+)\)\s*$/);
994
+ if (m3) {
995
+ const p = m3[1].split(',').map((v) => parseFloat(v.trim()));
996
+ if (p.length !== 16 || p.some((v) => !Number.isFinite(v))) return false;
997
+ const expectOne = [0, 5, 10, 15];
998
+ for (let i = 0; i < 16; i++) {
999
+ if (i === 12 || i === 13 || i === 14) continue;
1000
+ const want = expectOne.includes(i) ? 1 : 0;
1001
+ if (Math.abs(p[i] - want) >= 1e-6) return false;
1002
+ }
1003
+ return true;
1004
+ }
1005
+ return false;
1006
+ }
1007
+
1008
+ // Parse `cs.backgroundImage` into layer descriptors, CSS painting order
1009
+ // (first = topmost). Recognised: linear-gradient, radial-gradient.
1010
+ // url(...) is silently accepted but not rendered (falls back to bg-color).
1011
+ function parseBackgroundImage(bgImage, warnings, el) {
1012
+ if (!bgImage || bgImage === 'none') return [];
1013
+ const layers = splitTopLevel(bgImage, ',');
1014
+ const out = [];
1015
+ for (const raw of layers) {
1016
+ const layer = raw.trim();
1017
+ if (!layer || layer === 'none') continue;
1018
+ const headMatch = /^([a-z-]+)\s*\(([\s\S]*)\)\s*$/i.exec(layer);
1019
+ if (!headMatch) {
1020
+ warnings?.push({ msg: `background-image layer not a gradient/url — ignored`, sample: elPath(el) });
1021
+ continue;
1022
+ }
1023
+ const kind = headMatch[1].toLowerCase();
1024
+ const inner = headMatch[2];
1025
+ if (kind === 'linear-gradient') {
1026
+ const g = parseCssLinearGradient(inner);
1027
+ if (g) out.push(g);
1028
+ } else if (kind === 'radial-gradient') {
1029
+ const g = parseCssRadialGradient(inner);
1030
+ if (g) out.push(g);
1031
+ } else if (kind === 'url') {
1032
+ continue;
1033
+ }
1034
+ }
1035
+ return out;
1036
+ }
1037
+
1038
+ function splitTopLevel(str, sep) {
1039
+ const out = [];
1040
+ let depth = 0;
1041
+ let start = 0;
1042
+ for (let i = 0; i < str.length; i++) {
1043
+ const c = str[i];
1044
+ if (c === '(') depth++;
1045
+ else if (c === ')') depth = Math.max(0, depth - 1);
1046
+ else if (c === sep && depth === 0) {
1047
+ out.push(str.slice(start, i));
1048
+ start = i + 1;
1049
+ }
1050
+ }
1051
+ out.push(str.slice(start));
1052
+ return out;
1053
+ }
1054
+
1055
+ function parseCssLinearGradient(body) {
1056
+ const parts = splitTopLevel(body, ',').map(s => s.trim());
1057
+ if (parts.length === 0) return null;
1058
+ let angleDeg = 180;
1059
+ let first = parts[0];
1060
+ const angleMatch = /^(-?\d+(?:\.\d+)?)deg$/i.exec(first);
1061
+ const toMatch = /^to\s+(.+)$/i.exec(first);
1062
+ if (angleMatch) {
1063
+ angleDeg = parseFloat(angleMatch[1]);
1064
+ parts.shift();
1065
+ } else if (toMatch) {
1066
+ angleDeg = sideToAngleDeg(toMatch[1].trim().toLowerCase());
1067
+ parts.shift();
1068
+ } else if (/^(-?\d+(?:\.\d+)?)(rad|grad|turn)$/i.test(first)) {
1069
+ const um = /^(-?\d+(?:\.\d+)?)(rad|grad|turn)$/i.exec(first);
1070
+ const v = parseFloat(um[1]);
1071
+ const u = um[2].toLowerCase();
1072
+ if (u === 'rad') angleDeg = v * 180 / Math.PI;
1073
+ else if (u === 'grad') angleDeg = v * 0.9;
1074
+ else if (u === 'turn') angleDeg = v * 360;
1075
+ parts.shift();
1076
+ }
1077
+ const stops = parseColorStops(parts);
1078
+ if (!stops.length) return null;
1079
+ return { kind: 'linear', angleDeg, stops };
1080
+ }
1081
+
1082
+ function sideToAngleDeg(side) {
1083
+ switch (side) {
1084
+ case 'top': return 0;
1085
+ case 'right': return 90;
1086
+ case 'bottom': return 180;
1087
+ case 'left': return 270;
1088
+ case 'top right': case 'right top': return 45;
1089
+ case 'bottom right': case 'right bottom': return 135;
1090
+ case 'bottom left': case 'left bottom': return 225;
1091
+ case 'top left': case 'left top': return 315;
1092
+ default: return 180;
1093
+ }
1094
+ }
1095
+
1096
+ function parseCssRadialGradient(body) {
1097
+ const parts = splitTopLevel(body, ',').map(s => s.trim());
1098
+ if (parts.length === 0) return null;
1099
+ let cx = 0.5, cy = 0.5, rx = 0.5, ry = 0.5;
1100
+ let stops;
1101
+ const head = parts[0];
1102
+ const looksLikeShape = /^(circle|ellipse)\b/i.test(head)
1103
+ || /\bat\b/i.test(head)
1104
+ || /^\d/.test(head)
1105
+ || /^-?\d*\.?\d+%/.test(head);
1106
+ if (looksLikeShape) {
1107
+ const shapePart = parts.shift();
1108
+ const atIdx = shapePart.search(/\bat\b/i);
1109
+ const sizeStr = atIdx >= 0 ? shapePart.slice(0, atIdx).trim() : shapePart.trim();
1110
+ const posStr = atIdx >= 0 ? shapePart.slice(atIdx + 2).trim() : '';
1111
+ const sizeTokens = sizeStr.split(/\s+/).filter(Boolean).filter(t => !/^(ellipse|circle)$/i.test(t));
1112
+ if (sizeTokens.length >= 2) {
1113
+ rx = cssLengthToUnit(sizeTokens[0], 'x');
1114
+ ry = cssLengthToUnit(sizeTokens[1], 'y');
1115
+ } else if (sizeTokens.length === 1) {
1116
+ const v = cssLengthToUnit(sizeTokens[0], 'x');
1117
+ rx = ry = v;
1118
+ }
1119
+ if (posStr) {
1120
+ const pos = parsePosition(posStr);
1121
+ cx = pos.x;
1122
+ cy = pos.y;
1123
+ }
1124
+ }
1125
+ stops = parseColorStops(parts);
1126
+ if (!stops.length) return null;
1127
+ if (!(rx > 0) || !(ry > 0)) return null;
1128
+ return { kind: 'radial', cx, cy, rx, ry, stops };
1129
+ }
1130
+
1131
+ function cssLengthToUnit(tok, axis) {
1132
+ const m = /^(-?\d*\.?\d+)(%|px)?$/.exec(tok);
1133
+ if (!m) return 0.5;
1134
+ const v = parseFloat(m[1]);
1135
+ const u = m[2] || 'px';
1136
+ if (u === '%') return v / 100;
1137
+ return 0.5;
1138
+ }
1139
+
1140
+ function parsePosition(str) {
1141
+ const toks = str.split(/\s+/).filter(Boolean);
1142
+ const map = { left: 0, center: 0.5, right: 1, top: 0, bottom: 1 };
1143
+ if (toks.length === 1) {
1144
+ const t = toks[0].toLowerCase();
1145
+ if (t in map) {
1146
+ const v = map[t];
1147
+ if (t === 'left' || t === 'right') return { x: v, y: 0.5 };
1148
+ if (t === 'top' || t === 'bottom') return { x: 0.5, y: v };
1149
+ return { x: 0.5, y: 0.5 };
1150
+ }
1151
+ const v = cssLengthToUnit(toks[0], 'x');
1152
+ return { x: v, y: 0.5 };
1153
+ }
1154
+ const x = toks[0] in map ? map[toks[0]] : cssLengthToUnit(toks[0], 'x');
1155
+ const y = toks[1] in map ? map[toks[1]] : cssLengthToUnit(toks[1], 'y');
1156
+ return { x, y };
1157
+ }
1158
+
1159
+ function parseColorStops(parts) {
1160
+ const raw = [];
1161
+ for (const p of parts) {
1162
+ const color = extractLeadingColor(p);
1163
+ if (!color) continue;
1164
+ const rest = p.slice(color.length).trim();
1165
+ const positions = [];
1166
+ if (rest) {
1167
+ const tokens = rest.split(/\s+/).filter(Boolean);
1168
+ for (const t of tokens) {
1169
+ const v = parsePositionValue(t);
1170
+ if (v != null) positions.push(v);
1171
+ }
1172
+ }
1173
+ if (positions.length === 0) {
1174
+ raw.push({ color, pos: null });
1175
+ } else {
1176
+ for (const pos of positions) raw.push({ color, pos });
1177
+ }
1178
+ }
1179
+ if (raw.length === 0) return [];
1180
+ if (raw[0].pos == null) raw[0].pos = 0;
1181
+ if (raw[raw.length - 1].pos == null) raw[raw.length - 1].pos = 1;
1182
+ for (let i = 0; i < raw.length; i++) {
1183
+ if (raw[i].pos == null) {
1184
+ let j = i;
1185
+ while (j < raw.length && raw[j].pos == null) j++;
1186
+ const startPos = raw[i - 1].pos;
1187
+ const endPos = raw[j].pos ?? 1;
1188
+ const gap = j - i + 1;
1189
+ for (let k = 0; k < j - i; k++) {
1190
+ raw[i + k].pos = startPos + (endPos - startPos) * (k + 1) / gap;
1191
+ }
1192
+ i = j - 1;
1193
+ }
1194
+ }
1195
+ return raw.map(s => ({ color: s.color, pos: Math.max(0, Math.min(1, s.pos)) }));
1196
+ }
1197
+
1198
+ function parsePositionValue(tok) {
1199
+ const m = /^(-?\d*\.?\d+)(%|px)?$/.exec(tok);
1200
+ if (!m) return null;
1201
+ const v = parseFloat(m[1]);
1202
+ const u = m[2] || '%';
1203
+ if (u === '%') return v / 100;
1204
+ return null;
1205
+ }
1206
+
1207
+ function extractLeadingColor(str) {
1208
+ const s = str.trim();
1209
+ if (s.startsWith('#')) {
1210
+ const m = /^#[0-9a-fA-F]+/.exec(s);
1211
+ return m ? m[0] : null;
1212
+ }
1213
+ const fn = /^([a-zA-Z]+)\s*\(/.exec(s);
1214
+ if (fn) {
1215
+ let depth = 0;
1216
+ for (let i = 0; i < s.length; i++) {
1217
+ if (s[i] === '(') depth++;
1218
+ else if (s[i] === ')') {
1219
+ depth--;
1220
+ if (depth === 0) return s.slice(0, i + 1);
1221
+ }
1222
+ }
1223
+ return null;
1224
+ }
1225
+ const name = /^[a-zA-Z]+/.exec(s);
1226
+ return name ? name[0] : null;
1227
+ }
1228
+
1229
+ // Parse `filter: blur(Npx)`. Returns the radius as a number, or null
1230
+ // if the filter value isn't a single blur(...) token. Multi-filter
1231
+ // strings like `blur(2px) drop-shadow(...)` return null because we
1232
+ // can't represent the composite in Figma yet.
1233
+ function parseBlurFilter(v) {
1234
+ if (!v || v === 'none') return null;
1235
+ const s = String(v).trim();
1236
+ const m = /^blur\(\s*(-?\d*\.?\d+)(?:px)?\s*\)$/i.exec(s);
1237
+ if (!m) return null;
1238
+ const n = parseFloat(m[1]);
1239
+ return Number.isFinite(n) && n > 0 ? n : null;
1240
+ }
1241
+
1242
+ function isVisibleColor(c) {
1243
+ if (!c) return false;
1244
+ if (c === 'transparent') return false;
1245
+ if (c === 'rgba(0, 0, 0, 0)') return false;
1246
+ return true;
1247
+ }
1248
+
1249
+ function pxFromCs(v) {
1250
+ if (v == null) return 0;
1251
+ const m = String(v).match(/(-?[\d.]+)/);
1252
+ return m ? parseFloat(m[1]) : 0;
1253
+ }
1254
+
1255
+ function collapseText(s) {
1256
+ return String(s || '').replace(/\s+/g, ' ').trim();
1257
+ }
1258
+
1259
+ function collapseTextPreservingBreaks(s) {
1260
+ const lines = String(s || '')
1261
+ .replace(/\r\n?/g, '\n')
1262
+ .split('\n')
1263
+ .map((line) => line.replace(/\s+/g, ' ').trim());
1264
+ while (lines.length > 0 && !lines[0]) lines.shift();
1265
+ while (lines.length > 0 && !lines[lines.length - 1]) lines.pop();
1266
+ return lines.join('\n');
1267
+ }
1268
+
1269
+ function elPath(el) {
1270
+ const t = (el.tagName || '').toLowerCase();
1271
+ const id = el.getAttribute?.('id');
1272
+ const cls = el.className && typeof el.className === 'string' ? el.className.split(/\s+/).slice(0, 2).join('.') : '';
1273
+ let s = t;
1274
+ if (id) s += '#' + id;
1275
+ else if (cls) s += '.' + cls;
1276
+ return s;
1277
+ }
1278
+ }
1279
+ }, { flexAutoLayout });
1280
+ }