#!/usr/bin/env python3
"""Build neutral editorial vector assets and deterministic transparent PNG exports."""
from pathlib import Path
from fontTools.ttLib import TTFont
from fontTools.pens.svgPathPen import SVGPathPen
from PIL import Image, ImageDraw, ImageFont
import hashlib, html, json, shutil, subprocess
from datetime import datetime, timezone
ROOT=Path(__file__).resolve().parent
for name in ['svg','png','editable','templates','fonts','qa']:(ROOT/name).mkdir(exist_ok=True)
NAVY='#17253D'; ORANGE='#C64E00'; WHITE='#FFFFFF'; INK='#111827'; MUTED='#475569'; WARM='#FFF2E8'; NEUTRAL='#F8FAFC'; BORDER='#64748B'
FONTROOT=Path('/usr/share/fonts/truetype/dejavu')
fontpaths={'bold':FONTROOT/'DejaVuSans-Bold.ttf','regular':FONTROOT/'DejaVuSans.ttf'}
fonts={k:TTFont(p) for k,p in fontpaths.items()}
for p in fontpaths.values():shutil.copy2(p,ROOT/'fonts'/p.name)
lic=Path('/usr/share/doc/fonts-dejavu-core/copyright').read_text().split('Files: debian/*')[0]
(ROOT/'FONT-LICENSE.txt').write_text(lic)
cache={}
def width(s,size=16,weight='regular',tracking=0):
f=fonts[weight];cm=f.getBestCmap();h=f['hmtx'].metrics
return sum(h[cm[ord(c)]][0] for c in s)*size/f['head'].unitsPerEm+max(0,len(s)-1)*tracking
def text(s,x,y,size,color=INK,weight='regular',tracking=0,outlined=True,anchor='start'):
if anchor=='middle':x-=width(s,size,weight,tracking)/2
if anchor=='end':x-=width(s,size,weight,tracking)
if not outlined:
return f'{html.escape(s)}'
f=fonts[weight];gs=f.getGlyphSet();cm=f.getBestCmap();scale=size/f['head'].unitsPerEm;pos=x;paths=[]
for c in s:
glyph=cm[ord(c)];key=(weight,glyph)
if key not in cache:
pen=SVGPathPen(gs);gs[glyph].draw(pen);cache[key]=pen.getCommands()
if cache[key]:paths.append(f'')
pos+=f['hmtx'].metrics[glyph][0]*scale+tracking
return f''+''.join(paths)+''
def symbol(primary=NAVY,accent=ORANGE,favicon=False):
if favicon:
return f''
return f''
def logo_body(kind='horizontal',outlined=True):
primary,accent,desc=NAVY,ORANGE,MUTED
if kind=='monochrome':primary=accent=desc=NAVY
if kind=='inverse':primary=accent=desc=WHITE
if kind in ['horizontal','monochrome','inverse']:
w,h=420,160
body=f''+symbol(primary,accent)+''
body+=text('ROLANDO',112,59,46,primary,'bold',.15,outlined)
body+=text('BARBOZA',112,107,46,primary,'bold',.15,outlined)
body+=text('DATOS Y PROPUESTAS',114,136,16,desc,'regular',.7,outlined)
elif kind=='stacked':
w,h=336,276
body=f''+symbol()+''
body+=text('ROLANDO',168,143,48,NAVY,'bold',.15,outlined,'middle')
body+=text('BARBOZA',168,195,48,NAVY,'bold',.15,outlined,'middle')
body+=text('DATOS Y PROPUESTAS',168,235,18,MUTED,'regular',.7,outlined,'middle')
elif kind=='compact':
w,h=292,100
body=f''+symbol()+''
body+=text('ROLANDO',86,45,32,NAVY,'bold',.1,outlined)
body+=text('BARBOZA',86,80,32,NAVY,'bold',.1,outlined)
elif kind=='icon':w,h,body=64,64,symbol()
elif kind=='favicon':w,h,body=16,16,symbol(favicon=True)
return w,h,body
def svg(w,h,body,title,desc=''):
return f'\n\n'
def export(source,target,w,h):
subprocess.run(['inkscape',str(source),'--export-type=png','--export-filename='+str(target),'--export-width='+str(w),'--export-height='+str(h),'--export-background-opacity=0'],check=True,stdout=subprocess.DEVNULL,stderr=subprocess.PIPE)
im=Image.open(target)
assert im.size==(w,h) and im.mode=='RGBA',(target,im.size,im.mode)
return {'path':str(target.relative_to(ROOT)),'width':w,'height':h,'transparentBackground':im.getchannel('A').getextrema()[0]==0,'sha256':hashlib.sha256(target.read_bytes()).hexdigest()}
assets=[]
for name in ['horizontal','stacked','monochrome','inverse','compact','icon','favicon']:
w,h,body=logo_body(name)
filename='logo-'+name if name not in ['icon','favicon'] else name
path=ROOT/'svg'/(filename+'.svg')
path.write_text(svg(w,h,body,'Rolando Barboza | Datos y propuestas · '+name,'Identidad editorial propuesta. No es un símbolo de Fuerza Popular ni una identidad institucional. Trazados vectoriales editables; original tipográfico en editable/.'))
_,_,editable_body=logo_body(name,False)
edit=ROOT/'editable'/(filename+'-text.svg')
edit.write_text(svg(w,h,editable_body,'Marca tipográfica editable · '+name,'Requiere DejaVu Sans, incluida en fonts/ con su licencia.'))
item={'id':name,'kind':'editorial_brand','svg':str(path.relative_to(ROOT)),'textEditableSvg':str(edit.relative_to(ROOT)),'width':w,'height':h,'outlinedExport':True,'background':'transparent','png':[]}
for scale in [1,2,4]:item['png'].append(dict(export(path,ROOT/'png'/(filename+'@'+str(scale)+'x.png'),w*scale,h*scale),scale=scale))
if name=='inverse':item['use']='Solo sobre superficie oscura con contraste comprobado; transparencia conservada.'
assets.append(item)
# Pixel-sized icon previews for a real optical check.
for size in [24,32,48]:export(ROOT/'svg/icon.svg',ROOT/'qa'/f'icon-{size}.png',size,size)
export(ROOT/'svg/favicon.svg',ROOT/'qa/favicon-16.png',16,16)
# Templates are editable text. Large placeholders identify them unmistakably.
def rect(x,y,w,h,fill,rx=0,stroke=None):
return f''
def t(s,x,y,size=24,color=INK,weight='regular',anchor='start'):
return text(s,x,y,size,color,weight,0,False,anchor)
def mark(x,y,scale=1):
return f''+logo_body('horizontal',False)[2]+''
def placeholder(x,y,w,h,label,size=24):
return rect(x,y,w,h,NEUTRAL,12,'#CBD5E1')+t(label,x+24,y+44,size,MUTED)
def footer(w,h):
return f''+t('FUENTE / CORTE / REVISIÓN: POR COMPLETAR',64,h-76,20,MUTED)+t('Plantilla editorial. No es una publicación de datos.',64,h-42,18,MUTED)
def badge(x,y):
return rect(x,y,490,42,WARM,8)+t('PLANTILLA · SIN CONTENIDO PUBLICADO',x+16,y+28,19,ORANGE,'bold')
templates=[]
# Open Graph / link image.
w,h=1200,630
body=rect(0,0,w,h,WHITE)+rect(0,0,12,h,ORANGE)+mark(54,18,.9)+badge(64,198)
body+=t('TÍTULO DEL DOCUMENTO',64,322,54,NAVY,'bold')+t('Subtítulo informativo por completar',64,378,29,MUTED)
body+=placeholder(64,412,1072,68,'ÁMBITO / TIPO DE DOCUMENTO / ESTADO',22)
body+=footer(w,h)
templates.append(('template-og',w,h,body,'Imagen de enlace editable, sin cifras, fechas ni afirmaciones publicadas'))
# Square information card.
w=h=1080
body=rect(0,0,w,h,WHITE)+rect(0,0,12,h,ORANGE)+mark(54,24,1)+badge(64,210)
body+=t('TÍTULO INFORMATIVO',64,340,52,NAVY,'bold')+t('Texto de contexto por completar',64,393,28,MUTED)
body+=placeholder(64,454,952,250,'CONTENIDO DOCUMENTADO PENDIENTE',25)
body+=placeholder(64,740,952,130,'ÁMBITO / PERIODO / LIMITACIÓN',23)+footer(w,h)
templates.append(('template-card',w,h,body,'Tarjeta cuadrada vacía; completar y verificar antes de publicar'))
# Vertical format.
w,h=1080,1920
body=rect(0,0,w,h,WHITE)+rect(0,0,12,h,ORANGE)+mark(54,70,1.12)+badge(64,308)
body+=t('TÍTULO DEL',64,468,68,NAVY,'bold')+t('DOCUMENTO',64,548,68,NAVY,'bold')
body+=t('Subtítulo de contexto por completar',64,628,29,MUTED)
body+=placeholder(64,712,952,420,'CONTENIDO VERIFICADO PENDIENTE',26)
body+=placeholder(64,1172,952,230,'EVIDENCIA / DOCUMENTO ORIGINAL',25)
body+=placeholder(64,1442,952,230,'ÁMBITO / PERIODO / LIMITACIÓN',25)+footer(w,h)
templates.append(('template-vertical',w,h,body,'Formato vertical vacío; no contiene actos, promesas ni datos inventados'))
# Report cover, A4-like aspect.
w,h=1240,1754
body=rect(0,0,w,h,WHITE)+rect(0,0,14,h,ORANGE)+mark(72,80,1.2)+badge(88,338)
body+=t('TÍTULO DEL INFORME',88,552,67,NAVY,'bold')+t('Subtítulo documental por completar',88,624,32,MUTED)
body+=placeholder(88,736,1064,350,'RESUMEN / ALCANCE POR COMPLETAR',28)
body+=t('AUTORÍA / ENTIDAD RESPONSABLE',88,1230,26,NAVY,'bold')+t('Por identificar y verificar',88,1276,28,MUTED)
body+=t('VERSIÓN / FECHA / FUENTES',88,1382,26,NAVY,'bold')+t('Campos editoriales pendientes',88,1428,28,MUTED)+footer(w,h)
templates.append(('template-report-cover',w,h,body,'Portada editorial en proporción A4, sin autoría o fechas inventadas'))
# Proposal fact sheet, not an attributed actual proposal.
w,h=1240,1754
body=rect(0,0,w,h,WHITE)+rect(0,0,14,h,ORANGE)+mark(72,30,.9)+badge(88,212)
body+=t('FICHA DE PROPUESTA',88,328,53,NAVY,'bold')+t('Título / autor / documento original: pendientes',88,378,27,MUTED)
labels=['PROBLEMA / LÍNEA BASE / FUENTE','ACCIÓN / ÁMBITO / ENTIDAD COMPETENTE','COSTO PRESENTADO / FINANCIACIÓN / SUPUESTOS','PLAZO / HITOS / DEPENDENCIAS','INDICADOR / META DOCUMENTADA / EVIDENCIA','ESTADO DE REVISIÓN / VACÍOS DOCUMENTALES']
for i,label in enumerate(labels):body+=placeholder(88,436+i*174,1064,148,label,22)
body+=footer(w,h)
templates.append(('template-proposal-sheet',w,h,body,'Ficha vacía; categorías no equivalen a promesas ni compromisos de una candidatura'))
for name,w,h,body,desc in templates:
p=ROOT/'templates'/(name+'.svg');p.write_text(svg(w,h,body,name,desc))
export(p,ROOT/'qa'/(name+'.png'),round(w*.4),round(h*.4))
assets.append({'id':name,'kind':'empty_editorial_template','svg':str(p.relative_to(ROOT)),'width':w,'height':h,'editableText':True,'fontFamily':'DejaVu Sans','status':'Template; no published data','background':'opaque white','publicationRule':'Sustituir campos pendientes, comprobar derechos, fuente y corte y retirar rótulo PLANTILLA solo tras revisión.'})
# Font-independent visual QA contact sheet assembled from rendered originals.
canvas=Image.new('RGB',(1240,1350),'#F8FAFC');d=ImageDraw.Draw(canvas);ft=ImageFont.truetype(str(fontpaths['regular']),21)
d.text((34,24),'Marca editorial · pruebas de escala y variantes',font=ft,fill=NAVY)
pos=[(34,82),(654,82),(34,305),(654,305)]
for name,(x,y) in zip(['horizontal','monochrome','compact','inverse'],pos):
im=Image.open(ROOT/'png'/f'logo-{name}@1x.png')
if name=='inverse':d.rounded_rectangle((x-8,y-8,x+480,y+175),radius=10,fill=NAVY)
canvas.paste(im,(x,y),im);d.text((x,y+180),name,font=ft,fill=NAVY)
im=Image.open(ROOT/'png/logo-stacked@1x.png');canvas.paste(im,(34,558),im)
d.text((34,842),'Apilado',font=ft,fill=NAVY)
for index,size in enumerate([16,24,32,48]):
filename='favicon-16.png' if size==16 else f'icon-{size}.png'
im=Image.open(ROOT/'qa'/filename);x=460+index*165;y=620
canvas.paste(im,(x,y),im);d.text((x,y+65),str(size)+' px',font=ft,fill=NAVY)
enlarged=im.resize((size*4,size*4),Image.Resampling.NEAREST)
canvas.paste(enlarged,(x,745),enlarged)
d.text((448,565),'Tamaño real y ampliación 4× para inspección',font=ft,fill=NAVY)
for index,name in enumerate(['template-og','template-card','template-proposal-sheet']):
im=Image.open(ROOT/'qa'/(name+'.png'));im.thumbnail((345,320));x=34+index*402
canvas.paste(im,(x,985),im);d.text((x,1320),name.replace('template-',''),font=ft,fill=NAVY)
canvas.save(ROOT/'qa/brand-contact-sheet.png')
# Standalone readable guide, mobile-safe and without external dependencies.
styles='''@font-face{font-family:DejaVu;src:url(fonts/DejaVuSans.ttf)}@font-face{font-family:DejaVu;src:url(fonts/DejaVuSans-Bold.ttf);font-weight:700}*{box-sizing:border-box}body{margin:0;background:#F8FAFC;color:#111827;font:16px/1.65 DejaVu,system-ui,sans-serif}header{background:#17253D;color:white;padding:36px max(20px,calc((100vw - 1040px)/2));}main{max-width:1080px;margin:auto;padding:32px 20px 64px}h1{font-size:32px;line-height:1.2;margin:0 0 14px}h2{font-size:25px;margin-top:40px}h3{font-size:18px}p{max-width:78ch}a{color:#C64E00;text-underline-offset:3px}a:focus-visible{outline:3px solid #17253D;outline-offset:4px}header a{color:white}.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px}.card{padding:22px;border:1px solid #CBD5E1;border-radius:12px;background:white;min-width:0}.preview{min-height:175px;display:flex;align-items:center;justify-content:center;border-radius:8px;background:repeating-conic-gradient(#F1F5F9 0% 25%,white 0% 50%) 0 0/20px 20px}.preview.dark{background:#17253D}.preview img{max-width:100%;height:auto}.small{font-size:14px;color:#475569}.swatches{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px}.swatch{min-height:90px;border:1px solid #64748B;border-radius:8px;padding:16px}table{border-collapse:collapse;width:100%;font-size:14px}th,td{text-align:left;padding:12px 8px;border-bottom:1px solid #CBD5E1;vertical-align:top}.scroll{overflow:auto}code{overflow-wrap:anywhere}li{margin:8px 0}.notice{background:#FFF2E8;border-left:4px solid #C64E00;padding:16px 20px}.templates img{max-height:320px;max-width:100%;object-fit:contain}.template-title{overflow-wrap:anywhere}@media(max-width:640px){.grid,.swatches{grid-template-columns:1fr}h1{font-size:28px}.card{padding:16px}main{padding:24px 16px}header{padding:28px 16px}.preview{min-height:150px}}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto}}'''
htmlparts=['
Guía de identidad editorial · Rolando Barboza
Identidad editorial
ROLANDO BARBOZA | DATOS Y PROPUESTAS
Activos de identificación y consulta documental. Propuesta creativa del portal; no es un manual oficial de Fuerza Popular.
']
htmlparts+=['
Estas piezas identifican un portal. No acreditan respaldo institucional, promotor autorizado, condición electoral ni propuestas publicadas. El símbolo de dos columnas es propio de esta propuesta; no sustituye el símbolo oficial de un partido.
Variantes de la marca
']
for asset in assets[:7]:
name=asset['id'];dark=' dark' if name=='inverse' else ''
htmlparts.append(f'
{html.escape(name)}
'+str(asset['width'])+' × '+str(asset['height'])+' unidades; fondo transparente.
']
for name,color,fg in [('Azul oscuro',NAVY,WHITE),('Naranja funcional',ORANGE,WHITE),('Texto principal',INK,WHITE),('Fondo cálido',WARM,INK),('Texto secundario',MUTED,WHITE),('Superficie neutra',NEUTRAL,INK)]:htmlparts.append(f'
{name} {color}
')
htmlparts+=['
Blanco sobre azul: 15,35:1. Blanco sobre naranja funcional: 4,68:1. Texto #111827 sobre naranja brillante #E96500: 5,34:1. Blanco sobre #E96500: 3,32:1; no usar para texto ordinario. Los estados e iconos deben tener etiquetas; el color no comunica por sí solo.
Ratios calculados para colores sólidos. Estos controles no acreditan conformidad integral con WCAG 2.2 AA.
Tipografía, medidas y protección
DejaVu Sans regular y bold. La entrega incluye los archivos de fuente originales y su licencia Bitstream Vera; los cambios de DejaVu son de dominio público. No se utilizó Inter. Los SVG finales contienen trazados para mantener la apariencia sin fuentes; editable/ conserva el texto editable.
Marca horizontal: proporción 420:160. Referencia mínima del encargo: 220 px. Para leer el descriptor se recomienda 360 px o más; por debajo, usar la variante compacta y colocar el descriptor como texto HTML legible fuera del logotipo.
Apilado: proporción 336:276; recomendado desde 280 px. Compacto: 292:100, sin descriptor.
Área libre exterior: al menos la mitad de la altura visible de la B de BARBOZA. No reducir ese espacio por la existencia de margen dentro del archivo.
Icono comprobado a 24, 32 y 48 px. Favicon específico a 16 px. No reducir el logotipo completo hasta tamaño de icono.
Escala de separación: 4, 8, 12, 16, 24, 32, 48 y 64 px. Radio de tarjeta 12 px; control 8 px.
En interfaz: cuerpo de 16–18 px, notas de 14 px, controles táctiles de 44 px. A 360 px usar una columna con 16 px laterales y marca compacta.
Uso editorial
Mostrar el promotor real de la web y su relación con la candidatura. No publicar campos pendientes como si identificaran a una persona.
Separar hechos, propuestas, estimaciones y evidencia verificada. Toda cifra publicada lleva fuente, ámbito, denominador cuando corresponda y corte.
Las plantillas no son compromisos ni publicaciones. Completar sus campos con documentos y pasar revisión antes de retirar el rótulo de plantilla.
No deformar, inclinar, aplicar sombras ni crear variantes de color sin documentarlas. La marca invertida blanca necesita fondo oscuro.
No añadir K aproximada, escudos, sellos, laureles, fotografías sintéticas ni señales de autoridad institucional.
Plantillas vacías
']
for asset in assets[7:]:
name=asset['id'];htmlparts.append(f'
{name}
{asset["width"]} × {asset["height"]} px · SVG con texto editable.
SVG y PNG generados localmente. Los PNG de marca mantienen transparencia a 1×, 2× y 4×. CairoSVG no estaba disponible; la exportación usa Inkscape 1.2.2. Las plantillas conservan fondo blanco intencionalmente.
']
(ROOT/'brand-guide.html').write_text(''.join(htmlparts))
manifest={'version':'1.0','createdAtUtc':datetime.now(timezone.utc).isoformat(),'brand':'ROLANDO BARBOZA | DATOS Y PROPUESTAS','purpose':'Neutral editorial identification and source-backed public information','creativeStatus':'Proposed portal identity; not an official party or government identity','font':{'family':'DejaVu Sans','weights':[400,700],'files':['fonts/DejaVuSans.ttf','fonts/DejaVuSans-Bold.ttf'],'license':'Bitstream Vera license; DejaVu changes public domain','licenseFile':'FONT-LICENSE.txt','localLicenseSource':'/usr/share/doc/fonts-dejavu-core/copyright','interUsed':False},'renderer':{'requestedPreferred':'CairoSVG if available','cairoSvgAvailable':False,'used':'Inkscape 1.2.2 (b0a8486541, 2022-12-01)','svgExports':'Outlined glyph paths','editableSources':'Text SVG with bundled DejaVu Sans'},'palette':{'navy':NAVY,'functionalOrange':ORANGE,'white':WHITE,'text':INK,'secondary':MUTED,'warm':WARM,'neutral':NEUTRAL,'border':BORDER,'decorativeOrange':'#E96500'},'contrast':[{'foreground':WHITE,'background':NAVY,'ratio':15.346,'ordinaryTextContrastPass':True},{'foreground':WHITE,'background':ORANGE,'ratio':4.676,'ordinaryTextContrastPass':True},{'foreground':WHITE,'background':'#E96500','ratio':3.320,'ordinaryTextContrastPass':False},{'foreground':INK,'background':'#E96500','ratio':5.344,'ordinaryTextContrastPass':True},{'foreground':MUTED,'background':WHITE,'ratio':7.578,'ordinaryTextContrastPass':True}],'contrastScope':'Solid color ratios only; not full accessibility certification','minimumSizes':{'horizontalReferencePx':220,'horizontalRecommendedForDescriptorPx':360,'compactMobile':'Use below 360px logo width, with readable HTML descriptor separately','iconChecksPx':[24,32,48],'faviconCheckPx':16},'clearSpace':'At least half of visible B cap height, outside the asset boundary','spacingPx':[4,8,12,16,24,32,48,64],'assets':assets,'rootFiles':['brand-guide.html','assets-manifest.json','FONT-LICENSE.txt','build_brand.py'],'qa':{'pngBackgroundsTransparent':all(p['transparentBackground'] for a in assets[:7] for p in a['png']),'logoVariants':7,'transparentPngExports':21,'emptyTemplates':5,'iconSizeRenders':[16,24,32,48],'visualReviewStatus':'Pending final image inspection','noPartySymbol':True,'noCandidateImage':True,'noInventedPublicData':True,'noSiteChanges':True}}
(ROOT/'assets-manifest.json').write_text(json.dumps(manifest,ensure_ascii=False,indent=2)+'\n')
print(json.dumps({'root':str(ROOT),'assets':len(assets),'transparentPngs':21,'templates':len(templates),'qa':manifest['qa']},ensure_ascii=False,indent=2))