openfig-cli 0.4.1 → 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.
package/README.md CHANGED
@@ -27,19 +27,19 @@ In Claude Design, choose the **Slide deck** tab and create your project.
27
27
  The **Use speaker notes** toggle is optional; if on, OpenFig maps notes
28
28
  to each slide.
29
29
 
30
- ![Create slide deck](docs/images/create_slides_project.png)
30
+ <img src="docs/images/create_slides_project.png" alt="Create slide deck" width="380" />
31
31
 
32
32
  ### 2. Build your deck, then export
33
33
 
34
34
  When you're done designing, click **Share → Export as standalone HTML**.
35
35
 
36
- ![Export as standalone HTML](docs/images/export_standalone_html.png)
36
+ <img src="docs/images/export_standalone_html.png" alt="Export as standalone HTML" width="720" />
37
37
 
38
38
  Claude Design generates a single self-contained file named
39
39
  `{Project Name} (Standalone).html` — all slides, images, fonts, and the
40
40
  rendering engine inlined. Works offline.
41
41
 
42
- ![Download standalone HTML](docs/images/download_standalone_html.png)
42
+ <img src="docs/images/download_standalone_html.png" alt="Download standalone HTML" width="480" />
43
43
 
44
44
  ### 3. Convert to .deck
45
45
 
@@ -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
- const path = ctx.resolveMedia(el.src);
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) img.setAttribute('src', `media/${asset.filename}`);
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'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openfig-cli",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "OpenFig — Open-source tools for Figma file parsing and rendering",
5
5
  "type": "module",
6
6
  "bin": {