a33c5e1b05
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>
134 lines
4.7 KiB
Python
134 lines
4.7 KiB
Python
#!/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()
|