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,1685 @@
|
|
|
1
|
+
const SERIF = 'Georgia';
|
|
2
|
+
const SANS = 'Inter';
|
|
3
|
+
const BORDER = '#E8EAEE';
|
|
4
|
+
|
|
5
|
+
const NON_PORTABLE_FONT_TOKENS = new Set([
|
|
6
|
+
'blinkmacsystemfont',
|
|
7
|
+
'system-ui',
|
|
8
|
+
'ui-sans-serif',
|
|
9
|
+
'ui-serif',
|
|
10
|
+
'ui-monospace',
|
|
11
|
+
'ui-rounded',
|
|
12
|
+
'sans-serif',
|
|
13
|
+
'serif',
|
|
14
|
+
'monospace',
|
|
15
|
+
'cursive',
|
|
16
|
+
'fantasy',
|
|
17
|
+
'emoji',
|
|
18
|
+
'math',
|
|
19
|
+
'fangsong',
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
function isPortableFontToken(token) {
|
|
23
|
+
if (!token) return false;
|
|
24
|
+
if (token.startsWith('-')) return false;
|
|
25
|
+
return !NON_PORTABLE_FONT_TOKENS.has(token.toLowerCase());
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function drawLine(slide, x1, y1, x2, y2, opts = {}) {
|
|
29
|
+
const out = { name: 'Line' };
|
|
30
|
+
if (opts.stroke ?? opts.color) out.stroke = opts.stroke ?? opts.color;
|
|
31
|
+
if (opts.strokeWeight ?? opts.weight) out.strokeWeight = opts.strokeWeight ?? opts.weight;
|
|
32
|
+
if (opts.strokeCap) out.strokeCap = opts.strokeCap;
|
|
33
|
+
if (opts.dashPattern) out.dashPattern = opts.dashPattern;
|
|
34
|
+
return slide.addPath(`M ${x1} ${y1} L ${x2} ${y2}`, out);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function mapFont(family) {
|
|
38
|
+
// Pass the designer's chosen font through to the deck. The convert-time
|
|
39
|
+
// font-unavailability audit (html-converter.mjs auditFonts) warns when a
|
|
40
|
+
// font is unlikely to resolve in Figma. Coercing serifs to Georgia here
|
|
41
|
+
// was a pre-warning shortcut that destroyed the designer's intent and
|
|
42
|
+
// masked real font-substitution bugs — see openspec Phase 2 §font
|
|
43
|
+
// resolution.
|
|
44
|
+
if (!family) return SANS;
|
|
45
|
+
// Raw SVG font-family attrs can be a CSS stack (`"Inter, sans-serif"` or
|
|
46
|
+
// `"-apple-system, system-ui, Helvetica Neue, sans-serif"`). html-converter
|
|
47
|
+
// strips the stack for HTML text, but SVG text reaches us unnormalized.
|
|
48
|
+
// Walk past tokens Figma cannot resolve so we don't hand it `-apple-system`
|
|
49
|
+
// and trigger a wider Inter fallback.
|
|
50
|
+
if (typeof family === 'string' && family.includes(',')) {
|
|
51
|
+
const entries = family.split(',').map((t) => t.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean);
|
|
52
|
+
if (entries.length === 0) return SANS;
|
|
53
|
+
return entries.find(isPortableFontToken) ?? entries[0];
|
|
54
|
+
}
|
|
55
|
+
return family;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function mapFontStyle(weight, style) {
|
|
59
|
+
const heavy = typeof weight === 'number' ? weight >= 600 : false;
|
|
60
|
+
const italic = style === 'italic';
|
|
61
|
+
if (heavy && italic) return 'Bold Italic';
|
|
62
|
+
if (heavy) return 'Bold';
|
|
63
|
+
if (italic) return 'Italic';
|
|
64
|
+
return 'Regular';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function textOpts(el, ctx = {}) {
|
|
68
|
+
const opts = {
|
|
69
|
+
x: el.x, y: el.y,
|
|
70
|
+
width: el.width,
|
|
71
|
+
fontSize: el.size,
|
|
72
|
+
font: mapFont(el.font),
|
|
73
|
+
fontStyle: mapFontStyle(el.weight, el.style),
|
|
74
|
+
};
|
|
75
|
+
if (el.color) opts.color = el.color;
|
|
76
|
+
if (el.height) opts.height = el.height;
|
|
77
|
+
if (typeof el.letterSpacing === 'number') opts.letterSpacing = el.letterSpacing;
|
|
78
|
+
if (typeof el.lineHeight === 'number') opts.lineHeight = el.lineHeight;
|
|
79
|
+
if (typeof el.opacity === 'number') opts.opacity = el.opacity;
|
|
80
|
+
if (el.align) opts.align = el.align.toUpperCase();
|
|
81
|
+
if (el.verticalAlign === 'middle') opts.verticalAlign = 'CENTER';
|
|
82
|
+
else if (el.verticalAlign === 'bottom') opts.verticalAlign = 'BOTTOM';
|
|
83
|
+
// Text autoresize strategy:
|
|
84
|
+
// - Single-line noWrap (labels, pill chips): WIDTH_AND_HEIGHT. Width may be
|
|
85
|
+
// tight (e.g. 112px pill) so we need Figma to grow width to stay on one
|
|
86
|
+
// line instead of wrapping.
|
|
87
|
+
// - Large multi-line noWrap (titles split by explicit \n): HEIGHT. The
|
|
88
|
+
// browser-measured height (e.g. 275) reflects Chrome's rendering, but
|
|
89
|
+
// Figma positions glyphs differently when lineHeight < fontSize —
|
|
90
|
+
// descenders overflow the browser-measured height and dip into the next
|
|
91
|
+
// absolute sibling. Letting Figma auto-grow the frame fits its own
|
|
92
|
+
// descender placement; siblings have enough buffer in practice.
|
|
93
|
+
// - Small multi-line noWrap captions: WIDTH_AND_HEIGHT. They need the same
|
|
94
|
+
// width freedom as labels, and the large-title descender issue does not
|
|
95
|
+
// apply at caption scale.
|
|
96
|
+
// - Wrapping text: HEIGHT so Figma can reflow vertically.
|
|
97
|
+
const isMultiLineNoWrap = el.noWrap
|
|
98
|
+
&& typeof el.text === 'string'
|
|
99
|
+
&& el.text.includes('\n');
|
|
100
|
+
const needsLargeTitleAutoHeight = isMultiLineNoWrap && el.size >= 48;
|
|
101
|
+
if (el.noWrap && !needsLargeTitleAutoHeight) {
|
|
102
|
+
opts.autoresize = 'WIDTH_AND_HEIGHT';
|
|
103
|
+
// Figma Slides enforces an implicit wrap boundary at
|
|
104
|
+
// (slide_right − text_x) even for WIDTH_AND_HEIGHT. Setting
|
|
105
|
+
// size.x = 16384 does NOT override it — the slide-edge boundary
|
|
106
|
+
// wins. This is Slides-specific: open-pencil's reference Figma
|
|
107
|
+
// text layout returns 1e6 for WIDTH_AND_HEIGHT, so regular Figma
|
|
108
|
+
// Design never wraps these. Slides treats the SLIDE node as a
|
|
109
|
+
// 1920×1080 clipping container.
|
|
110
|
+
//
|
|
111
|
+
// Narrowly-scoped guard: a multi-character large right-anchored token
|
|
112
|
+
// (e.g. a divider numeral "11" at fontSize 420 placed via
|
|
113
|
+
// CSS right:56px) can fit in Chromium's measured width but
|
|
114
|
+
// overflow Slides' boundary by a few pixels and wrap. Shift x
|
|
115
|
+
// leftward by an absolute, fontSize-scaled buffer to absorb the
|
|
116
|
+
// few-pixel divergence. Body text and any string with whitespace
|
|
117
|
+
// is excluded — they're never the failure mode.
|
|
118
|
+
const slideWidth = ctx.slideWidth ?? 1920;
|
|
119
|
+
const text = el.text || el.runs?.map(r => r.text || '').join('') || '';
|
|
120
|
+
const slack = slideWidth - (el.x + (el.width ?? 0));
|
|
121
|
+
const gateSize = el.size >= 96;
|
|
122
|
+
const gateText = text.length > 0 && !/\s/.test(text);
|
|
123
|
+
const gateSlack = slack <= 80;
|
|
124
|
+
const isLargeRightEdgeToken = gateSize && gateText && gateSlack;
|
|
125
|
+
let shift = 0;
|
|
126
|
+
let buffer = 0;
|
|
127
|
+
if (isLargeRightEdgeToken) {
|
|
128
|
+
buffer = Math.max(12, Math.min(96, el.size * 0.20));
|
|
129
|
+
const need = (el.width ?? 0) + buffer;
|
|
130
|
+
const available = slideWidth - el.x;
|
|
131
|
+
if (need > available) {
|
|
132
|
+
shift = need - available;
|
|
133
|
+
opts.x = Math.max(0, el.x - shift);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (ctx.noWrapDiagnostics) {
|
|
137
|
+
ctx.noWrapDiagnostics.push({
|
|
138
|
+
slide: ctx.slideIndex,
|
|
139
|
+
x: el.x, y: el.y, width: el.width, fontSize: el.size,
|
|
140
|
+
text: text.slice(0, 60),
|
|
141
|
+
slack,
|
|
142
|
+
gates: { size: gateSize, text: gateText, slack: gateSlack },
|
|
143
|
+
fired: isLargeRightEdgeToken && shift > 0,
|
|
144
|
+
buffer: isLargeRightEdgeToken ? buffer : 0,
|
|
145
|
+
shift,
|
|
146
|
+
xAfter: opts.x,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
} else {
|
|
150
|
+
opts.autoresize = 'HEIGHT';
|
|
151
|
+
}
|
|
152
|
+
return opts;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function richTextRuns(runs) {
|
|
156
|
+
return runs.map(r => {
|
|
157
|
+
const out = { text: r.text };
|
|
158
|
+
if (r.color) out.color = r.color;
|
|
159
|
+
if (r.weight && r.weight >= 600) out.bold = true;
|
|
160
|
+
if (r.style === 'italic') out.italic = true;
|
|
161
|
+
if (r.bullet) out.bullet = true;
|
|
162
|
+
if (r.number) out.number = true;
|
|
163
|
+
return out;
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function handleText(slide, el, ctx) {
|
|
168
|
+
slide.addText(el.text ?? '', textOpts(el, ctx));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function handleRichText(slide, el, ctx) {
|
|
172
|
+
slide.addText(richTextRuns(el.runs ?? []), textOpts(el, ctx));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Render a flex / single-axis grid container from `browser-extract.mjs` as a
|
|
176
|
+
// Figma Auto Layout frame. The container's outer size is FIXED (we trust the
|
|
177
|
+
// authored rect); children are HUG (their intrinsic size). When Figma
|
|
178
|
+
// re-measures a child text leaf 1% wider than Chromium did, the re-flow
|
|
179
|
+
// happens inside this frame — siblings shift together instead of a single
|
|
180
|
+
// leaf crossing a pre-computed divider at slide coords.
|
|
181
|
+
async function handleLayoutContainer(target, el, ctx) {
|
|
182
|
+
if (el.fallbackToAbsolute) {
|
|
183
|
+
for (const child of el.children ?? []) {
|
|
184
|
+
await applyElement(target, child, ctx);
|
|
185
|
+
}
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const frame = target.addFrame(el.x, el.y, el.width, el.height, {
|
|
189
|
+
direction: el.direction === 'COLUMN' ? 'VERTICAL' : 'HORIZONTAL',
|
|
190
|
+
spacing: el.gap ?? 0,
|
|
191
|
+
name: 'FlexContainer',
|
|
192
|
+
});
|
|
193
|
+
styleAutoLayoutFrame(frame?._node, {
|
|
194
|
+
paddingLeft: el.paddingLeft ?? 0,
|
|
195
|
+
paddingRight: el.paddingRight ?? 0,
|
|
196
|
+
paddingTop: el.paddingTop ?? 0,
|
|
197
|
+
paddingBottom: el.paddingBottom ?? 0,
|
|
198
|
+
});
|
|
199
|
+
if (frame?._node) {
|
|
200
|
+
// FIXED outer size: the slide layout depends on the container keeping its
|
|
201
|
+
// authored dimensions. Children remain HUG (default after
|
|
202
|
+
// styleAutoLayoutFrame) so they size to their own content and re-flow on
|
|
203
|
+
// Chromium↔Figma metric drift.
|
|
204
|
+
frame._node.stackPrimarySizing = 'FIXED';
|
|
205
|
+
frame._node.stackCounterSizing = 'FIXED';
|
|
206
|
+
if (el.primaryAxisAlignItems) {
|
|
207
|
+
frame._node.stackPrimaryAlignItems = el.primaryAxisAlignItems;
|
|
208
|
+
}
|
|
209
|
+
if (el.counterAxisAlignItems) {
|
|
210
|
+
frame._node.stackCounterAlignItems = el.counterAxisAlignItems;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
for (const child of el.children ?? []) {
|
|
214
|
+
// Inside an Auto Layout frame, HTML stack relationships are preserved:
|
|
215
|
+
// if a text child wraps one extra line because Figma's glyph metrics are
|
|
216
|
+
// wider than Chromium's, the container re-flows its siblings. The flat
|
|
217
|
+
// path's "preserve browser-measured height" trick (textOpts setting
|
|
218
|
+
// autoresize NONE when el.height is present) no longer applies — clear
|
|
219
|
+
// el.height so textOpts picks HEIGHT and the text frame grows vertically.
|
|
220
|
+
let dispatched = child;
|
|
221
|
+
if ((child.type === 'text' || child.type === 'richText') && !child.noWrap && child.height) {
|
|
222
|
+
dispatched = { ...child, height: undefined };
|
|
223
|
+
}
|
|
224
|
+
await applyElement(frame, dispatched, ctx);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function styleAutoLayoutFrame(node, opts = {}) {
|
|
229
|
+
if (!node) return;
|
|
230
|
+
const framePaint = (paint) => {
|
|
231
|
+
if (!paint || paint.type !== 'SOLID') return paint;
|
|
232
|
+
const alpha = paint.opacity ?? 1;
|
|
233
|
+
return {
|
|
234
|
+
...paint,
|
|
235
|
+
opacity: 1,
|
|
236
|
+
color: { ...paint.color, a: alpha },
|
|
237
|
+
};
|
|
238
|
+
};
|
|
239
|
+
node.frameMaskDisabled = true;
|
|
240
|
+
node.stackPrimarySizing = 'RESIZE_TO_FIT_WITH_IMPLICIT_SIZE';
|
|
241
|
+
node.stackCounterSizing = 'RESIZE_TO_FIT_WITH_IMPLICIT_SIZE';
|
|
242
|
+
node.stackHorizontalPadding = opts.paddingLeft ?? 0;
|
|
243
|
+
node.stackVerticalPadding = opts.paddingTop ?? 0;
|
|
244
|
+
node.stackPaddingRight = opts.paddingRight ?? opts.paddingLeft ?? 0;
|
|
245
|
+
node.stackPaddingBottom = opts.paddingBottom ?? opts.paddingTop ?? 0;
|
|
246
|
+
node.fillPaints = opts.fill ? [framePaint(opts.fill)] : [{
|
|
247
|
+
type: 'SOLID',
|
|
248
|
+
color: { r: 0, g: 0, b: 0, a: 1 },
|
|
249
|
+
opacity: 0,
|
|
250
|
+
visible: true,
|
|
251
|
+
blendMode: 'NORMAL',
|
|
252
|
+
}];
|
|
253
|
+
node.strokePaints = opts.stroke ? [opts.stroke] : [];
|
|
254
|
+
node.strokeWeight = opts.stroke ? (opts.strokeWeight ?? 1) : 0;
|
|
255
|
+
if (opts.cornerRadius) {
|
|
256
|
+
node.cornerRadius = opts.cornerRadius;
|
|
257
|
+
node.rectangleTopLeftCornerRadius = opts.cornerRadius;
|
|
258
|
+
node.rectangleTopRightCornerRadius = opts.cornerRadius;
|
|
259
|
+
node.rectangleBottomLeftCornerRadius = opts.cornerRadius;
|
|
260
|
+
node.rectangleBottomRightCornerRadius = opts.cornerRadius;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function handlePillRow(slide, el) {
|
|
265
|
+
const row = slide.addFrame(el.x, el.y, el.width, el.height, {
|
|
266
|
+
direction: 'HORIZONTAL',
|
|
267
|
+
spacing: el.gap,
|
|
268
|
+
name: 'PillRow',
|
|
269
|
+
});
|
|
270
|
+
styleAutoLayoutFrame(row?._node);
|
|
271
|
+
for (const item of el.items ?? []) {
|
|
272
|
+
const rect = item.rect;
|
|
273
|
+
const text = item.text;
|
|
274
|
+
const pillFill = blendSolidPaintOver(
|
|
275
|
+
rect.fill,
|
|
276
|
+
typeof rect.opacity === 'number' ? rect.opacity : 1,
|
|
277
|
+
'#080808',
|
|
278
|
+
);
|
|
279
|
+
const pill = row.addFrame(0, 0, rect.width, rect.height, {
|
|
280
|
+
direction: 'HORIZONTAL',
|
|
281
|
+
spacing: 0,
|
|
282
|
+
name: 'Pill',
|
|
283
|
+
});
|
|
284
|
+
const topPad = Math.max(0, Math.round(text.y - rect.y) + 2);
|
|
285
|
+
const bottomPad = Math.max(0, Math.round(rect.y + rect.height - (text.y + text.height)) - 2);
|
|
286
|
+
styleAutoLayoutFrame(pill?._node, {
|
|
287
|
+
fill: pillFill,
|
|
288
|
+
stroke: buildSolidPaint(rect.stroke),
|
|
289
|
+
strokeWeight: rect.strokeWeight ?? 1,
|
|
290
|
+
cornerRadius: rect.cornerRadius,
|
|
291
|
+
paddingLeft: Math.max(0, Math.round(text.x - rect.x)),
|
|
292
|
+
paddingTop: topPad,
|
|
293
|
+
paddingRight: Math.max(0, Math.round(rect.x + rect.width - (text.x + text.width))),
|
|
294
|
+
paddingBottom: bottomPad,
|
|
295
|
+
});
|
|
296
|
+
pill.addText(text.text ?? '', textOpts(text));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function handleTextWithPillRow(slide, el) {
|
|
301
|
+
const outer = slide.addFrame(el.x, el.y, el.width, el.height, {
|
|
302
|
+
direction: 'VERTICAL',
|
|
303
|
+
spacing: el.gap,
|
|
304
|
+
name: 'TextWithPillRow',
|
|
305
|
+
});
|
|
306
|
+
styleAutoLayoutFrame(outer?._node);
|
|
307
|
+
|
|
308
|
+
const textBlock = { ...el.textBlock };
|
|
309
|
+
delete textBlock.height;
|
|
310
|
+
const textValue = textBlock.type === 'richText'
|
|
311
|
+
? richTextRuns(textBlock.runs ?? [])
|
|
312
|
+
: (textBlock.text ?? '');
|
|
313
|
+
outer.addText(textValue, textOpts(textBlock));
|
|
314
|
+
|
|
315
|
+
const row = outer.addFrame(0, 0, el.row.width, el.row.height, {
|
|
316
|
+
direction: 'HORIZONTAL',
|
|
317
|
+
spacing: el.row.gap,
|
|
318
|
+
name: 'PillRow',
|
|
319
|
+
});
|
|
320
|
+
styleAutoLayoutFrame(row?._node);
|
|
321
|
+
|
|
322
|
+
for (const item of el.row.items ?? []) {
|
|
323
|
+
const rect = item.rect;
|
|
324
|
+
const text = item.text;
|
|
325
|
+
const pillFill = blendSolidPaintOver(
|
|
326
|
+
rect.fill,
|
|
327
|
+
typeof rect.opacity === 'number' ? rect.opacity : 1,
|
|
328
|
+
'#080808',
|
|
329
|
+
);
|
|
330
|
+
const pill = row.addFrame(0, 0, rect.width, rect.height, {
|
|
331
|
+
direction: 'HORIZONTAL',
|
|
332
|
+
spacing: 0,
|
|
333
|
+
name: 'Pill',
|
|
334
|
+
});
|
|
335
|
+
const topPad = Math.max(0, Math.round(text.y - rect.y) + 2);
|
|
336
|
+
const bottomPad = Math.max(0, Math.round(rect.y + rect.height - (text.y + text.height)) - 2);
|
|
337
|
+
styleAutoLayoutFrame(pill?._node, {
|
|
338
|
+
fill: pillFill,
|
|
339
|
+
stroke: buildSolidPaint(rect.stroke),
|
|
340
|
+
strokeWeight: rect.strokeWeight ?? 1,
|
|
341
|
+
cornerRadius: rect.cornerRadius,
|
|
342
|
+
paddingLeft: Math.max(0, Math.round(text.x - rect.x)),
|
|
343
|
+
paddingTop: topPad,
|
|
344
|
+
paddingRight: Math.max(0, Math.round(rect.x + rect.width - (text.x + text.width))),
|
|
345
|
+
paddingBottom: bottomPad,
|
|
346
|
+
});
|
|
347
|
+
pill.addText(text.text ?? '', textOpts(text));
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function handleStatWithRing(slide, el, ctx) {
|
|
352
|
+
const lineHeight = Math.round(el.label.lineHeight ?? 0);
|
|
353
|
+
const measuredLines = lineHeight > 0
|
|
354
|
+
? Math.max(1, Math.round((el.label.height ?? 0) / lineHeight))
|
|
355
|
+
: 0;
|
|
356
|
+
const boost = measuredLines > 0 && measuredLines <= 3 ? lineHeight : 0;
|
|
357
|
+
|
|
358
|
+
if (el.divider) {
|
|
359
|
+
const extendedBottom = el.ring.y + boost + el.ring.height;
|
|
360
|
+
await handleRect(slide, {
|
|
361
|
+
...el.divider,
|
|
362
|
+
height: Math.max(el.divider.height, extendedBottom - el.divider.y),
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
await handleRichText(slide, el.number, ctx);
|
|
367
|
+
await handleText(slide, {
|
|
368
|
+
...el.label,
|
|
369
|
+
height: el.label.height + boost,
|
|
370
|
+
}, ctx);
|
|
371
|
+
await handleSvg(slide, {
|
|
372
|
+
...el.ring,
|
|
373
|
+
y: el.ring.y + boost,
|
|
374
|
+
}, ctx);
|
|
375
|
+
await handleText(slide, {
|
|
376
|
+
...el.caption,
|
|
377
|
+
y: el.caption.y + boost,
|
|
378
|
+
}, ctx);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Map CSS `filter: blur(Npx)` (captured by browser-extract as
|
|
382
|
+
// `el.filter = { blur: N }`) onto a Figma FOREGROUND_BLUR effect.
|
|
383
|
+
// No-op when the element has no filter.
|
|
384
|
+
function applyFilter(node, el) {
|
|
385
|
+
if (!node || !el?.filter) return;
|
|
386
|
+
if (typeof el.filter.blur === 'number' && el.filter.blur > 0) {
|
|
387
|
+
const existing = Array.isArray(node.effects) ? node.effects : [];
|
|
388
|
+
node.effects = [
|
|
389
|
+
...existing,
|
|
390
|
+
{ type: 'FOREGROUND_BLUR', radius: el.filter.blur, visible: true, blendMode: 'NORMAL' },
|
|
391
|
+
];
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function handleImage(slide, el, ctx) {
|
|
396
|
+
const path = ctx.resolveMedia(el.src);
|
|
397
|
+
const opts = { x: el.x, y: el.y, width: el.width, height: el.height };
|
|
398
|
+
opts.scaleMode = el.objectFit === 'contain' ? 'FIT' : 'FILL';
|
|
399
|
+
const node = await slide.addImage(path, opts);
|
|
400
|
+
applyFilter(node, el);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function handleRect(slide, el) {
|
|
404
|
+
const opts = {};
|
|
405
|
+
// If the element has CSS gradient layers, fold the solid fill (if any)
|
|
406
|
+
// plus each gradient into a single fillPaints stack. Solid on bottom,
|
|
407
|
+
// gradients layered on top in reverse CSS order so the FIRST CSS layer
|
|
408
|
+
// ends up topmost (matching how browsers paint stacked background-image).
|
|
409
|
+
const gradientPaints = buildCssBackgroundPaints(el.backgroundLayers);
|
|
410
|
+
if (gradientPaints.length > 0) {
|
|
411
|
+
if (el.stroke) { opts.stroke = el.stroke; opts.strokeWeight = el.strokeWeight ?? 1; }
|
|
412
|
+
if (el.cornerRadius) opts.cornerRadius = el.cornerRadius;
|
|
413
|
+
if (el.dashPattern) opts.dashPattern = el.dashPattern;
|
|
414
|
+
opts.fill = el.fill || '#000000'; // placeholder; overwritten below
|
|
415
|
+
const node = slide.addRectangle(el.x, el.y, el.width, el.height, opts);
|
|
416
|
+
const paints = [];
|
|
417
|
+
if (el.fill) {
|
|
418
|
+
const solid = buildSolidPaint(el.fill);
|
|
419
|
+
if (solid) {
|
|
420
|
+
if (el.opacity != null && el.opacity < 1) solid.opacity *= el.opacity;
|
|
421
|
+
paints.push(solid);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
for (let i = gradientPaints.length - 1; i >= 0; i--) paints.push(gradientPaints[i]);
|
|
425
|
+
if (paints.length > 0) node.fillPaints = paints;
|
|
426
|
+
applyFilter(node, el);
|
|
427
|
+
return node;
|
|
428
|
+
}
|
|
429
|
+
if (el.fill) opts.fill = el.fill;
|
|
430
|
+
if (el.stroke) { opts.stroke = el.stroke; opts.strokeWeight = el.strokeWeight ?? 1; }
|
|
431
|
+
if (el.cornerRadius) opts.cornerRadius = el.cornerRadius;
|
|
432
|
+
if (el.dashPattern) opts.dashPattern = el.dashPattern;
|
|
433
|
+
const node = slide.addRectangle(el.x, el.y, el.width, el.height, opts);
|
|
434
|
+
// Apply fill alpha to the fill paint only, not the node. Node-level opacity
|
|
435
|
+
// would drag the stroke down with it (e.g. a 10% translucent pill would get
|
|
436
|
+
// a 10% translucent outline), which never matches the CSS intent.
|
|
437
|
+
if (el.opacity != null && el.opacity < 1 && el.fill && node?.fillPaints?.[0]) {
|
|
438
|
+
node.fillPaints[0].opacity = el.opacity;
|
|
439
|
+
}
|
|
440
|
+
applyFilter(node, el);
|
|
441
|
+
return node;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async function handleEllipse(slide, el) {
|
|
445
|
+
// addEllipse (SHAPE_WITH_TEXT) doesn't support dashPattern, so dashed
|
|
446
|
+
// ellipses go through addPath using a bezier approximation of the ring.
|
|
447
|
+
if (el.dashPattern) {
|
|
448
|
+
const cx = el.x + el.width / 2;
|
|
449
|
+
const cy = el.y + el.height / 2;
|
|
450
|
+
const rx = el.width / 2;
|
|
451
|
+
const ry = el.height / 2;
|
|
452
|
+
const k = 0.5522847498;
|
|
453
|
+
const kx = k * rx, ky = k * ry;
|
|
454
|
+
const d = `M ${cx + rx} ${cy} ` +
|
|
455
|
+
`C ${cx + rx} ${cy + ky} ${cx + kx} ${cy + ry} ${cx} ${cy + ry} ` +
|
|
456
|
+
`C ${cx - kx} ${cy + ry} ${cx - rx} ${cy + ky} ${cx - rx} ${cy} ` +
|
|
457
|
+
`C ${cx - rx} ${cy - ky} ${cx - kx} ${cy - ry} ${cx} ${cy - ry} ` +
|
|
458
|
+
`C ${cx + kx} ${cy - ry} ${cx + rx} ${cy - ky} ${cx + rx} ${cy} Z`;
|
|
459
|
+
const opts = { name: 'Ellipse' };
|
|
460
|
+
if (el.stroke) { opts.stroke = el.stroke; opts.strokeWeight = el.strokeWeight ?? 1; }
|
|
461
|
+
if (el.fill) opts.fill = el.fill;
|
|
462
|
+
opts.dashPattern = el.dashPattern;
|
|
463
|
+
const node = slide.addPath(d, opts);
|
|
464
|
+
applyFilter(node, el);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
const opts = {};
|
|
468
|
+
if (el.fill) opts.fill = el.fill;
|
|
469
|
+
if (el.stroke) { opts.stroke = el.stroke; opts.strokeWeight = el.strokeWeight ?? 1; }
|
|
470
|
+
const node = slide.addEllipse(el.x, el.y, el.width, el.height, opts);
|
|
471
|
+
applyFilter(node, el);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async function handleBulletList(slide, el) {
|
|
475
|
+
const runs = (el.items ?? []).map((t, i, arr) => ({
|
|
476
|
+
text: t + (i < arr.length - 1 ? '\n' : ''),
|
|
477
|
+
bullet: true,
|
|
478
|
+
}));
|
|
479
|
+
slide.addText(runs, {
|
|
480
|
+
x: el.x + 34, y: el.y, width: el.width - 34,
|
|
481
|
+
fontSize: el.size ?? 24,
|
|
482
|
+
font: mapFont(el.font),
|
|
483
|
+
color: el.color,
|
|
484
|
+
list: 'UNORDERED',
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async function handleBlockquote(slide, el) {
|
|
489
|
+
slide.addRectangle(el.x, el.y, 4, 140, { fill: el.borderColor ?? '#DC241F' });
|
|
490
|
+
slide.addText(el.text, {
|
|
491
|
+
x: el.x + 28, y: el.y,
|
|
492
|
+
width: el.width - 28,
|
|
493
|
+
fontSize: el.size ?? 22,
|
|
494
|
+
font: mapFont(el.font ?? 'EB Garamond'),
|
|
495
|
+
fontStyle: mapFontStyle(el.weight, el.style ?? 'italic'),
|
|
496
|
+
color: el.color,
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async function handleCard(slide, el) {
|
|
501
|
+
slide.addRectangle(el.x, el.y, el.width, el.height, {
|
|
502
|
+
fill: el.background ?? '#FFFFFF',
|
|
503
|
+
stroke: el.border ?? BORDER,
|
|
504
|
+
strokeWeight: 1,
|
|
505
|
+
});
|
|
506
|
+
if (el.accentColor) {
|
|
507
|
+
slide.addRectangle(el.x, el.y, el.accentWidth ?? 12, el.height, { fill: el.accentColor });
|
|
508
|
+
}
|
|
509
|
+
if (el.number) {
|
|
510
|
+
slide.addText(el.number, {
|
|
511
|
+
x: el.x + 44, y: el.y + 36, width: el.width - 88,
|
|
512
|
+
fontSize: 24, font: SANS, fontStyle: 'Bold',
|
|
513
|
+
color: el.accentColor ?? '#0B1B33',
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
if (el.title) {
|
|
517
|
+
slide.addText(el.title, {
|
|
518
|
+
x: el.x + 44, y: el.y + 80, width: el.width - 88,
|
|
519
|
+
fontSize: 42, font: SERIF, fontStyle: 'Bold',
|
|
520
|
+
color: '#0B1B33',
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
if (el.body) {
|
|
524
|
+
slide.addText(el.body, {
|
|
525
|
+
x: el.x + 44, y: el.y + 168, width: el.width - 88,
|
|
526
|
+
fontSize: 24, font: SANS, color: '#5A6B82',
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
async function handleFactRow(slide, el) {
|
|
532
|
+
const n = el.facts.length;
|
|
533
|
+
const gap = 48;
|
|
534
|
+
const colW = (el.width - gap * (n - 1)) / n;
|
|
535
|
+
for (let i = 0; i < n; i++) {
|
|
536
|
+
const fx = el.x + i * (colW + gap);
|
|
537
|
+
slide.addText(el.facts[i].label, {
|
|
538
|
+
x: fx, y: el.y, width: colW,
|
|
539
|
+
fontSize: el.labelSize ?? 22, font: SANS, fontStyle: 'Bold',
|
|
540
|
+
color: el.labelColor ?? '#DC241F',
|
|
541
|
+
});
|
|
542
|
+
slide.addText(el.facts[i].text, {
|
|
543
|
+
x: fx, y: el.y + 38, width: colW,
|
|
544
|
+
fontSize: el.textSize ?? 22, font: SANS,
|
|
545
|
+
color: el.textColor ?? '#C9D4E8',
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
async function handleImageRow(slide, el, ctx) {
|
|
551
|
+
const gap = el.gap ?? 0;
|
|
552
|
+
let cx = el.x;
|
|
553
|
+
for (const img of el.images) {
|
|
554
|
+
await slide.addImage(ctx.resolveMedia(img.src), {
|
|
555
|
+
x: cx, y: el.y, width: img.width, height: img.height, scaleMode: 'FIT',
|
|
556
|
+
});
|
|
557
|
+
cx += img.width + gap;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
async function handleTable(slide, el) {
|
|
562
|
+
const columns = el.columns ?? [];
|
|
563
|
+
const rows = el.rows ?? [];
|
|
564
|
+
const headerRow = columns.slice();
|
|
565
|
+
const dataRows = rows.map(r => columns.map(c => {
|
|
566
|
+
const v = r[c];
|
|
567
|
+
if (v && typeof v === 'object' && v.type === 'color-swatch') return `■ ${v.color}`;
|
|
568
|
+
return String(v ?? '');
|
|
569
|
+
}));
|
|
570
|
+
slide.addTable(el.x, el.y, [headerRow, ...dataRows], { width: el.width, height: 720 });
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async function handleTimeline(slide, el) {
|
|
574
|
+
const steps = el.steps ?? [];
|
|
575
|
+
const CARD_W = 320, GAP = 40, HEAD_H = 60, BODY_H = el.height - HEAD_H - 20;
|
|
576
|
+
for (let i = 0; i < steps.length; i++) {
|
|
577
|
+
const cx = el.x + i * (CARD_W + GAP);
|
|
578
|
+
slide.addRectangle(cx, el.y, CARD_W, HEAD_H, { fill: steps[i].color });
|
|
579
|
+
slide.addText(steps[i].year, {
|
|
580
|
+
x: cx, y: el.y + 14, width: CARD_W,
|
|
581
|
+
fontSize: 26, font: SANS, fontStyle: 'Bold', color: '#FFFFFF', align: 'CENTER',
|
|
582
|
+
});
|
|
583
|
+
slide.addRectangle(cx, el.y + HEAD_H, CARD_W, BODY_H, {
|
|
584
|
+
fill: '#FFFFFF', stroke: BORDER, strokeWeight: 1,
|
|
585
|
+
});
|
|
586
|
+
slide.addText(steps[i].event, {
|
|
587
|
+
x: cx + 18, y: el.y + HEAD_H + 20, width: CARD_W - 36,
|
|
588
|
+
fontSize: 34, font: SERIF, fontStyle: 'Bold', color: '#0B1B33',
|
|
589
|
+
});
|
|
590
|
+
slide.addText(steps[i].description, {
|
|
591
|
+
x: cx + 18, y: el.y + HEAD_H + 80, width: CARD_W - 36,
|
|
592
|
+
fontSize: 22, font: SANS, color: '#5A6B82',
|
|
593
|
+
});
|
|
594
|
+
if (i < steps.length - 1) {
|
|
595
|
+
slide.addText('→', {
|
|
596
|
+
x: cx + CARD_W + 6, y: el.y + 38, width: GAP - 12,
|
|
597
|
+
fontSize: 32, font: SANS, color: '#B0B8C4', align: 'CENTER',
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
async function handleChart(slide, el) {
|
|
604
|
+
const X0 = el.x + 100, X1 = el.x + el.width - 62;
|
|
605
|
+
const Y0 = el.y + 30, Y1 = el.y + el.height - 200;
|
|
606
|
+
slide.addRectangle(X0, Y0, 2, Y1 - Y0, { fill: BORDER });
|
|
607
|
+
slide.addRectangle(X0, Y1, X1 - X0, 2, { fill: BORDER });
|
|
608
|
+
const ticks = el.yAxis?.ticks ?? [];
|
|
609
|
+
const yMax = el.yAxis?.max ?? 1;
|
|
610
|
+
for (const t of ticks) {
|
|
611
|
+
const ty = Y1 - (t / yMax) * (Y1 - Y0);
|
|
612
|
+
drawLine(slide, X0, ty, X1, ty, { stroke: BORDER, strokeWeight: 1, dashPattern: [6, 4] });
|
|
613
|
+
const label = t >= 1000 ? `${Math.round(t / 1000)}k` : `${t}`;
|
|
614
|
+
slide.addText(label, {
|
|
615
|
+
x: X0 - 90, y: ty - 16, width: 80,
|
|
616
|
+
fontSize: 22, font: SANS, color: '#5A6B82', align: 'RIGHT',
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
const xs = el.xAxis?.values ?? [];
|
|
620
|
+
const series = el.series?.[0]?.data ?? [];
|
|
621
|
+
const seriesColor = el.series?.[0]?.color ?? '#0B1B33';
|
|
622
|
+
const points = series.map((v, i) => {
|
|
623
|
+
const px = X0 + ((i + 0.5) / xs.length) * (X1 - X0);
|
|
624
|
+
const py = Y1 - (v / yMax) * (Y1 - Y0);
|
|
625
|
+
return { x: px, y: py };
|
|
626
|
+
});
|
|
627
|
+
for (let i = 0; i < points.length - 1; i++) {
|
|
628
|
+
drawLine(slide, points[i].x, points[i].y, points[i + 1].x, points[i + 1].y, {
|
|
629
|
+
color: seriesColor, weight: 4,
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
const annotations = el.annotations ?? [];
|
|
633
|
+
const redIdx = annotations[0]?.x;
|
|
634
|
+
for (let i = 0; i < points.length; i++) {
|
|
635
|
+
const red = i === redIdx;
|
|
636
|
+
slide.addEllipse(points[i].x - 7, points[i].y - 7, 14, 14, {
|
|
637
|
+
fill: red ? (annotations[0].color ?? '#DC241F') : seriesColor,
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
for (let i = 0; i < xs.length; i++) {
|
|
641
|
+
const px = X0 + ((i + 0.5) / xs.length) * (X1 - X0);
|
|
642
|
+
const red = i === redIdx;
|
|
643
|
+
slide.addText(xs[i], {
|
|
644
|
+
x: px - 80, y: Y1 + 18, width: 160,
|
|
645
|
+
fontSize: 22, font: SANS,
|
|
646
|
+
color: red ? (annotations[0].color ?? '#DC241F') : '#5A6B82',
|
|
647
|
+
fontStyle: red ? 'Bold' : 'Regular',
|
|
648
|
+
align: 'CENTER',
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
if (el.xAxis?.label) {
|
|
652
|
+
slide.addText(el.xAxis.label, {
|
|
653
|
+
x: el.x, y: Y1 + 72, width: el.width,
|
|
654
|
+
fontSize: 22, font: SANS, fontStyle: 'Italic', color: '#5A6B82', align: 'CENTER',
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
if (redIdx !== undefined) {
|
|
658
|
+
const rx = X0 + ((redIdx + 0.5) / xs.length) * (X1 - X0);
|
|
659
|
+
const annoColor = annotations[0].color ?? '#DC241F';
|
|
660
|
+
drawLine(slide, rx, Y0 + 20, rx, Y1, { stroke: annoColor, strokeWeight: 2, dashPattern: [8, 5] });
|
|
661
|
+
slide.addRectangle(rx - 165, Y0, 330, 54, { fill: annoColor, cornerRadius: 4 });
|
|
662
|
+
slide.addText(annotations[0].label, {
|
|
663
|
+
x: rx - 165, y: Y0 + 12, width: 330,
|
|
664
|
+
fontSize: 22, font: SANS, fontStyle: 'Bold', color: '#FFFFFF', align: 'CENTER',
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
if (el.note) {
|
|
668
|
+
slide.addText(el.note, {
|
|
669
|
+
x: el.x, y: el.y + el.height - 30, width: el.width,
|
|
670
|
+
fontSize: 22, font: SANS, fontStyle: 'Italic', color: '#5A6B82', align: 'CENTER',
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function normalizeColor(c) {
|
|
676
|
+
if (!c || c === 'none' || c === 'transparent') return null;
|
|
677
|
+
const s = c.trim().toLowerCase();
|
|
678
|
+
const m = s.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/);
|
|
679
|
+
if (m) return `#${m[1]}${m[1]}${m[2]}${m[2]}${m[3]}${m[3]}`.toUpperCase();
|
|
680
|
+
return c.toUpperCase().startsWith('#') ? c.toUpperCase() : c;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// Parse a CSS color string (#rrggbb / #rgb / rgb(...) / rgba(...) / named) into
|
|
684
|
+
// an { r, g, b, a } record with each channel in 0..1. Returns null for
|
|
685
|
+
// unparseable values so callers can skip the stop.
|
|
686
|
+
function parseColorRgba(c) {
|
|
687
|
+
if (!c) return null;
|
|
688
|
+
const s = c.trim().toLowerCase();
|
|
689
|
+
if (s === 'none' || s === 'transparent') return { r: 0, g: 0, b: 0, a: 0 };
|
|
690
|
+
let m = s.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/);
|
|
691
|
+
if (m) {
|
|
692
|
+
return {
|
|
693
|
+
r: parseInt(m[1] + m[1], 16) / 255,
|
|
694
|
+
g: parseInt(m[2] + m[2], 16) / 255,
|
|
695
|
+
b: parseInt(m[3] + m[3], 16) / 255,
|
|
696
|
+
a: 1,
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
m = s.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})?$/);
|
|
700
|
+
if (m) {
|
|
701
|
+
return {
|
|
702
|
+
r: parseInt(m[1], 16) / 255,
|
|
703
|
+
g: parseInt(m[2], 16) / 255,
|
|
704
|
+
b: parseInt(m[3], 16) / 255,
|
|
705
|
+
a: m[4] != null ? parseInt(m[4], 16) / 255 : 1,
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
m = s.match(/^rgba?\(([^)]+)\)$/);
|
|
709
|
+
if (m) {
|
|
710
|
+
const parts = m[1].split(',').map(p => p.trim());
|
|
711
|
+
if (parts.length < 3) return null;
|
|
712
|
+
const r = parseFloat(parts[0]) / 255;
|
|
713
|
+
const g = parseFloat(parts[1]) / 255;
|
|
714
|
+
const b = parseFloat(parts[2]) / 255;
|
|
715
|
+
const a = parts.length >= 4 ? parseFloat(parts[3]) : 1;
|
|
716
|
+
if ([r, g, b, a].some(n => !Number.isFinite(n))) return null;
|
|
717
|
+
return { r, g, b, a };
|
|
718
|
+
}
|
|
719
|
+
return null;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// Build a Figma fillPaint (GRADIENT_LINEAR or GRADIENT_RADIAL) from a parsed
|
|
723
|
+
// SVG gradient entry. `bbox` is the shape's bounding box in SVG user space,
|
|
724
|
+
// required when the gradient uses gradientUnits="userSpaceOnUse".
|
|
725
|
+
function buildGradientPaint(g, bbox) {
|
|
726
|
+
const stops = g.stops
|
|
727
|
+
.map(s => {
|
|
728
|
+
const rgba = parseColorRgba(s.color);
|
|
729
|
+
if (!rgba) return null;
|
|
730
|
+
return {
|
|
731
|
+
position: Math.max(0, Math.min(1, s.position)),
|
|
732
|
+
color: { r: rgba.r, g: rgba.g, b: rgba.b, a: rgba.a * (s.opacity ?? 1) },
|
|
733
|
+
};
|
|
734
|
+
})
|
|
735
|
+
.filter(Boolean)
|
|
736
|
+
.sort((a, b) => a.position - b.position);
|
|
737
|
+
if (stops.length === 0) return null;
|
|
738
|
+
|
|
739
|
+
// Convert a point from gradient-space to shape-local 0..1 coords. For
|
|
740
|
+
// objectBoundingBox, gradient space IS 0..1; we only apply gradientTransform.
|
|
741
|
+
// For userSpaceOnUse we apply the transform then divide by the shape bbox.
|
|
742
|
+
const toLocal = (x, y) => {
|
|
743
|
+
let [tx, ty] = g.transform ? applyAffine(g.transform, x, y) : [x, y];
|
|
744
|
+
if (g.units === 'userSpaceOnUse') {
|
|
745
|
+
if (!bbox || !bbox.w || !bbox.h) return null;
|
|
746
|
+
tx = (tx - bbox.x) / bbox.w;
|
|
747
|
+
ty = (ty - bbox.y) / bbox.h;
|
|
748
|
+
}
|
|
749
|
+
return [tx, ty];
|
|
750
|
+
};
|
|
751
|
+
|
|
752
|
+
if (g.type === 'linear') {
|
|
753
|
+
const p1 = toLocal(g.x1, g.y1);
|
|
754
|
+
const p2 = toLocal(g.x2, g.y2);
|
|
755
|
+
if (!p1 || !p2) return null;
|
|
756
|
+
const [x1, y1] = p1;
|
|
757
|
+
const [x2, y2] = p2;
|
|
758
|
+
const dx = x2 - x1;
|
|
759
|
+
const dy = y2 - y1;
|
|
760
|
+
const det = dx * dx + dy * dy;
|
|
761
|
+
if (det === 0) return null;
|
|
762
|
+
const m00 = dx / det;
|
|
763
|
+
const m01 = dy / det;
|
|
764
|
+
const m02 = -(dx * x1 + dy * y1) / det;
|
|
765
|
+
const m10 = -dy / det;
|
|
766
|
+
const m11 = dx / det;
|
|
767
|
+
const m12 = 0.5 + (dy * x1 - dx * y1) / det;
|
|
768
|
+
return {
|
|
769
|
+
type: 'GRADIENT_LINEAR',
|
|
770
|
+
visible: true,
|
|
771
|
+
opacity: 1,
|
|
772
|
+
blendMode: 'NORMAL',
|
|
773
|
+
transform: { m00, m01, m02, m10, m11, m12 },
|
|
774
|
+
stops,
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// Radial with optional gradientTransform: build two basis vectors
|
|
779
|
+
// e1 = (r, 0) and e2 = (0, r) in gradient space, transform them, then
|
|
780
|
+
// invert the resulting basis to produce Figma's 2x3 paint transform.
|
|
781
|
+
const { cx, cy, r } = g;
|
|
782
|
+
if (!r) return null;
|
|
783
|
+
const center = toLocal(cx, cy);
|
|
784
|
+
if (!center) return null;
|
|
785
|
+
const e1end = toLocal(cx + r, cy);
|
|
786
|
+
const e2end = toLocal(cx, cy + r);
|
|
787
|
+
if (!e1end || !e2end) return null;
|
|
788
|
+
const bx = e1end[0] - center[0];
|
|
789
|
+
const by = e1end[1] - center[1];
|
|
790
|
+
const cxv = e2end[0] - center[0];
|
|
791
|
+
const cyv = e2end[1] - center[1];
|
|
792
|
+
const det2 = bx * cyv - by * cxv;
|
|
793
|
+
if (!det2) return null;
|
|
794
|
+
const inv00 = cyv / det2;
|
|
795
|
+
const inv01 = -cxv / det2;
|
|
796
|
+
const inv10 = -by / det2;
|
|
797
|
+
const inv11 = bx / det2;
|
|
798
|
+
const m00 = 0.5 * inv00;
|
|
799
|
+
const m01 = 0.5 * inv01;
|
|
800
|
+
const m02 = 0.5 - 0.5 * (inv00 * center[0] + inv01 * center[1]);
|
|
801
|
+
const m10 = 0.5 * inv10;
|
|
802
|
+
const m11 = 0.5 * inv11;
|
|
803
|
+
const m12 = 0.5 - 0.5 * (inv10 * center[0] + inv11 * center[1]);
|
|
804
|
+
return {
|
|
805
|
+
type: 'GRADIENT_RADIAL',
|
|
806
|
+
visible: true,
|
|
807
|
+
opacity: 1,
|
|
808
|
+
blendMode: 'NORMAL',
|
|
809
|
+
transform: { m00, m01, m02, m10, m11, m12 },
|
|
810
|
+
stops,
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function buildSolidPaint(cssColor) {
|
|
815
|
+
const rgba = parseColorRgba(cssColor);
|
|
816
|
+
if (!rgba || rgba.a === 0) return null;
|
|
817
|
+
return {
|
|
818
|
+
type: 'SOLID',
|
|
819
|
+
visible: true,
|
|
820
|
+
opacity: rgba.a,
|
|
821
|
+
blendMode: 'NORMAL',
|
|
822
|
+
color: { r: rgba.r, g: rgba.g, b: rgba.b, a: 1 },
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function blendSolidPaintOver(cssColor, opacity = 1, bgColor = '#000000') {
|
|
827
|
+
const fg = parseColorRgba(cssColor);
|
|
828
|
+
const bg = parseColorRgba(bgColor);
|
|
829
|
+
if (!fg) return null;
|
|
830
|
+
if (!bg) return buildSolidPaint(cssColor);
|
|
831
|
+
const a = Math.max(0, Math.min(1, (fg.a ?? 1) * opacity));
|
|
832
|
+
return {
|
|
833
|
+
type: 'SOLID',
|
|
834
|
+
visible: true,
|
|
835
|
+
opacity: 1,
|
|
836
|
+
blendMode: 'NORMAL',
|
|
837
|
+
color: {
|
|
838
|
+
r: fg.r * a + bg.r * (1 - a),
|
|
839
|
+
g: fg.g * a + bg.g * (1 - a),
|
|
840
|
+
b: fg.b * a + bg.b * (1 - a),
|
|
841
|
+
a: 1,
|
|
842
|
+
},
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// Translate CSS gradient layer descriptors (from browser-extract) into
|
|
847
|
+
// Figma gradient paints.
|
|
848
|
+
function buildCssBackgroundPaints(layers) {
|
|
849
|
+
if (!Array.isArray(layers) || layers.length === 0) return [];
|
|
850
|
+
const out = [];
|
|
851
|
+
for (const layer of layers) {
|
|
852
|
+
const paint = layer.kind === 'linear'
|
|
853
|
+
? buildCssLinearPaint(layer)
|
|
854
|
+
: layer.kind === 'radial'
|
|
855
|
+
? buildCssRadialPaint(layer)
|
|
856
|
+
: null;
|
|
857
|
+
if (paint) out.push(paint);
|
|
858
|
+
}
|
|
859
|
+
return out;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function mapCssStops(stops) {
|
|
863
|
+
const out = [];
|
|
864
|
+
for (const s of stops) {
|
|
865
|
+
const rgba = parseColorRgba(s.color);
|
|
866
|
+
if (!rgba) continue;
|
|
867
|
+
out.push({
|
|
868
|
+
position: Math.max(0, Math.min(1, s.pos)),
|
|
869
|
+
color: { r: rgba.r, g: rgba.g, b: rgba.b, a: rgba.a },
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
out.sort((a, b) => a.position - b.position);
|
|
873
|
+
return out;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function buildCssLinearPaint(layer) {
|
|
877
|
+
const stops = mapCssStops(layer.stops);
|
|
878
|
+
if (stops.length === 0) return null;
|
|
879
|
+
const theta = (layer.angleDeg ?? 180) * Math.PI / 180;
|
|
880
|
+
const s = Math.sin(theta);
|
|
881
|
+
const c = Math.cos(theta);
|
|
882
|
+
const half = (Math.abs(s) + Math.abs(c)) / 2;
|
|
883
|
+
const x1 = 0.5 - half * s;
|
|
884
|
+
const y1 = 0.5 + half * c;
|
|
885
|
+
const x2 = 0.5 + half * s;
|
|
886
|
+
const y2 = 0.5 - half * c;
|
|
887
|
+
const dx = x2 - x1;
|
|
888
|
+
const dy = y2 - y1;
|
|
889
|
+
const det = dx * dx + dy * dy;
|
|
890
|
+
if (det === 0) return null;
|
|
891
|
+
const m00 = dx / det;
|
|
892
|
+
const m01 = dy / det;
|
|
893
|
+
const m02 = -(dx * x1 + dy * y1) / det;
|
|
894
|
+
const m10 = -dy / det;
|
|
895
|
+
const m11 = dx / det;
|
|
896
|
+
const m12 = 0.5 + (dy * x1 - dx * y1) / det;
|
|
897
|
+
return {
|
|
898
|
+
type: 'GRADIENT_LINEAR',
|
|
899
|
+
visible: true,
|
|
900
|
+
opacity: 1,
|
|
901
|
+
blendMode: 'NORMAL',
|
|
902
|
+
transform: { m00, m01, m02, m10, m11, m12 },
|
|
903
|
+
stops,
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function buildCssRadialPaint(layer) {
|
|
908
|
+
const stops = mapCssStops(layer.stops);
|
|
909
|
+
if (stops.length === 0) return null;
|
|
910
|
+
const { cx, cy, rx, ry } = layer;
|
|
911
|
+
if (!(rx > 0) || !(ry > 0)) return null;
|
|
912
|
+
const m00 = 1 / rx / 2;
|
|
913
|
+
const m02 = 0.5 - cx / rx / 2;
|
|
914
|
+
const m11 = 1 / ry / 2;
|
|
915
|
+
const m12 = 0.5 - cy / ry / 2;
|
|
916
|
+
return {
|
|
917
|
+
type: 'GRADIENT_RADIAL',
|
|
918
|
+
visible: true,
|
|
919
|
+
opacity: 1,
|
|
920
|
+
blendMode: 'NORMAL',
|
|
921
|
+
transform: { m00, m01: 0, m02, m10: 0, m11, m12 },
|
|
922
|
+
stops,
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function attr(tag, name) {
|
|
927
|
+
const m = tag.match(new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`, 'i'));
|
|
928
|
+
return m ? m[1] : undefined;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
function numAttr(tag, name) {
|
|
932
|
+
const v = attr(tag, name);
|
|
933
|
+
return v === undefined ? undefined : Number(v);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function findSvgBlock(html, viewBox) {
|
|
937
|
+
const re = new RegExp(`<svg\\b[^>]*viewBox\\s*=\\s*"${viewBox.replace(/\s+/g, '\\s+')}"[^>]*>([\\s\\S]*?)<\\/svg>`, 'i');
|
|
938
|
+
const m = html.match(re);
|
|
939
|
+
return m ? m[1] : null;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function svgContainerClass(html, viewBox) {
|
|
943
|
+
const svgRe = new RegExp(`<svg\\b[^>]*viewBox\\s*=\\s*"${viewBox.replace(/\s+/g, '\\s+')}"`, 'i');
|
|
944
|
+
const svgMatch = html.match(svgRe);
|
|
945
|
+
if (!svgMatch) return null;
|
|
946
|
+
const stopAt = svgMatch.index;
|
|
947
|
+
const tagRe = /<(\/?)div\b([^>]*)>/gi;
|
|
948
|
+
const stack = [];
|
|
949
|
+
let t;
|
|
950
|
+
while ((t = tagRe.exec(html)) !== null) {
|
|
951
|
+
if (t.index >= stopAt) break;
|
|
952
|
+
if (t[1] === '/') { stack.pop(); continue; }
|
|
953
|
+
const cm = t[2].match(/class\s*=\s*"([^"]+)"/i);
|
|
954
|
+
stack.push(cm ? cm[1].split(/\s+/)[0] : null);
|
|
955
|
+
}
|
|
956
|
+
for (let i = stack.length - 1; i >= 0; i--) {
|
|
957
|
+
if (stack[i]) return stack[i];
|
|
958
|
+
}
|
|
959
|
+
return null;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function parseCssBlock(html, className) {
|
|
963
|
+
const re = new RegExp(`\\.${className.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}\\s*\\{([^}]*)\\}`);
|
|
964
|
+
const m = html.match(re);
|
|
965
|
+
if (!m) return null;
|
|
966
|
+
const rules = {};
|
|
967
|
+
for (const decl of m[1].split(';')) {
|
|
968
|
+
const idx = decl.indexOf(':');
|
|
969
|
+
if (idx < 0) continue;
|
|
970
|
+
rules[decl.slice(0, idx).trim()] = decl.slice(idx + 1).trim();
|
|
971
|
+
}
|
|
972
|
+
return rules;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
function parsePx(v) {
|
|
976
|
+
if (v == null) return null;
|
|
977
|
+
const s = String(v).trim();
|
|
978
|
+
if (s === '0') return 0;
|
|
979
|
+
const m = s.match(/^(-?\d+(?:\.\d+)?)px$/);
|
|
980
|
+
return m ? parseFloat(m[1]) : null;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
function parsePadding(v) {
|
|
984
|
+
const parts = (v ?? '').trim().split(/\s+/).map(p => parsePx(p) ?? 0);
|
|
985
|
+
if (parts.length === 1) return { t: parts[0], r: parts[0], b: parts[0], l: parts[0] };
|
|
986
|
+
if (parts.length === 2) return { t: parts[0], r: parts[1], b: parts[0], l: parts[1] };
|
|
987
|
+
if (parts.length === 3) return { t: parts[0], r: parts[1], b: parts[2], l: parts[1] };
|
|
988
|
+
return { t: parts[0] ?? 0, r: parts[1] ?? 0, b: parts[2] ?? 0, l: parts[3] ?? 0 };
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
function computeContainerBox(rules, slideW, slideH) {
|
|
992
|
+
const top = parsePx(rules.top);
|
|
993
|
+
const right = parsePx(rules.right);
|
|
994
|
+
const bottom = parsePx(rules.bottom);
|
|
995
|
+
const left = parsePx(rules.left);
|
|
996
|
+
const width = parsePx(rules.width);
|
|
997
|
+
const height = parsePx(rules.height);
|
|
998
|
+
let x = null, y = null, w = null, h = null;
|
|
999
|
+
if (width != null) w = width;
|
|
1000
|
+
else if (left != null && right != null) w = slideW - left - right;
|
|
1001
|
+
if (height != null) h = height;
|
|
1002
|
+
else if (top != null && bottom != null) h = slideH - top - bottom;
|
|
1003
|
+
if (left != null) x = left;
|
|
1004
|
+
else if (right != null && w != null) x = slideW - right - w;
|
|
1005
|
+
if (top != null) y = top;
|
|
1006
|
+
else if (bottom != null && h != null) y = slideH - bottom - h;
|
|
1007
|
+
if (x == null || y == null || w == null || h == null) return null;
|
|
1008
|
+
return { x, y, w, h };
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
function fitViewBoxMeet(contentBox, vbW, vbH) {
|
|
1012
|
+
const scale = Math.min(contentBox.w / vbW, contentBox.h / vbH);
|
|
1013
|
+
const renderW = vbW * scale;
|
|
1014
|
+
const renderH = vbH * scale;
|
|
1015
|
+
return {
|
|
1016
|
+
x: contentBox.x + (contentBox.w - renderW) / 2,
|
|
1017
|
+
y: contentBox.y + (contentBox.h - renderH) / 2,
|
|
1018
|
+
w: renderW,
|
|
1019
|
+
h: renderH,
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function resolveSvgBounds(el, ctx) {
|
|
1024
|
+
if (!ctx.html || !el.viewBox) return null;
|
|
1025
|
+
const cls = svgContainerClass(ctx.html, el.viewBox);
|
|
1026
|
+
if (!cls) return null;
|
|
1027
|
+
const rules = parseCssBlock(ctx.html, cls);
|
|
1028
|
+
if (!rules || rules.position !== 'absolute') return null;
|
|
1029
|
+
const slideW = 1920, slideH = 1080;
|
|
1030
|
+
const container = computeContainerBox(rules, slideW, slideH);
|
|
1031
|
+
if (!container) return null;
|
|
1032
|
+
const pad = parsePadding(rules.padding);
|
|
1033
|
+
const content = {
|
|
1034
|
+
x: container.x + pad.l,
|
|
1035
|
+
y: container.y + pad.t,
|
|
1036
|
+
w: container.w - pad.l - pad.r,
|
|
1037
|
+
h: container.h - pad.t - pad.b,
|
|
1038
|
+
};
|
|
1039
|
+
const vb = el.viewBox.split(/\s+/).map(Number);
|
|
1040
|
+
return fitViewBoxMeet(content, vb[2], vb[3]);
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
function parseSvgShapes(innerMarkup) {
|
|
1044
|
+
const shapes = [];
|
|
1045
|
+
const stripped = innerMarkup.replace(/<!--[\s\S]*?-->/g, '');
|
|
1046
|
+
const gradients = parseSvgGradients(stripped);
|
|
1047
|
+
const tagRe = /<(circle|line|path|text|rect|ellipse|polyline|polygon)\b([^>]*?)(\/>|>([\s\S]*?)<\/\1\s*>)/gi;
|
|
1048
|
+
let m;
|
|
1049
|
+
while ((m = tagRe.exec(stripped)) !== null) {
|
|
1050
|
+
const tag = m[1].toLowerCase();
|
|
1051
|
+
const attrsChunk = `<x ${m[2]}>`;
|
|
1052
|
+
const body = m[4];
|
|
1053
|
+
if (tag === 'circle') {
|
|
1054
|
+
shapes.push({
|
|
1055
|
+
type: 'circle',
|
|
1056
|
+
cx: numAttr(attrsChunk, 'cx'),
|
|
1057
|
+
cy: numAttr(attrsChunk, 'cy'),
|
|
1058
|
+
r: numAttr(attrsChunk, 'r'),
|
|
1059
|
+
fill: attr(attrsChunk, 'fill'),
|
|
1060
|
+
stroke: attr(attrsChunk, 'stroke'),
|
|
1061
|
+
strokeWidth: numAttr(attrsChunk, 'stroke-width'),
|
|
1062
|
+
strokeLinecap: attr(attrsChunk, 'stroke-linecap'),
|
|
1063
|
+
opacity: numAttr(attrsChunk, 'opacity'),
|
|
1064
|
+
});
|
|
1065
|
+
} else if (tag === 'line') {
|
|
1066
|
+
shapes.push({
|
|
1067
|
+
type: 'line',
|
|
1068
|
+
x1: numAttr(attrsChunk, 'x1'),
|
|
1069
|
+
y1: numAttr(attrsChunk, 'y1'),
|
|
1070
|
+
x2: numAttr(attrsChunk, 'x2'),
|
|
1071
|
+
y2: numAttr(attrsChunk, 'y2'),
|
|
1072
|
+
stroke: attr(attrsChunk, 'stroke'),
|
|
1073
|
+
strokeWidth: numAttr(attrsChunk, 'stroke-width'),
|
|
1074
|
+
strokeLinecap: attr(attrsChunk, 'stroke-linecap'),
|
|
1075
|
+
strokeDasharray: attr(attrsChunk, 'stroke-dasharray'),
|
|
1076
|
+
opacity: numAttr(attrsChunk, 'opacity'),
|
|
1077
|
+
});
|
|
1078
|
+
} else if (tag === 'path') {
|
|
1079
|
+
shapes.push({
|
|
1080
|
+
type: 'path',
|
|
1081
|
+
d: attr(attrsChunk, 'd'),
|
|
1082
|
+
fill: attr(attrsChunk, 'fill'),
|
|
1083
|
+
stroke: attr(attrsChunk, 'stroke'),
|
|
1084
|
+
strokeWidth: numAttr(attrsChunk, 'stroke-width'),
|
|
1085
|
+
strokeLinecap: attr(attrsChunk, 'stroke-linecap'),
|
|
1086
|
+
strokeLinejoin: attr(attrsChunk, 'stroke-linejoin'),
|
|
1087
|
+
strokeDasharray: attr(attrsChunk, 'stroke-dasharray'),
|
|
1088
|
+
opacity: numAttr(attrsChunk, 'opacity'),
|
|
1089
|
+
});
|
|
1090
|
+
} else if (tag === 'polyline' || tag === 'polygon') {
|
|
1091
|
+
const pts = (attr(attrsChunk, 'points') || '').trim();
|
|
1092
|
+
const nums = pts.split(/[\s,]+/).filter(Boolean).map(Number);
|
|
1093
|
+
if (nums.length < 4 || nums.length % 2 !== 0) continue;
|
|
1094
|
+
const parts = [];
|
|
1095
|
+
for (let i = 0; i < nums.length; i += 2) {
|
|
1096
|
+
parts.push(`${i === 0 ? 'M' : 'L'} ${nums[i]} ${nums[i + 1]}`);
|
|
1097
|
+
}
|
|
1098
|
+
if (tag === 'polygon') parts.push('Z');
|
|
1099
|
+
shapes.push({
|
|
1100
|
+
type: 'path',
|
|
1101
|
+
d: parts.join(' '),
|
|
1102
|
+
fill: attr(attrsChunk, 'fill'),
|
|
1103
|
+
stroke: attr(attrsChunk, 'stroke'),
|
|
1104
|
+
strokeWidth: numAttr(attrsChunk, 'stroke-width'),
|
|
1105
|
+
strokeLinecap: attr(attrsChunk, 'stroke-linecap'),
|
|
1106
|
+
strokeLinejoin: attr(attrsChunk, 'stroke-linejoin'),
|
|
1107
|
+
opacity: numAttr(attrsChunk, 'opacity'),
|
|
1108
|
+
});
|
|
1109
|
+
} else if (tag === 'rect') {
|
|
1110
|
+
shapes.push({
|
|
1111
|
+
type: 'rect',
|
|
1112
|
+
x: numAttr(attrsChunk, 'x') ?? 0,
|
|
1113
|
+
y: numAttr(attrsChunk, 'y') ?? 0,
|
|
1114
|
+
width: numAttr(attrsChunk, 'width') ?? 0,
|
|
1115
|
+
height: numAttr(attrsChunk, 'height') ?? 0,
|
|
1116
|
+
rx: numAttr(attrsChunk, 'rx'),
|
|
1117
|
+
ry: numAttr(attrsChunk, 'ry'),
|
|
1118
|
+
fill: attr(attrsChunk, 'fill'),
|
|
1119
|
+
stroke: attr(attrsChunk, 'stroke'),
|
|
1120
|
+
strokeWidth: numAttr(attrsChunk, 'stroke-width'),
|
|
1121
|
+
opacity: numAttr(attrsChunk, 'opacity'),
|
|
1122
|
+
});
|
|
1123
|
+
} else if (tag === 'ellipse') {
|
|
1124
|
+
shapes.push({
|
|
1125
|
+
type: 'ellipse',
|
|
1126
|
+
cx: numAttr(attrsChunk, 'cx') ?? 0,
|
|
1127
|
+
cy: numAttr(attrsChunk, 'cy') ?? 0,
|
|
1128
|
+
rx: numAttr(attrsChunk, 'rx') ?? 0,
|
|
1129
|
+
ry: numAttr(attrsChunk, 'ry') ?? 0,
|
|
1130
|
+
fill: attr(attrsChunk, 'fill'),
|
|
1131
|
+
stroke: attr(attrsChunk, 'stroke'),
|
|
1132
|
+
strokeWidth: numAttr(attrsChunk, 'stroke-width'),
|
|
1133
|
+
opacity: numAttr(attrsChunk, 'opacity'),
|
|
1134
|
+
});
|
|
1135
|
+
} else if (tag === 'text') {
|
|
1136
|
+
shapes.push({
|
|
1137
|
+
type: 'text',
|
|
1138
|
+
x: numAttr(attrsChunk, 'x'),
|
|
1139
|
+
y: numAttr(attrsChunk, 'y'),
|
|
1140
|
+
fill: attr(attrsChunk, 'fill'),
|
|
1141
|
+
fontSize: numAttr(attrsChunk, 'font-size'),
|
|
1142
|
+
fontFamily: attr(attrsChunk, 'font-family'),
|
|
1143
|
+
fontStyle: attr(attrsChunk, 'font-style'),
|
|
1144
|
+
fontWeight: attr(attrsChunk, 'font-weight'),
|
|
1145
|
+
textAnchor: attr(attrsChunk, 'text-anchor'),
|
|
1146
|
+
text: (body ?? '').trim(),
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
return { shapes, gradients };
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
// Parse <linearGradient> / <radialGradient> defs, keyed by id.
|
|
1154
|
+
// Returns a Map<id, { type, x1, y1, x2, y2, cx, cy, r, units, transform, stops }>.
|
|
1155
|
+
// `units` is 'objectBoundingBox' (default) or 'userSpaceOnUse'.
|
|
1156
|
+
// `transform` is a 2x3 affine [a,b,c,d,e,f] from gradientTransform, or null.
|
|
1157
|
+
function parseSvgGradients(markup) {
|
|
1158
|
+
const out = new Map();
|
|
1159
|
+
const gradRe = /<(linearGradient|radialGradient)\b([^>]*?)>([\s\S]*?)<\/\1\s*>/gi;
|
|
1160
|
+
let gm;
|
|
1161
|
+
while ((gm = gradRe.exec(markup)) !== null) {
|
|
1162
|
+
const kind = gm[1].toLowerCase();
|
|
1163
|
+
const attrsChunk = `<x ${gm[2]}>`;
|
|
1164
|
+
const id = attr(attrsChunk, 'id');
|
|
1165
|
+
if (!id) continue;
|
|
1166
|
+
const body = gm[3];
|
|
1167
|
+
const stopRe = /<stop\b([^>]*?)(?:\/>|>[\s\S]*?<\/stop\s*>)/gi;
|
|
1168
|
+
const stops = [];
|
|
1169
|
+
let sm;
|
|
1170
|
+
while ((sm = stopRe.exec(body)) !== null) {
|
|
1171
|
+
const sAttrs = `<x ${sm[1]}>`;
|
|
1172
|
+
const offsetRaw = attr(sAttrs, 'offset');
|
|
1173
|
+
const offset = offsetRaw
|
|
1174
|
+
? (offsetRaw.endsWith('%') ? parseFloat(offsetRaw) / 100 : parseFloat(offsetRaw))
|
|
1175
|
+
: 0;
|
|
1176
|
+
const color = attr(sAttrs, 'stop-color') || '#000';
|
|
1177
|
+
const op = attr(sAttrs, 'stop-opacity');
|
|
1178
|
+
const opacity = op != null ? parseFloat(op) : 1;
|
|
1179
|
+
stops.push({ position: offset, color, opacity });
|
|
1180
|
+
}
|
|
1181
|
+
if (stops.length === 0) continue;
|
|
1182
|
+
const unitsRaw = attr(attrsChunk, 'gradientUnits');
|
|
1183
|
+
const units = unitsRaw === 'userSpaceOnUse' ? 'userSpaceOnUse' : 'objectBoundingBox';
|
|
1184
|
+
const transformRaw = attr(attrsChunk, 'gradientTransform');
|
|
1185
|
+
const transform = transformRaw ? parseSvgTransform(transformRaw) : null;
|
|
1186
|
+
// kind was lowercased above, so compare lowercase.
|
|
1187
|
+
const entry = { type: kind === 'lineargradient' ? 'linear' : 'radial', stops, units, transform };
|
|
1188
|
+
const defaultEnd = units === 'userSpaceOnUse' ? 0 : 1;
|
|
1189
|
+
if (entry.type === 'linear') {
|
|
1190
|
+
entry.x1 = numAttr(attrsChunk, 'x1') ?? 0;
|
|
1191
|
+
entry.y1 = numAttr(attrsChunk, 'y1') ?? 0;
|
|
1192
|
+
entry.x2 = numAttr(attrsChunk, 'x2') ?? defaultEnd;
|
|
1193
|
+
entry.y2 = numAttr(attrsChunk, 'y2') ?? 0;
|
|
1194
|
+
} else {
|
|
1195
|
+
entry.cx = numAttr(attrsChunk, 'cx') ?? 0.5;
|
|
1196
|
+
entry.cy = numAttr(attrsChunk, 'cy') ?? 0.5;
|
|
1197
|
+
entry.r = numAttr(attrsChunk, 'r') ?? 0.5;
|
|
1198
|
+
}
|
|
1199
|
+
out.set(id, entry);
|
|
1200
|
+
}
|
|
1201
|
+
return out;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// Parse an SVG gradientTransform string into a 2x3 affine [a,b,c,d,e,f]
|
|
1205
|
+
// encoding [[a,c,e],[b,d,f],[0,0,1]]. Primitives compose left-to-right.
|
|
1206
|
+
function parseSvgTransform(str) {
|
|
1207
|
+
const mul = (A, B) => [
|
|
1208
|
+
A[0] * B[0] + A[2] * B[1],
|
|
1209
|
+
A[1] * B[0] + A[3] * B[1],
|
|
1210
|
+
A[0] * B[2] + A[2] * B[3],
|
|
1211
|
+
A[1] * B[2] + A[3] * B[3],
|
|
1212
|
+
A[0] * B[4] + A[2] * B[5] + A[4],
|
|
1213
|
+
A[1] * B[4] + A[3] * B[5] + A[5],
|
|
1214
|
+
];
|
|
1215
|
+
let m = [1, 0, 0, 1, 0, 0];
|
|
1216
|
+
const re = /(matrix|translate|scale|rotate|skewX|skewY)\s*\(([^)]*)\)/gi;
|
|
1217
|
+
let tm;
|
|
1218
|
+
while ((tm = re.exec(str)) !== null) {
|
|
1219
|
+
const name = tm[1].toLowerCase();
|
|
1220
|
+
const nums = tm[2].trim().split(/[\s,]+/).filter(Boolean).map(Number);
|
|
1221
|
+
let T;
|
|
1222
|
+
if (name === 'matrix') {
|
|
1223
|
+
if (nums.length < 6) continue;
|
|
1224
|
+
T = nums.slice(0, 6);
|
|
1225
|
+
} else if (name === 'translate') {
|
|
1226
|
+
T = [1, 0, 0, 1, nums[0] ?? 0, nums[1] ?? 0];
|
|
1227
|
+
} else if (name === 'scale') {
|
|
1228
|
+
const sx = nums[0] ?? 1;
|
|
1229
|
+
const sy = nums.length >= 2 ? nums[1] : sx;
|
|
1230
|
+
T = [sx, 0, 0, sy, 0, 0];
|
|
1231
|
+
} else if (name === 'rotate') {
|
|
1232
|
+
const a = ((nums[0] ?? 0) * Math.PI) / 180;
|
|
1233
|
+
const cos = Math.cos(a), sin = Math.sin(a);
|
|
1234
|
+
const cx = nums[1] ?? 0, cy = nums[2] ?? 0;
|
|
1235
|
+
if (cx === 0 && cy === 0) {
|
|
1236
|
+
T = [cos, sin, -sin, cos, 0, 0];
|
|
1237
|
+
} else {
|
|
1238
|
+
T = mul([1, 0, 0, 1, cx, cy], [cos, sin, -sin, cos, 0, 0]);
|
|
1239
|
+
T = mul(T, [1, 0, 0, 1, -cx, -cy]);
|
|
1240
|
+
}
|
|
1241
|
+
} else if (name === 'skewx') {
|
|
1242
|
+
T = [1, 0, Math.tan(((nums[0] ?? 0) * Math.PI) / 180), 1, 0, 0];
|
|
1243
|
+
} else if (name === 'skewy') {
|
|
1244
|
+
T = [1, Math.tan(((nums[0] ?? 0) * Math.PI) / 180), 0, 1, 0, 0];
|
|
1245
|
+
} else {
|
|
1246
|
+
continue;
|
|
1247
|
+
}
|
|
1248
|
+
m = mul(m, T);
|
|
1249
|
+
}
|
|
1250
|
+
return m;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
function applyAffine(m, x, y) {
|
|
1254
|
+
return [m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]];
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
// Bounding box of a parsed SVG shape in its own user space. Used to
|
|
1258
|
+
// normalize userSpaceOnUse gradients against the referencing shape.
|
|
1259
|
+
function shapeBBoxSvg(sh) {
|
|
1260
|
+
if (!sh) return null;
|
|
1261
|
+
if (sh.type === 'rect') {
|
|
1262
|
+
return { x: sh.x ?? 0, y: sh.y ?? 0, w: sh.width ?? 0, h: sh.height ?? 0 };
|
|
1263
|
+
}
|
|
1264
|
+
if (sh.type === 'circle') {
|
|
1265
|
+
const r = sh.r ?? 0;
|
|
1266
|
+
return { x: (sh.cx ?? 0) - r, y: (sh.cy ?? 0) - r, w: r * 2, h: r * 2 };
|
|
1267
|
+
}
|
|
1268
|
+
if (sh.type === 'ellipse') {
|
|
1269
|
+
const rx = sh.rx ?? 0, ry = sh.ry ?? 0;
|
|
1270
|
+
return { x: (sh.cx ?? 0) - rx, y: (sh.cy ?? 0) - ry, w: rx * 2, h: ry * 2 };
|
|
1271
|
+
}
|
|
1272
|
+
if (sh.type === 'path' && sh.d) {
|
|
1273
|
+
const cmds = pathDToAbsoluteCmds(sh.d);
|
|
1274
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
1275
|
+
for (const c of cmds) {
|
|
1276
|
+
if (c.cmd === 'Z' || !c.pts) continue;
|
|
1277
|
+
for (const [x, y] of c.pts) {
|
|
1278
|
+
if (x < minX) minX = x; if (y < minY) minY = y;
|
|
1279
|
+
if (x > maxX) maxX = x; if (y > maxY) maxY = y;
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
if (!Number.isFinite(minX)) return null;
|
|
1283
|
+
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
|
|
1284
|
+
}
|
|
1285
|
+
return null;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// Convert one SVG elliptical arc segment to a sequence of cubic bezier
|
|
1289
|
+
// curves (≤90° per piece). Implements the center-parameterization conversion
|
|
1290
|
+
// from SVG 1.1 Appendix F.6. Returns an array of [cp1, cp2, end] triples in
|
|
1291
|
+
// absolute coordinates; each triple corresponds to a single C command.
|
|
1292
|
+
function arcToCubicBeziers(x1, y1, rx, ry, phiDeg, fA, fS, x2, y2) {
|
|
1293
|
+
if (x1 === x2 && y1 === y2) return [];
|
|
1294
|
+
if (!rx || !ry) return [[[x2, y2], [x2, y2], [x2, y2]]];
|
|
1295
|
+
const absRx = Math.abs(rx);
|
|
1296
|
+
const absRy = Math.abs(ry);
|
|
1297
|
+
const phi = (phiDeg * Math.PI) / 180;
|
|
1298
|
+
const cosPhi = Math.cos(phi);
|
|
1299
|
+
const sinPhi = Math.sin(phi);
|
|
1300
|
+
const dx = (x1 - x2) / 2;
|
|
1301
|
+
const dy = (y1 - y2) / 2;
|
|
1302
|
+
const x1p = cosPhi * dx + sinPhi * dy;
|
|
1303
|
+
const y1p = -sinPhi * dx + cosPhi * dy;
|
|
1304
|
+
const x1p2 = x1p * x1p;
|
|
1305
|
+
const y1p2 = y1p * y1p;
|
|
1306
|
+
let rxAdj = absRx;
|
|
1307
|
+
let ryAdj = absRy;
|
|
1308
|
+
const lambda = x1p2 / (rxAdj * rxAdj) + y1p2 / (ryAdj * ryAdj);
|
|
1309
|
+
if (lambda > 1) {
|
|
1310
|
+
const s = Math.sqrt(lambda);
|
|
1311
|
+
rxAdj *= s;
|
|
1312
|
+
ryAdj *= s;
|
|
1313
|
+
}
|
|
1314
|
+
const rx2 = rxAdj * rxAdj;
|
|
1315
|
+
const ry2 = ryAdj * ryAdj;
|
|
1316
|
+
const sign = fA === fS ? -1 : 1;
|
|
1317
|
+
const num = rx2 * ry2 - rx2 * y1p2 - ry2 * x1p2;
|
|
1318
|
+
const den = rx2 * y1p2 + ry2 * x1p2;
|
|
1319
|
+
const coef = sign * Math.sqrt(Math.max(0, num / den));
|
|
1320
|
+
const cxp = coef * (rxAdj * y1p) / ryAdj;
|
|
1321
|
+
const cyp = coef * -(ryAdj * x1p) / rxAdj;
|
|
1322
|
+
const cx = cosPhi * cxp - sinPhi * cyp + (x1 + x2) / 2;
|
|
1323
|
+
const cy = sinPhi * cxp + cosPhi * cyp + (y1 + y2) / 2;
|
|
1324
|
+
const fSEff = fS;
|
|
1325
|
+
const angle = (ux, uy, vx, vy) => {
|
|
1326
|
+
const dot = ux * vx + uy * vy;
|
|
1327
|
+
const len = Math.sqrt((ux * ux + uy * uy) * (vx * vx + vy * vy));
|
|
1328
|
+
let a = Math.acos(Math.max(-1, Math.min(1, dot / len)));
|
|
1329
|
+
if (ux * vy - uy * vx < 0) a = -a;
|
|
1330
|
+
return a;
|
|
1331
|
+
};
|
|
1332
|
+
const theta1 = angle(1, 0, (x1p - cxp) / rxAdj, (y1p - cyp) / ryAdj);
|
|
1333
|
+
let deltaTheta = angle(
|
|
1334
|
+
(x1p - cxp) / rxAdj, (y1p - cyp) / ryAdj,
|
|
1335
|
+
(-x1p - cxp) / rxAdj, (-y1p - cyp) / ryAdj,
|
|
1336
|
+
);
|
|
1337
|
+
if (!fSEff && deltaTheta > 0) deltaTheta -= 2 * Math.PI;
|
|
1338
|
+
else if (fSEff && deltaTheta < 0) deltaTheta += 2 * Math.PI;
|
|
1339
|
+
const segments = Math.max(1, Math.ceil(Math.abs(deltaTheta) / (Math.PI / 6)));
|
|
1340
|
+
const dtheta = deltaTheta / segments;
|
|
1341
|
+
const k = (4 / 3) * Math.tan(dtheta / 4);
|
|
1342
|
+
const project = (lx, ly) => {
|
|
1343
|
+
const x = lx * rxAdj;
|
|
1344
|
+
const y = ly * ryAdj;
|
|
1345
|
+
return [cosPhi * x - sinPhi * y + cx, sinPhi * x + cosPhi * y + cy];
|
|
1346
|
+
};
|
|
1347
|
+
const out = [];
|
|
1348
|
+
let th = theta1;
|
|
1349
|
+
for (let i = 0; i < segments; i++) {
|
|
1350
|
+
const th2 = th + dtheta;
|
|
1351
|
+
const cos1 = Math.cos(th), sin1 = Math.sin(th);
|
|
1352
|
+
const cos2 = Math.cos(th2), sin2 = Math.sin(th2);
|
|
1353
|
+
const cp1 = project(cos1 - k * sin1, sin1 + k * cos1);
|
|
1354
|
+
const cp2 = project(cos2 + k * sin2, sin2 - k * cos2);
|
|
1355
|
+
const end = project(cos2, sin2);
|
|
1356
|
+
out.push([cp1, cp2, end]);
|
|
1357
|
+
th = th2;
|
|
1358
|
+
}
|
|
1359
|
+
return out;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
function circleBezierPath(cx, cy, r) {
|
|
1363
|
+
const k = 0.5522847498 * r;
|
|
1364
|
+
return `M ${cx + r} ${cy} ` +
|
|
1365
|
+
`C ${cx + r} ${cy + k} ${cx + k} ${cy + r} ${cx} ${cy + r} ` +
|
|
1366
|
+
`C ${cx - k} ${cy + r} ${cx - r} ${cy + k} ${cx - r} ${cy} ` +
|
|
1367
|
+
`C ${cx - r} ${cy - k} ${cx - k} ${cy - r} ${cx} ${cy - r} ` +
|
|
1368
|
+
`C ${cx + k} ${cy - r} ${cx + r} ${cy - k} ${cx + r} ${cy} Z`;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
// Parse an SVG path `d` string into an array of { cmd, nums } commands.
|
|
1372
|
+
// Handles implicit repeated commands and concatenated numbers like `h38m0 0l-10-9`.
|
|
1373
|
+
function tokenizePathD(d) {
|
|
1374
|
+
const NUM_RE = /[+-]?(?:\d+\.\d+|\.\d+|\d+)(?:[eE][+-]?\d+)?/g;
|
|
1375
|
+
const tokens = [];
|
|
1376
|
+
const cmdRe = /[MmLlHhVvCcSsQqTtAaZz]/g;
|
|
1377
|
+
let m;
|
|
1378
|
+
const marks = [];
|
|
1379
|
+
while ((m = cmdRe.exec(d)) !== null) marks.push({ cmd: m[0], start: m.index });
|
|
1380
|
+
for (let i = 0; i < marks.length; i++) {
|
|
1381
|
+
const { cmd, start } = marks[i];
|
|
1382
|
+
const end = i + 1 < marks.length ? marks[i + 1].start : d.length;
|
|
1383
|
+
const segment = d.slice(start + 1, end);
|
|
1384
|
+
const nums = [];
|
|
1385
|
+
let nm;
|
|
1386
|
+
NUM_RE.lastIndex = 0;
|
|
1387
|
+
while ((nm = NUM_RE.exec(segment)) !== null) nums.push(parseFloat(nm[0]));
|
|
1388
|
+
tokens.push({ cmd, nums });
|
|
1389
|
+
}
|
|
1390
|
+
return tokens;
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
// Convert tokenized path into absolute M/L/C/Q/Z commands with resolved
|
|
1394
|
+
// coordinates. Tracks pen position and last moveto for Z. Implicit commands
|
|
1395
|
+
// after M become L; after m become l; multiple coord groups are handled.
|
|
1396
|
+
function pathDToAbsoluteCmds(d) {
|
|
1397
|
+
const tokens = tokenizePathD(d);
|
|
1398
|
+
let cx = 0, cy = 0; // current pen
|
|
1399
|
+
let sx = 0, sy = 0; // last moveto (subpath start)
|
|
1400
|
+
let lastCx = null, lastCy = null; // last control point (for S/T smoothing)
|
|
1401
|
+
const out = [];
|
|
1402
|
+
for (const { cmd, nums } of tokens) {
|
|
1403
|
+
const abs = cmd === cmd.toUpperCase();
|
|
1404
|
+
const lc = cmd.toLowerCase();
|
|
1405
|
+
if (lc === 'z') { out.push({ cmd: 'Z' }); cx = sx; cy = sy; lastCx = lastCy = null; continue; }
|
|
1406
|
+
let i = 0;
|
|
1407
|
+
const take = (n) => { const r = nums.slice(i, i + n); i += n; return r; };
|
|
1408
|
+
const firstOfPolyline = { m: true };
|
|
1409
|
+
let isFirstPair = true;
|
|
1410
|
+
while (i < nums.length) {
|
|
1411
|
+
if (lc === 'm') {
|
|
1412
|
+
const [x, y] = take(2);
|
|
1413
|
+
cx = abs ? x : cx + x; cy = abs ? y : cy + y;
|
|
1414
|
+
if (isFirstPair) { sx = cx; sy = cy; out.push({ cmd: 'M', pts: [[cx, cy]] }); isFirstPair = false; }
|
|
1415
|
+
else out.push({ cmd: 'L', pts: [[cx, cy]] }); // subsequent pairs become L
|
|
1416
|
+
lastCx = lastCy = null;
|
|
1417
|
+
} else if (lc === 'l') {
|
|
1418
|
+
const [x, y] = take(2);
|
|
1419
|
+
cx = abs ? x : cx + x; cy = abs ? y : cy + y;
|
|
1420
|
+
out.push({ cmd: 'L', pts: [[cx, cy]] });
|
|
1421
|
+
lastCx = lastCy = null;
|
|
1422
|
+
} else if (lc === 'h') {
|
|
1423
|
+
const [x] = take(1);
|
|
1424
|
+
cx = abs ? x : cx + x;
|
|
1425
|
+
out.push({ cmd: 'L', pts: [[cx, cy]] });
|
|
1426
|
+
lastCx = lastCy = null;
|
|
1427
|
+
} else if (lc === 'v') {
|
|
1428
|
+
const [y] = take(1);
|
|
1429
|
+
cy = abs ? y : cy + y;
|
|
1430
|
+
out.push({ cmd: 'L', pts: [[cx, cy]] });
|
|
1431
|
+
lastCx = lastCy = null;
|
|
1432
|
+
} else if (lc === 'c') {
|
|
1433
|
+
const [x1, y1, x2, y2, x, y] = take(6);
|
|
1434
|
+
const p1 = [abs ? x1 : cx + x1, abs ? y1 : cy + y1];
|
|
1435
|
+
const p2 = [abs ? x2 : cx + x2, abs ? y2 : cy + y2];
|
|
1436
|
+
cx = abs ? x : cx + x; cy = abs ? y : cy + y;
|
|
1437
|
+
out.push({ cmd: 'C', pts: [p1, p2, [cx, cy]] });
|
|
1438
|
+
lastCx = p2[0]; lastCy = p2[1];
|
|
1439
|
+
} else if (lc === 's') {
|
|
1440
|
+
const [x2, y2, x, y] = take(4);
|
|
1441
|
+
const p1 = (lastCx !== null) ? [2 * cx - lastCx, 2 * cy - lastCy] : [cx, cy];
|
|
1442
|
+
const p2 = [abs ? x2 : cx + x2, abs ? y2 : cy + y2];
|
|
1443
|
+
cx = abs ? x : cx + x; cy = abs ? y : cy + y;
|
|
1444
|
+
out.push({ cmd: 'C', pts: [p1, p2, [cx, cy]] });
|
|
1445
|
+
lastCx = p2[0]; lastCy = p2[1];
|
|
1446
|
+
} else if (lc === 'q') {
|
|
1447
|
+
const [x1, y1, x, y] = take(4);
|
|
1448
|
+
const p1 = [abs ? x1 : cx + x1, abs ? y1 : cy + y1];
|
|
1449
|
+
cx = abs ? x : cx + x; cy = abs ? y : cy + y;
|
|
1450
|
+
out.push({ cmd: 'Q', pts: [p1, [cx, cy]] });
|
|
1451
|
+
lastCx = p1[0]; lastCy = p1[1];
|
|
1452
|
+
} else if (lc === 't') {
|
|
1453
|
+
const [x, y] = take(2);
|
|
1454
|
+
const p1 = (lastCx !== null) ? [2 * cx - lastCx, 2 * cy - lastCy] : [cx, cy];
|
|
1455
|
+
cx = abs ? x : cx + x; cy = abs ? y : cy + y;
|
|
1456
|
+
out.push({ cmd: 'Q', pts: [p1, [cx, cy]] });
|
|
1457
|
+
lastCx = p1[0]; lastCy = p1[1];
|
|
1458
|
+
} else {
|
|
1459
|
+
// Arc (A/a): rx ry x-axis-rotation large-arc-flag sweep-flag x y
|
|
1460
|
+
const [rx, ry, rot, fA, fS, xn, yn] = take(7);
|
|
1461
|
+
const endX = abs ? xn : cx + xn;
|
|
1462
|
+
const endY = abs ? yn : cy + yn;
|
|
1463
|
+
const beziers = arcToCubicBeziers(cx, cy, rx, ry, rot, !!fA, !!fS, endX, endY);
|
|
1464
|
+
if (beziers.length === 0) {
|
|
1465
|
+
out.push({ cmd: 'L', pts: [[endX, endY]] });
|
|
1466
|
+
} else {
|
|
1467
|
+
for (const [p1, p2, p3] of beziers) out.push({ cmd: 'C', pts: [p1, p2, p3] });
|
|
1468
|
+
}
|
|
1469
|
+
cx = endX; cy = endY;
|
|
1470
|
+
lastCx = lastCy = null;
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
return out;
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
function transformPathD(d, X, Y) {
|
|
1478
|
+
const cmds = pathDToAbsoluteCmds(d);
|
|
1479
|
+
const parts = [];
|
|
1480
|
+
for (const c of cmds) {
|
|
1481
|
+
if (c.cmd === 'Z') { parts.push('Z'); continue; }
|
|
1482
|
+
const coords = c.pts.map(([x, y]) => `${X(x).toFixed(3)} ${Y(y).toFixed(3)}`).join(' ');
|
|
1483
|
+
parts.push(`${c.cmd} ${coords}`);
|
|
1484
|
+
}
|
|
1485
|
+
return parts.join(' ');
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
async function handleSvg(slide, el, ctx) {
|
|
1489
|
+
const vb = (el.viewBox ?? '0 0 600 600').split(/\s+/).map(Number);
|
|
1490
|
+
const vbX = vb[0] ?? 0;
|
|
1491
|
+
const vbY = vb[1] ?? 0;
|
|
1492
|
+
const vbW = vb[2] ?? 600;
|
|
1493
|
+
const vbH = vb[3] ?? 600;
|
|
1494
|
+
const htmlBounds = resolveSvgBounds(el, ctx);
|
|
1495
|
+
const boxX = htmlBounds?.x ?? el.x;
|
|
1496
|
+
const boxY = htmlBounds?.y ?? el.y;
|
|
1497
|
+
const boxW = htmlBounds?.w ?? el.width;
|
|
1498
|
+
const boxH = htmlBounds?.h ?? el.height;
|
|
1499
|
+
const sx = boxW / vbW;
|
|
1500
|
+
const sy = boxH / vbH;
|
|
1501
|
+
const X = x => boxX + (x - vbX) * sx;
|
|
1502
|
+
const Y = y => boxY + (y - vbY) * sy;
|
|
1503
|
+
const S = Math.min(sx, sy);
|
|
1504
|
+
// Inherited CSS group opacity from ancestors (e.g. `.s1-deco { opacity: 0.12 }`).
|
|
1505
|
+
// Applied as node-level opacity to every emitted shape so decorative SVGs
|
|
1506
|
+
// render at the designer-intended weight.
|
|
1507
|
+
const svgOpacity = (typeof el.opacity === 'number' && el.opacity < 1) ? el.opacity : null;
|
|
1508
|
+
// Multiply ancestor group-opacity by each shape's own `opacity` attribute so
|
|
1509
|
+
// a partially-transparent <path> inside a dimmed group renders at the
|
|
1510
|
+
// product of the two values (matches CSS/SVG compositing semantics).
|
|
1511
|
+
const applyOpacity = (node, sh) => {
|
|
1512
|
+
if (!node) return;
|
|
1513
|
+
const shapeOp = (typeof sh?.opacity === 'number' && sh.opacity < 1) ? sh.opacity : null;
|
|
1514
|
+
const combined = svgOpacity != null && shapeOp != null
|
|
1515
|
+
? svgOpacity * shapeOp
|
|
1516
|
+
: (svgOpacity ?? shapeOp);
|
|
1517
|
+
if (combined != null && combined < 1) node.opacity = combined;
|
|
1518
|
+
};
|
|
1519
|
+
|
|
1520
|
+
let shapes = el.shapes;
|
|
1521
|
+
let gradients = el.gradients instanceof Map ? el.gradients : new Map();
|
|
1522
|
+
if (!shapes) {
|
|
1523
|
+
// Prefer the per-element inline markup captured by the browser extractor
|
|
1524
|
+
// (el.outerHTML). Falling back to regex-matching ctx.html by viewBox is
|
|
1525
|
+
// ambiguous when multiple <svg> blocks on different slides share the
|
|
1526
|
+
// same viewBox (e.g. a progress ring on one slide and a donut chart on
|
|
1527
|
+
// another both use viewBox="-50 -50 100 100"); .match() picks the first
|
|
1528
|
+
// occurrence and the later SVG gets the wrong shapes.
|
|
1529
|
+
let markup = null;
|
|
1530
|
+
if (typeof el.inline === 'string' && el.inline.length > 0) {
|
|
1531
|
+
const innerMatch = el.inline.match(/<svg\b[^>]*>([\s\S]*)<\/svg>\s*$/i);
|
|
1532
|
+
markup = innerMatch ? innerMatch[1] : el.inline;
|
|
1533
|
+
}
|
|
1534
|
+
if (!markup) {
|
|
1535
|
+
if (!ctx.html) {
|
|
1536
|
+
throw new Error(`svg element "${el.id}" (slide ${ctx.slideIndex}) has no shapes[] and bundle has no HTML source to extract from`);
|
|
1537
|
+
}
|
|
1538
|
+
markup = findSvgBlock(ctx.html, el.viewBox ?? '0 0 600 600');
|
|
1539
|
+
if (!markup) {
|
|
1540
|
+
throw new Error(`svg element "${el.id}" (slide ${ctx.slideIndex}): no <svg viewBox="${el.viewBox}"> found in bundle HTML`);
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
const parsed = parseSvgShapes(markup);
|
|
1544
|
+
shapes = parsed.shapes;
|
|
1545
|
+
gradients = parsed.gradients;
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
// Resolve `fill="url(#id)"` into either a Figma GRADIENT_LINEAR / _RADIAL
|
|
1549
|
+
// paint (returned as { gradient: paintObj }) or null if the ref is unknown
|
|
1550
|
+
// or the gradient has no stops. Solid fills come back as a hex string
|
|
1551
|
+
// via normalizeColor() as before.
|
|
1552
|
+
const resolveFill = (raw, shape) => {
|
|
1553
|
+
if (!raw) return { fill: null };
|
|
1554
|
+
const m = /^url\(#([^)]+)\)$/.exec(raw.trim());
|
|
1555
|
+
if (!m) return { fill: normalizeColor(raw) };
|
|
1556
|
+
const g = gradients.get(m[1]);
|
|
1557
|
+
if (!g) return { fill: null };
|
|
1558
|
+
const bbox = g.units === 'userSpaceOnUse' ? shapeBBoxSvg(shape) : null;
|
|
1559
|
+
return { gradient: buildGradientPaint(g, bbox) };
|
|
1560
|
+
};
|
|
1561
|
+
|
|
1562
|
+
const applyGradient = (node, gradientPaint) => {
|
|
1563
|
+
if (node && gradientPaint) node.fillPaints = [gradientPaint];
|
|
1564
|
+
};
|
|
1565
|
+
|
|
1566
|
+
for (const sh of shapes) {
|
|
1567
|
+
if (sh.type === 'circle') {
|
|
1568
|
+
const { fill, gradient } = resolveFill(sh.fill, sh);
|
|
1569
|
+
const stroke = normalizeColor(sh.stroke);
|
|
1570
|
+
const strokeWeight = (sh.strokeWidth ?? 1) * S;
|
|
1571
|
+
if (fill || gradient || stroke) {
|
|
1572
|
+
const d = circleBezierPath(X(sh.cx), Y(sh.cy), sh.r * S);
|
|
1573
|
+
const opts = { name: 'Circle' };
|
|
1574
|
+
if (fill) opts.fill = fill;
|
|
1575
|
+
else if (gradient) opts.fill = '#000000'; // placeholder so addPath emits fill geometry
|
|
1576
|
+
if (stroke) { opts.stroke = stroke; opts.strokeWeight = strokeWeight; }
|
|
1577
|
+
else opts.strokeWeight = 0;
|
|
1578
|
+
const node = slide.addPath(d, opts);
|
|
1579
|
+
if (gradient) applyGradient(node, gradient);
|
|
1580
|
+
applyOpacity(node, sh);
|
|
1581
|
+
}
|
|
1582
|
+
} else if (sh.type === 'ellipse') {
|
|
1583
|
+
const { fill, gradient } = resolveFill(sh.fill, sh);
|
|
1584
|
+
const stroke = normalizeColor(sh.stroke);
|
|
1585
|
+
const strokeWeight = (sh.strokeWidth ?? 1) * S;
|
|
1586
|
+
const rx = (sh.rx ?? 0) * sx;
|
|
1587
|
+
const ry = (sh.ry ?? 0) * sy;
|
|
1588
|
+
const opts = {};
|
|
1589
|
+
if (fill) opts.fill = fill;
|
|
1590
|
+
if (stroke) { opts.stroke = stroke; opts.strokeWeight = strokeWeight; }
|
|
1591
|
+
const node = slide.addEllipse(X(sh.cx) - rx, Y(sh.cy) - ry, 2 * rx, 2 * ry, opts);
|
|
1592
|
+
if (gradient) applyGradient(node, gradient);
|
|
1593
|
+
applyOpacity(node, sh);
|
|
1594
|
+
} else if (sh.type === 'line') {
|
|
1595
|
+
const stroke = normalizeColor(sh.stroke);
|
|
1596
|
+
if (!stroke) continue;
|
|
1597
|
+
const lineOpts = {
|
|
1598
|
+
name: 'Line',
|
|
1599
|
+
stroke,
|
|
1600
|
+
strokeWeight: (sh.strokeWidth ?? 1) * S,
|
|
1601
|
+
};
|
|
1602
|
+
const cap = sh.strokeLinecap?.toUpperCase();
|
|
1603
|
+
if (cap === 'ROUND' || cap === 'SQUARE') lineOpts.strokeCap = cap;
|
|
1604
|
+
if (sh.strokeDasharray) lineOpts.dashPattern = sh.strokeDasharray.split(/[,\s]+/).map(Number).filter(n => Number.isFinite(n));
|
|
1605
|
+
const node = slide.addPath(`M ${X(sh.x1)} ${Y(sh.y1)} L ${X(sh.x2)} ${Y(sh.y2)}`, lineOpts);
|
|
1606
|
+
applyOpacity(node, sh);
|
|
1607
|
+
} else if (sh.type === 'rect') {
|
|
1608
|
+
const { fill, gradient } = resolveFill(sh.fill, sh);
|
|
1609
|
+
const stroke = normalizeColor(sh.stroke);
|
|
1610
|
+
const opts = {};
|
|
1611
|
+
if (fill) opts.fill = fill;
|
|
1612
|
+
if (stroke) { opts.stroke = stroke; opts.strokeWeight = (sh.strokeWidth ?? 1) * S; }
|
|
1613
|
+
const radius = Math.max(sh.rx ?? 0, sh.ry ?? 0);
|
|
1614
|
+
if (radius > 0) opts.cornerRadius = radius * S;
|
|
1615
|
+
const node = slide.addRectangle(X(sh.x ?? 0), Y(sh.y ?? 0), (sh.width ?? 0) * sx, (sh.height ?? 0) * sy, opts);
|
|
1616
|
+
if (gradient) applyGradient(node, gradient);
|
|
1617
|
+
applyOpacity(node, sh);
|
|
1618
|
+
} else if (sh.type === 'path') {
|
|
1619
|
+
if (!sh.d) continue;
|
|
1620
|
+
const stroke = normalizeColor(sh.stroke);
|
|
1621
|
+
const { fill, gradient } = resolveFill(sh.fill, sh);
|
|
1622
|
+
const d = transformPathD(sh.d, X, Y);
|
|
1623
|
+
const opts = { name: 'Curve' };
|
|
1624
|
+
if (stroke) { opts.stroke = stroke; opts.strokeWeight = (sh.strokeWidth ?? 1) * S; }
|
|
1625
|
+
else opts.strokeWeight = 0;
|
|
1626
|
+
if (sh.strokeLinecap) opts.strokeCap = sh.strokeLinecap.toUpperCase();
|
|
1627
|
+
if (sh.strokeLinejoin) opts.strokeJoin = sh.strokeLinejoin.toUpperCase();
|
|
1628
|
+
if (sh.strokeDasharray) opts.dashPattern = sh.strokeDasharray.split(/[,\s]+/).map(Number).filter(n => Number.isFinite(n));
|
|
1629
|
+
if (fill) opts.fill = fill;
|
|
1630
|
+
else if (gradient) opts.fill = '#000000'; // placeholder to request fill geometry
|
|
1631
|
+
const node = slide.addPath(d, opts);
|
|
1632
|
+
if (gradient) applyGradient(node, gradient);
|
|
1633
|
+
applyOpacity(node, sh);
|
|
1634
|
+
} else if (sh.type === 'text') {
|
|
1635
|
+
if (!sh.text) continue;
|
|
1636
|
+
const fontSize = (sh.fontSize ?? 16) * S;
|
|
1637
|
+
const align = sh.textAnchor === 'middle' ? 'CENTER' : sh.textAnchor === 'end' ? 'RIGHT' : 'LEFT';
|
|
1638
|
+
const width = boxW;
|
|
1639
|
+
let x = X(sh.x);
|
|
1640
|
+
if (align === 'CENTER') x = X(sh.x) - width / 2;
|
|
1641
|
+
else if (align === 'RIGHT') x = X(sh.x) - width;
|
|
1642
|
+
const opts = {
|
|
1643
|
+
x,
|
|
1644
|
+
y: Y(sh.y) - fontSize,
|
|
1645
|
+
width,
|
|
1646
|
+
fontSize,
|
|
1647
|
+
align,
|
|
1648
|
+
font: mapFont(sh.fontFamily),
|
|
1649
|
+
fontStyle: mapFontStyle(Number(sh.fontWeight) || 400, sh.fontStyle),
|
|
1650
|
+
};
|
|
1651
|
+
const color = normalizeColor(sh.fill);
|
|
1652
|
+
if (color) opts.color = color;
|
|
1653
|
+
slide.addText(sh.text, opts);
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
const HANDLERS = {
|
|
1659
|
+
text: handleText,
|
|
1660
|
+
richText: handleRichText,
|
|
1661
|
+
textWithPillRow: handleTextWithPillRow,
|
|
1662
|
+
pillRow: handlePillRow,
|
|
1663
|
+
statWithRing: handleStatWithRing,
|
|
1664
|
+
image: handleImage,
|
|
1665
|
+
rect: handleRect,
|
|
1666
|
+
ellipse: handleEllipse,
|
|
1667
|
+
bulletList: handleBulletList,
|
|
1668
|
+
blockquote: handleBlockquote,
|
|
1669
|
+
card: handleCard,
|
|
1670
|
+
factRow: handleFactRow,
|
|
1671
|
+
imageRow: handleImageRow,
|
|
1672
|
+
table: handleTable,
|
|
1673
|
+
timeline: handleTimeline,
|
|
1674
|
+
chart: handleChart,
|
|
1675
|
+
svg: handleSvg,
|
|
1676
|
+
layoutContainer: handleLayoutContainer,
|
|
1677
|
+
};
|
|
1678
|
+
|
|
1679
|
+
export async function applyElement(slide, el, ctx) {
|
|
1680
|
+
const handler = HANDLERS[el.type];
|
|
1681
|
+
if (!handler) {
|
|
1682
|
+
throw new Error(`handoff converter: unsupported element type "${el.type}" (slide ${ctx.slideIndex}, id=${el.id ?? '?'})`);
|
|
1683
|
+
}
|
|
1684
|
+
await handler(slide, el, ctx);
|
|
1685
|
+
}
|