openfig-cli 0.3.41 → 0.4.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.
- package/README.md +4 -1
- package/bin/cli.mjs +5 -0
- package/bin/commands/convert-html.mjs +44 -0
- package/bin/commands/create-deck.mjs +34 -0
- package/lib/core/fig-deck.mjs +39 -0
- package/lib/rasterizer/svg-builder.mjs +181 -41
- package/lib/slides/api.mjs +435 -63
- package/lib/slides/browser-extract.mjs +1280 -0
- package/lib/slides/empty-deck.mjs +354 -0
- package/lib/slides/handoff/bundle-loader.mjs +93 -0
- package/lib/slides/handoff/element-dispatch.mjs +1685 -0
- package/lib/slides/handoff-converter.mjs +321 -0
- package/lib/slides/html-converter.mjs +395 -0
- package/lib/slides/playwright-layout.mjs +169 -0
- package/mcp-server.mjs +36 -0
- package/package.json +4 -1
- package/lib/slides/blank-template.deck +0 -0
|
@@ -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
|
+
}
|