openfig-cli 0.4.2 → 0.4.3
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.
|
@@ -191,6 +191,7 @@ export async function extractSlides(page, opts = {}) {
|
|
|
191
191
|
viewBox: vb ? vb[2] : `0 0 ${rect.width} ${rect.height}`,
|
|
192
192
|
inline: svgInline,
|
|
193
193
|
...(parseFloat(cs.opacity) < 1 ? { opacity: parseFloat(cs.opacity) } : {}),
|
|
194
|
+
...(cs.filter && cs.filter !== 'none' ? { filter: cs.filter } : {}),
|
|
194
195
|
});
|
|
195
196
|
} else {
|
|
196
197
|
pushElement({
|
|
@@ -199,6 +200,7 @@ export async function extractSlides(page, opts = {}) {
|
|
|
199
200
|
x: rect.x, y: rect.y, width: rect.width, height: rect.height,
|
|
200
201
|
objectFit: cs.objectFit || 'contain',
|
|
201
202
|
opacity: cs.opacity,
|
|
203
|
+
...(cs.filter && cs.filter !== 'none' ? { filter: cs.filter } : {}),
|
|
202
204
|
});
|
|
203
205
|
}
|
|
204
206
|
} else {
|
|
@@ -221,6 +223,7 @@ export async function extractSlides(page, opts = {}) {
|
|
|
221
223
|
viewBox: el.getAttribute('viewBox') || `0 0 ${rect.width} ${rect.height}`,
|
|
222
224
|
inline: el.outerHTML,
|
|
223
225
|
...(eff < 1 ? { opacity: eff } : {}),
|
|
226
|
+
...(cs.filter && cs.filter !== 'none' ? { filter: cs.filter } : {}),
|
|
224
227
|
});
|
|
225
228
|
} else {
|
|
226
229
|
if (tag !== 'SECTION') maybeEmitShape(el, cs, rect);
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import sharp from 'sharp';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { basename, dirname, extname, join } from 'node:path';
|
|
4
|
+
|
|
1
5
|
const SERIF = 'Georgia';
|
|
2
6
|
const SANS = 'Inter';
|
|
3
7
|
const BORDER = '#E8EAEE';
|
|
@@ -392,8 +396,63 @@ function applyFilter(node, el) {
|
|
|
392
396
|
}
|
|
393
397
|
}
|
|
394
398
|
|
|
399
|
+
// Bake a parsed CSS image filter into the raster bytes via sharp, returning
|
|
400
|
+
// the path to a (cached) variant file. Returns the original path unchanged
|
|
401
|
+
// if the filter is empty or one we don't know how to apply here.
|
|
402
|
+
//
|
|
403
|
+
// Output is always PNG so alpha is preserved for both invert and forceWhite
|
|
404
|
+
// (the "brightness(0) invert(1)" white-mask trick).
|
|
405
|
+
async function bakeImageFilter(srcPath, filter, displayWidth) {
|
|
406
|
+
if (!filter || (!filter.invert && !filter.forceWhite)) return srcPath;
|
|
407
|
+
|
|
408
|
+
const ext = extname(srcPath).toLowerCase();
|
|
409
|
+
const stem = basename(srcPath, ext);
|
|
410
|
+
const key = filter.forceWhite ? 'forceWhite' : 'invert';
|
|
411
|
+
const outPath = join(dirname(srcPath), `${stem}.${key}.png`);
|
|
412
|
+
if (existsSync(outPath)) return outPath;
|
|
413
|
+
|
|
414
|
+
// SVGs need an explicit density to rasterize crisply. Pick ~4× the target
|
|
415
|
+
// display width so the resulting PNG holds up under typical Figma zoom.
|
|
416
|
+
const isSvg = ext === '.svg';
|
|
417
|
+
const density = isSvg ? Math.max(192, Math.round((displayWidth || 256) * 4 * 72 / 256)) : 72;
|
|
418
|
+
const pipeline = isSvg ? sharp(srcPath, { density }) : sharp(srcPath);
|
|
419
|
+
|
|
420
|
+
if (filter.forceWhite) {
|
|
421
|
+
// brightness(0) invert(1) → every pixel becomes opaque-white where the
|
|
422
|
+
// source had any color, with the source's alpha channel preserved.
|
|
423
|
+
// Implemented as: composite a solid white plate using the source as a
|
|
424
|
+
// `dest-in` mask, which keeps the white only where the source has alpha.
|
|
425
|
+
const meta = await pipeline.metadata();
|
|
426
|
+
const white = sharp({
|
|
427
|
+
create: {
|
|
428
|
+
width: meta.width,
|
|
429
|
+
height: meta.height,
|
|
430
|
+
channels: 4,
|
|
431
|
+
background: { r: 255, g: 255, b: 255, alpha: 1 },
|
|
432
|
+
},
|
|
433
|
+
});
|
|
434
|
+
// Re-read the source through a fresh pipeline as a composite input —
|
|
435
|
+
// sharp doesn't let us reuse the existing `pipeline` reference.
|
|
436
|
+
const sourceForMask = isSvg
|
|
437
|
+
? await sharp(srcPath, { density }).png().toBuffer()
|
|
438
|
+
: await sharp(srcPath).png().toBuffer();
|
|
439
|
+
await white
|
|
440
|
+
.composite([{ input: sourceForMask, blend: 'dest-in' }])
|
|
441
|
+
.png()
|
|
442
|
+
.toFile(outPath);
|
|
443
|
+
return outPath;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Plain invert(1): flip RGB, preserve alpha.
|
|
447
|
+
await pipeline.negate({ alpha: false }).png().toFile(outPath);
|
|
448
|
+
return outPath;
|
|
449
|
+
}
|
|
450
|
+
|
|
395
451
|
async function handleImage(slide, el, ctx) {
|
|
396
|
-
|
|
452
|
+
let path = ctx.resolveMedia(el.src);
|
|
453
|
+
if (el.filter && (el.filter.invert || el.filter.forceWhite)) {
|
|
454
|
+
path = await bakeImageFilter(path, el.filter, el.width);
|
|
455
|
+
}
|
|
397
456
|
const opts = { x: el.x, y: el.y, width: el.width, height: el.height };
|
|
398
457
|
opts.scaleMode = el.objectFit === 'contain' ? 'FIT' : 'FILL';
|
|
399
458
|
const node = await slide.addImage(path, opts);
|
|
@@ -48,7 +48,17 @@ function rewriteTemplateForBrowser(template, mediaMap) {
|
|
|
48
48
|
const src = img.getAttribute('src');
|
|
49
49
|
if (!src) continue;
|
|
50
50
|
const asset = mediaMap[src];
|
|
51
|
-
if (asset)
|
|
51
|
+
if (!asset) continue;
|
|
52
|
+
if (asset.mime === 'image/svg+xml') {
|
|
53
|
+
// Inline SVG assets as data URLs so browser-extract's decodeSvgDataUrl
|
|
54
|
+
// path picks them up and emits a native Figma VECTOR node. Without
|
|
55
|
+
// this, the <img> is treated as a raster reference and the logo
|
|
56
|
+
// bakes to pixels at display size, losing crispness on Figma zoom.
|
|
57
|
+
const svgBytes = readFileSync(asset.path);
|
|
58
|
+
img.setAttribute('src', `data:image/svg+xml;base64,${svgBytes.toString('base64')}`);
|
|
59
|
+
} else {
|
|
60
|
+
img.setAttribute('src', `media/${asset.filename}`);
|
|
61
|
+
}
|
|
52
62
|
}
|
|
53
63
|
return doc.toString();
|
|
54
64
|
}
|
|
@@ -131,6 +141,119 @@ function normalizeFont(family) {
|
|
|
131
141
|
return portable ?? entries[0];
|
|
132
142
|
}
|
|
133
143
|
|
|
144
|
+
// Invert an RGB color value. Returns the input unchanged if the format isn't
|
|
145
|
+
// recognized (so unusual literals fall through rather than corrupting the
|
|
146
|
+
// SVG). Handles #RGB, #RRGGBB, rgb()/rgba(), and the named colors "black" and
|
|
147
|
+
// "white" — enough to cover Claude Design's exports.
|
|
148
|
+
function invertCssColor(color) {
|
|
149
|
+
const s = String(color).trim();
|
|
150
|
+
if (s === 'none' || s === 'transparent' || s === 'currentColor') return s;
|
|
151
|
+
if (s.startsWith('#')) {
|
|
152
|
+
const hex = s.length === 4
|
|
153
|
+
? '#' + [...s.slice(1)].map((c) => c + c).join('')
|
|
154
|
+
: s;
|
|
155
|
+
if (!/^#[0-9a-f]{6}$/i.test(hex)) return color;
|
|
156
|
+
const r = 255 - parseInt(hex.slice(1, 3), 16);
|
|
157
|
+
const g = 255 - parseInt(hex.slice(3, 5), 16);
|
|
158
|
+
const b = 255 - parseInt(hex.slice(5, 7), 16);
|
|
159
|
+
return '#' + [r, g, b].map((n) => n.toString(16).padStart(2, '0')).join('');
|
|
160
|
+
}
|
|
161
|
+
const m = s.match(/^rgba?\(([^)]+)\)$/i);
|
|
162
|
+
if (m) {
|
|
163
|
+
const parts = m[1].split(',').map((t) => t.trim());
|
|
164
|
+
const rgb = parts.slice(0, 3).map((t) => parseInt(t, 10));
|
|
165
|
+
if (rgb.some((n) => Number.isNaN(n))) return color;
|
|
166
|
+
const inv = rgb.map((n) => 255 - n);
|
|
167
|
+
return parts.length === 4
|
|
168
|
+
? `rgba(${inv.join(', ')}, ${parts[3]})`
|
|
169
|
+
: `rgb(${inv.join(', ')})`;
|
|
170
|
+
}
|
|
171
|
+
const lower = s.toLowerCase();
|
|
172
|
+
if (lower === 'black') return '#ffffff';
|
|
173
|
+
if (lower === 'white') return '#000000';
|
|
174
|
+
return color;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Rewrite every fill/stroke color in an SVG string. `mode` is either
|
|
178
|
+
// 'invert' (RGB complement) or 'forceWhite' (every visible color → #fff,
|
|
179
|
+
// used for the brightness(0) invert(1) "white mask" trick).
|
|
180
|
+
//
|
|
181
|
+
// Touches three forms of color declaration:
|
|
182
|
+
// 1. <… fill="X" stroke="Y" …>
|
|
183
|
+
// 2. <… style="fill: X; stroke: Y">
|
|
184
|
+
// 3. CSS rules inside <style> blocks
|
|
185
|
+
function transformSvgColors(svg, mode) {
|
|
186
|
+
const transform = mode === 'forceWhite'
|
|
187
|
+
? (val) => {
|
|
188
|
+
const v = String(val).trim();
|
|
189
|
+
if (v === 'none' || v === 'transparent' || v === 'currentColor') return v;
|
|
190
|
+
return '#ffffff';
|
|
191
|
+
}
|
|
192
|
+
: (val) => invertCssColor(val);
|
|
193
|
+
|
|
194
|
+
// Attribute form: fill="..." / stroke="..." (single or double quotes)
|
|
195
|
+
let out = svg.replace(
|
|
196
|
+
/\b(fill|stroke)\s*=\s*(['"])([^'"]+)\2/gi,
|
|
197
|
+
(_, attr, quote, val) => `${attr}=${quote}${transform(val)}${quote}`,
|
|
198
|
+
);
|
|
199
|
+
// Inline style attribute: style="fill: ...; stroke: ..."
|
|
200
|
+
out = out.replace(/\bstyle\s*=\s*(['"])([^'"]*)\1/gi, (_, quote, css) => {
|
|
201
|
+
const newCss = css.replace(
|
|
202
|
+
/\b(fill|stroke)\s*:\s*([^;]+)/gi,
|
|
203
|
+
(__, prop, val) => `${prop}: ${transform(val.trim())}`,
|
|
204
|
+
);
|
|
205
|
+
return `style=${quote}${newCss}${quote}`;
|
|
206
|
+
});
|
|
207
|
+
// CSS rules inside <style> blocks
|
|
208
|
+
out = out.replace(/<style\b([^>]*)>([\s\S]*?)<\/style>/gi, (_, attrs, body) => {
|
|
209
|
+
const newBody = body.replace(
|
|
210
|
+
/\b(fill|stroke)\s*:\s*([^;}\s]+)/gi,
|
|
211
|
+
(__, prop, val) => `${prop}: ${transform(val)}`,
|
|
212
|
+
);
|
|
213
|
+
return `<style${attrs}>${newBody}</style>`;
|
|
214
|
+
});
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Parse the subset of CSS `filter` values we can bake into image bytes via
|
|
219
|
+
// sharp at handoff time. Returns { invert?: 1, forceWhite?: true, raw }, or
|
|
220
|
+
// null if the value isn't one we know how to apply.
|
|
221
|
+
//
|
|
222
|
+
// Supported today:
|
|
223
|
+
// `invert(1)` / `invert(100%)` → flip raster colors
|
|
224
|
+
// `brightness(0) invert(1)` (any order) → force every visible pixel white
|
|
225
|
+
// (the "white-from-any-source" trick)
|
|
226
|
+
//
|
|
227
|
+
// Anything else (hue-rotate, grayscale, sepia, partial invert, etc.) returns
|
|
228
|
+
// null so the caller can emit a warning. Adding new cases is mechanical —
|
|
229
|
+
// see DECK-FEATURES-ROADMAP for the filter-coverage plan.
|
|
230
|
+
function parseImageFilter(raw) {
|
|
231
|
+
if (!raw || raw === 'none') return null;
|
|
232
|
+
const tokens = String(raw).match(/[a-z-]+\([^)]*\)/gi) || [];
|
|
233
|
+
if (tokens.length === 0) return null;
|
|
234
|
+
let invert = false;
|
|
235
|
+
let brightnessZero = false;
|
|
236
|
+
for (const tok of tokens) {
|
|
237
|
+
const m = tok.match(/^([a-z-]+)\(\s*([^)]*)\s*\)$/i);
|
|
238
|
+
if (!m) return null;
|
|
239
|
+
const fn = m[1].toLowerCase();
|
|
240
|
+
const arg = m[2].trim();
|
|
241
|
+
if (fn === 'invert') {
|
|
242
|
+
// accept invert(1) or invert(100%)
|
|
243
|
+
if (arg === '1' || arg === '100%') invert = true;
|
|
244
|
+
else return null;
|
|
245
|
+
} else if (fn === 'brightness') {
|
|
246
|
+
if (arg === '0' || arg === '0%') brightnessZero = true;
|
|
247
|
+
else return null;
|
|
248
|
+
} else {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (brightnessZero && invert) return { forceWhite: true, raw };
|
|
253
|
+
if (invert) return { invert: 1, raw };
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
|
|
134
257
|
function normalizeElement(el) {
|
|
135
258
|
if (!el) return null;
|
|
136
259
|
const out = { ...el };
|
|
@@ -181,6 +304,30 @@ function normalizeElement(el) {
|
|
|
181
304
|
else delete out.opacity;
|
|
182
305
|
}
|
|
183
306
|
}
|
|
307
|
+
if (el.type === 'image' && el.filter) {
|
|
308
|
+
const parsed = parseImageFilter(el.filter);
|
|
309
|
+
if (parsed) {
|
|
310
|
+
out.filter = parsed;
|
|
311
|
+
} else {
|
|
312
|
+
// Keep raw so the warn pass downstream can report which CSS we skipped.
|
|
313
|
+
out.unhandledFilter = el.filter;
|
|
314
|
+
delete out.filter;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (el.type === 'svg' && el.filter) {
|
|
318
|
+
const parsed = parseImageFilter(el.filter);
|
|
319
|
+
if (parsed && typeof out.inline === 'string') {
|
|
320
|
+
// Apply the filter by rewriting fill/stroke colors directly in the SVG
|
|
321
|
+
// markup so the downstream vector pipeline emits already-corrected
|
|
322
|
+
// colors — no raster trip, no Figma effect needed.
|
|
323
|
+
const mode = parsed.forceWhite ? 'forceWhite' : 'invert';
|
|
324
|
+
out.inline = transformSvgColors(out.inline, mode);
|
|
325
|
+
delete out.filter;
|
|
326
|
+
} else if (!parsed) {
|
|
327
|
+
out.unhandledFilter = el.filter;
|
|
328
|
+
delete out.filter;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
184
331
|
if (el.type === 'layoutContainer') {
|
|
185
332
|
out.children = normalizeElements(el.children ?? []);
|
|
186
333
|
}
|
|
@@ -366,6 +513,24 @@ export async function convertStandaloneHtml(htmlPath, outDeckPath, opts = {}) {
|
|
|
366
513
|
);
|
|
367
514
|
}
|
|
368
515
|
|
|
516
|
+
// Surface any CSS filters on <img> / inline-<svg> elements that we
|
|
517
|
+
// recognised the shape of but don't apply yet (anything outside
|
|
518
|
+
// parseImageFilter's allowlist). Strips the diagnostic field after
|
|
519
|
+
// reporting so it never reaches the handoff stage.
|
|
520
|
+
for (const slide of manifestOut.slides) {
|
|
521
|
+
for (const el of slide.elements ?? []) {
|
|
522
|
+
if ((el.type === 'image' || el.type === 'svg') && el.unhandledFilter) {
|
|
523
|
+
const what = el.type === 'svg' ? '<svg>' : '<img>';
|
|
524
|
+
collector.warn(
|
|
525
|
+
slide.index - 1,
|
|
526
|
+
`${what} filter not applied: ${el.unhandledFilter} (only invert(1) and brightness(0) invert(1) are supported today)`,
|
|
527
|
+
el.src || (el.inline ? el.inline.slice(0, 40) : undefined),
|
|
528
|
+
);
|
|
529
|
+
delete el.unhandledFilter;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
369
534
|
const warnings = collector.report();
|
|
370
535
|
if (warnings.length && !opts.silent) {
|
|
371
536
|
const logger = opts.warnLogger || ((s) => process.stderr.write(s + '\n'));
|