feat(design-media): /clone-style — extracção de design tokens (web + pptx)

Resolve dor real: clonar estilos visuais de sites e slides para propostas.
Tentativas anteriores (Penpot, scrapers HTML, Figma import) falharam porque
tentavam reproduzir layouts. Esta skill extrai tokens (cores, fontes,
espaçamento, raios, sombras) e alimenta gerador (Stitch / design-engine).

Modos:
- /clone-style web <url>: extract-web-tokens.js via chrome real (CSS computado)
- /clone-style slides <pptx>: extract-pptx-theme.py (theme1.xml + slideMasters)
- /clone-style apply <tokens.json>: mapeia para Stitch / design-engine / pptx

Validado: PPTX (Calibri/Calibri Light + 6 accent colors do Office default theme).
Web: aguarda primeiro teste end-to-end com browser real.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 05:13:16 +01:00
parent 37f62eb733
commit a33c5e1b05
4 changed files with 533 additions and 0 deletions
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""
extract-pptx-theme.py — extrai design tokens de um ficheiro PPTX
Uso:
python3 extract-pptx-theme.py <caminho.pptx> [--output tokens.json]
Devolve JSON no mesmo formato do extract-web-tokens.js para que /clone-style apply
seja unificado entre os 2 modos.
"""
import sys
import json
import zipfile
import xml.etree.ElementTree as ET
from pathlib import Path
from datetime import datetime, timezone
NS = {'a': 'http://schemas.openxmlformats.org/drawingml/2006/main'}
def extract_theme(pptx_path: Path) -> dict:
if not pptx_path.exists():
raise FileNotFoundError(f"Não encontrado: {pptx_path}")
with zipfile.ZipFile(pptx_path) as z:
# Listar themes (pode haver múltiplos; usar o primeiro)
theme_files = sorted([n for n in z.namelist() if n.startswith('ppt/theme/theme') and n.endswith('.xml')])
if not theme_files:
raise ValueError("PPTX sem theme — formato inválido ou corrompido")
theme_xml = z.read(theme_files[0])
root = ET.fromstring(theme_xml)
# Extrair cores do clrScheme
colors = {}
for clr_scheme in root.iter('{http://schemas.openxmlformats.org/drawingml/2006/main}clrScheme'):
for color_node in clr_scheme:
name = color_node.tag.split('}')[1] # ex: 'accent1', 'dk1', 'lt1'
srgb = color_node.find('a:srgbClr', NS)
sysclr = color_node.find('a:sysClr', NS)
if srgb is not None:
colors[name] = '#' + srgb.get('val')
elif sysclr is not None:
# sysClr usa o lastClr como fallback (cor resolvida)
colors[name] = '#' + sysclr.get('lastClr', '000000')
# Extrair fontes do fontScheme
fonts = {'headline': None, 'body': None}
for fs in root.iter('{http://schemas.openxmlformats.org/drawingml/2006/main}fontScheme'):
major = fs.find('a:majorFont/a:latin', NS)
minor = fs.find('a:minorFont/a:latin', NS)
if major is not None:
fonts['headline'] = major.get('typeface')
if minor is not None:
fonts['body'] = minor.get('typeface')
# Mapear cores PPTX → estrutura unificada
accent_keys = ['accent1', 'accent2', 'accent3', 'accent4', 'accent5', 'accent6']
accents = [colors[k] for k in accent_keys if k in colors]
primary = colors.get('accent1') or accents[0] if accents else '#000000'
secondary = colors.get('accent2')
tertiary = colors.get('accent3')
neutral = colors.get('lt1') or colors.get('dk1') or '#ffffff'
background = colors.get('lt1') or colors.get('bg1') or '#ffffff'
is_dark = colors.get('lt1', '#ffffff').lstrip('#').upper() != 'FFFFFF'
return {
'url': str(pptx_path),
'title': pptx_path.stem,
'source': 'pptx',
'timestamp': datetime.now(timezone.utc).isoformat(),
'colors': {
'top': [{'value': v, 'count': 1} for v in accents],
'backgrounds': [{'value': background, 'count': 1}],
'dominant': primary,
'raw_scheme': colors,
},
'fonts': {
'families': [
{'value': fonts['headline'], 'count': 1, 'role': 'headline'},
{'value': fonts['body'], 'count': 1, 'role': 'body'},
],
'sizes': [],
'weights': [],
'lineHeights': [],
},
'borderRadius': [],
'shadows': [],
'spacing': {'gridBaseGuess': 8, 'top': []},
'components': {},
'meta': {
'colorMode': 'DARK' if is_dark else 'LIGHT',
'theme_file': theme_files[0],
'accent_count': len(accents),
},
'mapping_hint': {
'stitch_customColor': primary,
'stitch_overrideSecondary': secondary,
'stitch_overrideTertiary': tertiary,
'stitch_overrideNeutral': neutral,
'note': 'PPTX só fornece cores e fontes — borderRadius/shadows/spacing têm de ser definidos manualmente em designMd',
},
}
def main():
if len(sys.argv) < 2:
print(__doc__, file=sys.stderr)
sys.exit(1)
pptx = Path(sys.argv[1]).expanduser().resolve()
output_path = None
if '--output' in sys.argv:
idx = sys.argv.index('--output')
if idx + 1 < len(sys.argv):
output_path = Path(sys.argv[idx + 1]).expanduser().resolve()
tokens = extract_theme(pptx)
js = json.dumps(tokens, indent=2, ensure_ascii=False)
if output_path:
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(js, encoding='utf-8')
print(f"✅ Tokens guardados em {output_path}", file=sys.stderr)
else:
print(js)
if __name__ == '__main__':
main()
@@ -0,0 +1,122 @@
// extract-web-tokens.js
// Injectar via mcp__claude-in-chrome__javascript_tool ou mcp__chrome-devtools__evaluate_script
// Devolve JSON com cores, fontes, espaçamentos, raios, sombras e amostras de componentes.
// Funciona em qualquer site (incluindo JS-rendered) porque usa getComputedStyle().
(() => {
const all = document.querySelectorAll('*');
const colorCounts = {};
const fontFamilies = {};
const fontSizes = {};
const fontWeights = {};
const lineHeights = {};
const radii = {};
const shadows = {};
const spacings = {};
const bgColors = {};
const norm = (v) => (v || '').trim();
const inc = (obj, k) => { if (!k || k === 'none' || k === '0px') return; obj[k] = (obj[k] || 0) + 1; };
// Helper: rgb(a) → hex (ignora transparente)
const toHex = (rgb) => {
if (!rgb || rgb === 'transparent' || rgb === 'rgba(0, 0, 0, 0)') return null;
const m = rgb.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
if (!m) return null;
const a = m[4] !== undefined ? parseFloat(m[4]) : 1;
if (a < 0.1) return null;
const h = (n) => parseInt(n).toString(16).padStart(2, '0');
return '#' + h(m[1]) + h(m[2]) + h(m[3]);
};
for (const el of all) {
if (!(el instanceof Element)) continue;
const cs = getComputedStyle(el);
// Cores
inc(colorCounts, toHex(cs.color));
const bg = toHex(cs.backgroundColor);
if (bg) {
inc(bgColors, bg);
inc(colorCounts, bg);
}
inc(colorCounts, toHex(cs.borderColor));
// Fontes
inc(fontFamilies, norm(cs.fontFamily));
inc(fontSizes, norm(cs.fontSize));
inc(fontWeights, norm(cs.fontWeight));
inc(lineHeights, norm(cs.lineHeight));
// Raios
inc(radii, norm(cs.borderRadius));
// Sombras
if (cs.boxShadow && cs.boxShadow !== 'none') inc(shadows, cs.boxShadow);
// Espaçamentos (paddings + margins + gap)
['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'marginTop', 'marginRight', 'marginBottom', 'marginLeft', 'gap', 'rowGap', 'columnGap'].forEach((p) => {
inc(spacings, norm(cs[p]));
});
}
// Top N por frequência
const topN = (obj, n) => Object.entries(obj).sort((a, b) => b[1] - a[1]).slice(0, n).map(([k, v]) => ({ value: k, count: v }));
// Detectar grid base (espaçamento mais comum)
const numericSpacings = Object.entries(spacings)
.map(([k, v]) => [parseFloat(k), v])
.filter(([n]) => !isNaN(n) && n > 0 && n < 200)
.sort((a, b) => b[1] - a[1]);
const gridBase = numericSpacings[0]?.[0] || 8;
// Capturar samples HTML de componentes-chave (pelo selector)
const sample = (sel) => {
const el = document.querySelector(sel);
if (!el) return null;
return {
html: el.outerHTML.substring(0, 800),
computed: {
bg: toHex(getComputedStyle(el).backgroundColor),
color: toHex(getComputedStyle(el).color),
radius: getComputedStyle(el).borderRadius,
padding: getComputedStyle(el).padding,
font: getComputedStyle(el).fontFamily,
}
};
};
return {
url: location.href,
title: document.title,
timestamp: new Date().toISOString(),
colors: {
top: topN(colorCounts, 12),
backgrounds: topN(bgColors, 6),
dominant: topN(colorCounts, 1)[0]?.value || null,
},
fonts: {
families: topN(fontFamilies, 5),
sizes: topN(fontSizes, 8),
weights: topN(fontWeights, 6),
lineHeights: topN(lineHeights, 6),
},
borderRadius: topN(radii, 6),
shadows: topN(shadows, 5),
spacing: {
gridBaseGuess: gridBase,
top: topN(spacings, 12),
},
components: {
button: sample('button, .btn, [class*="button" i]'),
card: sample('.card, [class*="card" i]'),
nav: sample('nav, header, [class*="nav" i]'),
hero: sample('main > section:first-child, .hero, [class*="hero" i]'),
},
meta: {
colorMode: getComputedStyle(document.body).backgroundColor.includes('255') ? 'LIGHT' : 'DARK',
lang: document.documentElement.lang || 'unknown',
viewport: document.querySelector('meta[name=viewport]')?.content || null,
}
};
})();