Artigo → História em Quadrinhos (PDF)
Use quando o usuário fornecer um artigo (PDF, LaTeX, texto) e pedir por uma história em quadrinhos, uma história, uma "revista de quadrinhos", um explicador cartoon ou um resumo visual divertido. A saída é um PDF A4, com cerca de 8 páginas, desenhado inteiramente em código (SVG + HTML → Chromium PDF). Nenhuma geração de imagem é necessária.
1. Leia o artigo e faça uma ficha técnica (na sua cabeça ou em um arquivo rascunho)
Extraia, em palavras simples:
- O sistema / dispositivo / algoritmo que o artigo estuda (isto se torna o herói).
- O problema ou métrica ruim (interrupção, erro, atraso, ataque...) — isto se torna o vilão.
- O método clássico / de linha de base e o método proposto (e qualquer variante, por exemplo, UCB, ablação).
- A ideia-chave, teorema ou insight, e uma metáfora do dia a dia para isso (peças de quebra-cabeça, portas, chaves, estradas...).
- A regra de decisão ou mecanismo, se houver (limiares, interruptores, portas).
- Configuração de teste (parâmetros, número de tentativas, SNRs...).
- Números de destaque, tabelas, verificações de robustez, limites, trabalhos futuros, autores e afiliação.
Regras: copie os números exatamente como no artigo. Nunca invente resultados, nomes de conjuntos de dados ou afirmações. Se um valor simples puder ser recalculado (por exemplo, uma dimensão ou a saída de uma fórmula), recalcule-o com Python para verificar. Mantenha os termos do próprio artigo, mas explique cada sigla uma vez.
2. Escolha os personagens (funções do motor)
| Papel na história | Função do motor | Uso típico |
|---|---|---|
| Herói (o sistema) | robot(...) | cor, etiqueta label no peito, antenna=True desenha um trilho de portas na cabeça |
| Mentor / narrador | scientist(...) | explica a teoria; headwear="hijab" ou "hair"; point=True |
| Vilão (o problema) | monster(...) | label na barriga, humores angry / hurt |
| Método clássico / de linha de base | sergeant(...) | disciplinado, régua, label por exemplo "GRID", "BP", "ZF" |
| Método proposto / adaptativo | explorer(...) | feixe de lanterna; segunda variante com tool="lantern" e outra cor |
| Regra de decisão / limiar | gatekeeper(...) | texto da placa label + sub (por exemplo "D","99") |
Dê ao herói um nome curto e amigável relacionado ao tópico (FLUI para uma antena fluida, DECO para um decodificador...). Escolha um título de história em quadrinhos cativante (por exemplo, "AS 50 PORTAS").
3. Storyboard (arco padrão de 8 páginas — adapte ao artigo)
- Capa: título grande, subtítulo "Como <herói> aprendeu a ...", tagline com a lição principal, todos os personagens, linha de crédito com o título completo do artigo, autores, afiliação.
- Cap.1 O mundo e o problema: quem é o herói, o que ele deve fazer, o que o limita (orçamento, ruído, uma cadeia de RF...), o vilão. Um pequeno quadro com o modelo físico.
- Cap.2 Duas maneiras de ver: método de linha de base vs. método proposto, como cada um funciona (uma fórmula em uma legenda
math), e as regras justas da comparação. - Cap.3 A reviravolta / insight-chave: uma surpresa (onde a nova ideia falha ou vence), então o mentor explica a teoria com um gráfico de lousa e uma tabela.
- Cap.4 O mecanismo: painéis de metáfora (por exemplo, peças de quebra-cabeça), números de evidência, a regra de decisão como um diamante com ramos SIM/NÃO.
- Cap.5 O grande teste: legenda de configuração, tabela de placar, mapa de calor ou gráfico, resultado de destaque em um balão do mentor.
- Cap.6 Robustez: outras configurações, verificações de sensibilidade, o "único erro" explicado honestamente.
- Final: painel de celebração, caixa A LIÇÃO, limites honestos + trabalhos futuros, FOLHA DE DICAS (números-chave) e PALAVRAS PARA SABER (glossário).
Um artigo curto pode precisar de 6 páginas; um longo, até 10. Cada página de capítulo tem 4–6 painéis.
Regras de escrita:
- Inglês simples (nível B2), amigável, suave: cada painel responde à pergunta do anterior e levanta a próxima.
- Uma ideia por painel. Balões com no máximo ~30 palavras; legendas com no máximo ~35 palavras.
- Negrito (
<b>) nas palavras e números-chave. Fórmulas apenas em legendasC(..., cls="math"), com<sub>/<sup>. - Todo número rastreável até o artigo. Marque desenhos ilustrativos como "típico" ou "exemplo".
- Os balões do mentor usam
cls="mentor"; exclamações usamcls="shout".
4. Construção
- Escreva o motor abaixo em
comic_engine.pyem uma pasta de trabalho (copie exatamente). - Escreva
story.pyque importa o motor, constrói as páginas e chamarender(pages, "<Titulo>_Comic_v1.pdf"). - Execute-o.
rendertambém escreve folhas de pré-visualização PNG 2-up em<nome>_preview/.
Requisitos: Python com playwright (Chromium), numpy, PIL, pdftoppm (poppler). As fontes Bangers + Comic Neue são baixadas uma vez por npm pack @fontsource/... em COMIC_FONT_DIR (padrão /tmp/comic_fonts); se offline, o motor usa fontes do sistema. Se o Chromium do Playwright estiver faltando, execute python -m playwright install chromium.
Folha de dicas de layout:
- Página: A4 210 × 297 mm. Área utilizável x = 7…203 mm, y = 16…289 mm (cabeçalho acima). Medianiz 4 mm. Painel de largura total w = 196; metade w = 96 (x = 7 e x = 107).
- Dentro de um painel, unidades SVG = 4 por mm: um painel de 96 × 70 mm é 384 × 280 unidades. Mantenha toda a arte dentro (x < w·4, y < h·4).
- Os personagens são desenhados com os pés em (x, y); coloque os pés ≈ h·4 – 8. Alturas em s = 1: robô ≈ 125 unidades, cientista ≈ 120, explorador ≈ 120, sargento ≈ 115, porteiro ≈ 195; monstro é centralizado e ≈ 150 de largura.
- Balões/legendas usam mm relativos ao painel. Em 9,8 pt, um balão de 45 mm de largura comporta ~8 palavras por linha; cada linha ≈ 4,3 mm de altura.
- Rabos:
bl br bc tl tr l r— o rabo deve apontar para o falante. - Tabelas e caixas:
RAW(x, y, w, table(...)), ouRAW(..., '<div class="box lesson"><div class="lt">A LIÇÃO</div>...</div>'); caixas com<table>fazem uma folha de dicas, com<p>um glossário. - Texto de banner dentro de um painel:
'<div class="banner">O GRANDE TESTE</div>'como um item.
Pequeno exemplo (uma página):
from comic_engine import *
p = []
p.append(panel(7, 16, 196, 70,
robot(160, 272, 1.75, "happy") + dot_row(330, 80, 420, n=50, glow=31, h=26),
[C(3, 3, 74, "Em uma cidade cheia de ondas de rádio vive <b>FLUI</b>..."),
B(100, 44, 92, "Eu tenho <b>50 portas</b> mas apenas <b>uma</b> cadeia de rádio!", "tl")]))
p.append(panel(7, 90, 96, 70, scientist(78, 270, 1.4, point=True) + monster(290, 110, 1.05),
[B(3, 3, 50, "Escolha uma porta fraca e o <b>Monstro da Interrupção</b> vence.", "br", "mentor"),
B(42, 50, 50, "Abaixo do limiar? Você é MEU!", "tr", "shout")]))
pages = [cover("AS 50 PORTAS", "Como uma antena de uma orelha aprendeu <i>quando</i> olhar esperta", art,
"Olhe onde importa...<br>mas apenas quando importa!", credit_html),
page(2, "Capítulo 1 · Uma Orelha, Cinquenta Portas", p)]
render(pages, "As_50_Portas_Comic_v1.pdf")
5. Verificação visual (sempre)
Abra cada folha de pré-visualização com a ferramenta de Leitura e corrija antes de entregar:
- Um balão ou legenda cobre um rosto ou um número-chave → mova-o.
- Arte cortada na borda do painel ou texto cortado na parte inferior → mova ou reduza.
- Rabo aponta para o personagem errado → mude o rabo.
- Um painel está meio vazio → adicione um personagem, um adereço ou um gráfico maior.
- Números correspondem ao artigo. Re-renderize até ficar limpo (geralmente 2–3 rodadas).
6. Entrega
- Nome do arquivo
<Titulo_Curto>_Comic_v1.pdf; cada revisão posterior recebe v2, v3, ... - Envie com SendUserFile. Responda em 3–5 linhas curtas: o título, o elenco, a lista de páginas e uma oferta (outro idioma, outros nomes de personagens, mais ou menos páginas).
- Para uma versão em árabe: adicione
dir="rtl"nos balões/legendas e carregue uma fonte árabe (por exemplo,npm pack @fontsource/cairo) no CSS; mantenha números e fórmulas LTR.
Motor: comic_engine.py (escreva este arquivo exatamente)
"""comic_engine.py — motor reutilizável para explicadores de artigos de pesquisa em estilo revista em quadrinhos.
Coordenadas: cada painel é colocado em mm em uma página A4 (210 x 297 mm). Dentro de um painel,
o desenho SVG usa U = 4 unidades por mm (um painel de 96 x 70 mm tem viewBox 0 0 384 280).
Os personagens são desenhados com os PÉS em (x, y); s = escala (1,0 tem cerca de 30 mm de altura).
"""
import os, subprocess, glob
import numpy as np
U = 4
INK = "#141414"
FONT_DIR = os.environ.get("COMIC_FONT_DIR", "/tmp/comic_fonts")
# ---------------------------------------------------------------- fonts
def ensure_fonts():
"""Baixa Bangers + Comic Neue (via npm) uma vez. Volta para fontes do sistema se offline."""
want = ["bangers-latin-400-normal.woff2", "comic-neue-latin-400-normal.woff2", "comic-neue-latin-700-normal.woff2"]
found = {os.path.basename(p): p for p in glob.glob(f"{FONT_DIR}/**/*.woff2", recursive=True)}
if all(w in found for w in want):
return found
os.makedirs(FONT_DIR, exist_ok=True)
try:
subprocess.run(["npm", "pack", "@fontsource/bangers", "@fontsource/comic-neue"], cwd=FONT_DIR,
check=True, capture_output=True)
for t in glob.glob(f"{FONT_DIR}/*.tgz"):
d = t[:-4]
os.makedirs(d, exist_ok=True)
subprocess.run(["tar", "xzf", t, "-C", d], check=True)
except Exception as e:
print("falha no download de fontes, usando fallbacks:", e)
return {os.path.basename(p): p for p in glob.glob(f"{FONT_DIR}/**/*.woff2", recursive=True)}
# ---------------------------------------------------------------- characters
def robot(x, y, s=1.0, mood="happy", arms="down", color="#29b6f6", label="1 RF", antenna=True, light="#e1f5fe"):
"""Robô herói (o sistema/dispositivo que o artigo estuda). humores: happy worried surprised determined joy.
arms: down up think. antenna=True desenha um trilho de portas na cabeça."""
mouths = {
"happy": f'<path d="M-11,-75 Q0,-64 11,-75" fill="none" stroke="{INK}" stroke-width="3" stroke-linecap="round"/>',
"worried": f'<path d="M-10,-70 Q0,-78 10,-70" fill="none" stroke="{INK}" stroke-width="3" stroke-linecap="round"/>'
f'<path d="M-18,-97 L-6,-94 M18,-97 L6,-94" stroke="{INK}" stroke-width="3" stroke-linecap="round"/>',
"surprised": f'<ellipse cx="0" cy="-72" rx="4.5" ry="6" fill="{INK}"/>',
"determined": f'<path d="M-9,-72 L9,-72" stroke="{INK}" stroke-width="3" stroke-linecap="round"/>'
f'<path d="M-18,-96 L-6,-92 M18,-96 L6,-92" stroke="{INK}" stroke-width="3.5" stroke-linecap="round"/>',
"joy": f'<path d="M-12,-76 Q0,-60 12,-76 Z" fill="#c62828" stroke="{INK}" stroke-width="2.5"/>',
}
a = {"up": "M-30,-52 L-48,-80 M30,-52 L48,-80", "think": "M-30,-52 L-44,-30 M30,-52 L22,-70"}.get(
arms, "M-30,-52 L-44,-30 M30,-52 L44,-30")
top = ""
if antenna:
dots = "".join(f'<circle cx="{-52 + k*11.5}" cy="-118" r="3" fill="#0277bd"/>' for k in range(10))
top = (f'<rect x="-62" y="-125" width="124" height="14" rx="7" fill="#b2ebf2" stroke="{INK}" stroke-width="3"/>{dots}'
f'<circle cx="{-52 + 6*11.5}" cy="-118" r="6" fill="#ffeb3b" stroke="{INK}" stroke-width="1.5"/>')
else:
top = f'<circle cx="0" cy="-122" r="7" fill="#ffeb3b" stroke="{INK}" stroke-width="2.5"/>'
return f'''<g transform="translate({x},{y}) scale({s})">
<ellipse cx="0" cy="2" rx="40" ry="6" fill="#000" opacity=".15"/>
<rect x="-21" y="-14" width="13" height="14" rx="3" fill="#455a64" stroke="{INK}" stroke-width="3"/>
<rect x="8" y="-14" width="13" height="14" rx="3" fill="#455a64" stroke="{INK}" stroke-width="3"/>
<path d="{a}" stroke="{INK}" stroke-width="11" stroke-linecap="round" fill="none"/>
<path d="{a}" stroke="{color}" stroke-width="6" stroke-linecap="round" fill="none"/>
<rect x="-32" y="-64" width="64" height="52" rx="12" fill="{color}" stroke="{INK}" stroke-width="3"/>
<rect x="-17" y="-54" width="34" height="20" rx="4" fill="#01579b" stroke="{INK}" stroke-width="2"/>
<text x="0" y="-39.5" font-size="10" fill="#fff" text-anchor="middle" font-family="Bangers">{label}</text>
<rect x="-6" y="-116" width="12" height="12" fill="#90a4ae" stroke="{INK}" stroke-width="2.5"/>
<rect x="-31" y="-106" width="62" height="44" rx="15" fill="{light}" stroke="{INK}" stroke-width="3"/>
<ellipse cx="-11" cy="-87" rx="6" ry="8" fill="{INK}"/><ellipse cx="11" cy="-87" rx="6" ry="8" fill="{INK}"/>
<circle cx="-9" cy="-90" r="2.2" fill="#fff"/><circle cx="13" cy="-90" r="2.2" fill="#fff"/>
<circle cx="-22" cy="-76" r="4" fill="#f48fb1" opacity=".7"/><circle cx="22" cy="-76" r="4" fill="#f48fb1" opacity=".7"/>
{mouths[mood]}{top}</g>'''
def scientist(x, y, s=1.0, point=False, mood="smile", flip=False, scarf="#7e57c2", headwear="hijab",
skin="#f1c27d", hair="#3e2723"):
"""Mentor / narrador que explica. headwear: 'hijab' ou 'hair'. humores: smile open serious.
point=True segura uma vareta apontando para o canto superior direito (flip=True espelha)."""
mouth = {"smile": f'<path d="M-6,-82 Q0,-77 6,-82" fill="none" stroke="{INK}" stroke-width="2.2" stroke-linecap="round"/>',
"open": f'<path d="M-6,-83 Q0,-74 6,-83 Z" fill="#c62828" stroke="{INK}" stroke-width="2"/>',
"serious": f'<path d="M-5,-81 L5,-81" stroke="{INK}" stroke-width="2.2" stroke-linecap="round"/>'}[mood]
arm = (f'<path d="M18,-60 L40,-82" stroke="{INK}" stroke-width="11" stroke-linecap="round"/>'
f'<path d="M18,-60 L40,-82" stroke="#fafafa" stroke-width="6" stroke-linecap="round"/>'
f'<path d="M40,-82 L66,-108" stroke="#6d4c41" stroke-width="3.5" stroke-linecap="round"/>'
f'<circle cx="40" cy="-82" r="5" fill="{skin}" stroke="{INK}" stroke-width="2"/>') if point else \
(f'<path d="M20,-60 L28,-30" stroke="{INK}" stroke-width="11" stroke-linecap="round"/>'
f'<path d="M20,-60 L28,-30" stroke="#fafafa" stroke-width="6" stroke-linecap="round"/>')
if headwear == "hijab":
head = (f'<path d="M-25,-92 Q-30,-66 -15,-62 L15,-62 Q30,-66 25,-92 Z" fill="{scarf}" stroke="{INK}" stroke-width="3"/>'
f'<circle cx="0" cy="-94" r="25" fill="{scarf}" stroke="{INK}" stroke-width="3"/>')
else:
head = f'<path d="M-19,-86 Q-22,-116 0,-114 Q22,-116 19,-86 Q12,-104 0,-102 Q-12,-104 -19,-86 Z" fill="{hair}" stroke="{INK}" stroke-width="2.5"/>'
fl = ' scale(-1,1)' if flip else ''
body = f'''<ellipse cx="0" cy="2" rx="34" ry="6" fill="#000" opacity=".15"/>
<ellipse cx="-11" cy="-2" rx="10" ry="5" fill="#3e2723" stroke="{INK}" stroke-width="2"/>
<ellipse cx="0" cy="-2" rx="10" ry="5" fill="#3e2723" stroke="{INK}" stroke-width="2"/>
<path d="M-18,-4 L-30,-40 C-40,-50 -50,-40 -60,-30" stroke="{INK}" stroke-width="8" fill="none" stroke-linecap="round"/>
<path d="M-18,-4 L-30,-40 C-40,-50 -50,-40 -60,-30" stroke="#fafafa" stroke-width="4" fill="none" stroke-linecap="round"/>'''
return f'<g transform="translate({x},{y}) scale({s})">{body}<circle cx="0" cy="-94" r="25" fill="{skin}" stroke="{INK}" stroke-width="3"/>{mouth}{head}<circle cx="-8" cy="-98" r="3.5" fill="{INK}"/><circle cx="8" cy="-98" r="3.5" fill="{INK}"/>{arm}</g>'
def monster(x, y, s=1.0, mood="angry", color="#e53935", label="Erro", eye_color="#ffeb3b"):
"""Vilão (o problema que o herói enfrenta). humores: angry hurt. label na barriga."""
mouth = {"angry": f'<path d="M-15,-20 Q0,-10 15,-20" stroke="{INK}" stroke-width="3" fill="none"/>',
"hurt": f'<path d="M-15,-20 L0,-15 L15,-20" stroke="{INK}" stroke-width="3" fill="none" stroke-linejoin="round"/>'}[mood]
return f'''<g transform="translate({x},{y}) scale({s})">
<ellipse cx="0" cy="2" rx="45" ry="6" fill="#000" opacity=".15"/>
<path d="M-30,-35 L-40,-45 L-30,-55 L-20,-45 Z" fill="{color}" stroke="{INK}" stroke-width="2"/>
<path d="M30,-35 L40,-45 L30,-55 L20,-45 Z" fill="{color}" stroke="{INK}" stroke-width="2"/>
<ellipse cx="0" cy="-15" rx="50" ry="40" fill="{color}" stroke="{INK}" stroke-width="3"/>
<circle cx="-15" cy="-25" r="8" fill="#fff" stroke="{INK}" stroke-width="2"/>
<circle cx="15" cy="-25" r="8" fill="#fff" stroke="{INK}" stroke-width="2"/>
<circle cx="-15" cy="-25" r="4" fill="{eye_color}" stroke="{INK}" stroke-width="1"/>
<circle cx="15" cy="-25" r="4" fill="{eye_color}" stroke="{INK}" stroke-width="1"/>
<ellipse cx="0" cy="-15" rx="15" ry="10" fill="#fff" stroke="{INK}" stroke-width="2"/>
<text x="0" y="-12" font-size="8" fill="{INK}" text-anchor="middle" font-family="Bangers">{label}</text>
{mouth}</g>'''
def sergeant(x, y, s=1.0, mood="strict", color="#5d4037", label="LINHA DE BASE"):
"""Método clássico / de linha de base. humores: strict neutral."""
mouth = {"strict": f'<line x1="-10" y1="-70" x2="10" y2="-70" stroke="{INK}" stroke-width="2"/>',
"neutral": f'<path d="M-6,-70 Q0,-66 6,-70" fill="none" stroke="{INK}" stroke-width="2"/>'}[mood]
return f'''<g transform="translate({x},{y}) scale({s})">
<ellipse cx="0" cy="2" rx="30" ry="5" fill="#000" opacity=".15"/>
<rect x="-22" y="-40" width="44" height="35" rx="8" fill="{color}" stroke="{INK}" stroke-width="2"/>
<rect x="-10" y="-20" width="20" height="12" rx="3" fill="#fff" stroke="{INK}" stroke-width="1.5"/>
<text x="0" y="-11" font-size="6" fill="{INK}" text-anchor="middle" font-family="Bangers">{label}</text>
<circle cx="0" cy="-50" r="20" fill="#f5f5f5" stroke="{INK}" stroke-width="2.5"/>
<circle cx="-7" cy="-53" r="2.5" fill="{INK}"/><circle cx="7" cy="-53" r="2.5" fill="{INK}"/>
{mouth}<path d="M-22,-30 L-12,-30" stroke="{INK}" stroke-width="3"/></g>'''
def explorer(x, y, s=1.0, mood="curious", color="#2e7d32", label="PROPOSTO", tool="flashlight"):
"""Método proposto / adaptativo. tool: 'flashlight' ou 'lantern'."""
mouth = {"curious": f'<ellipse cx="0" cy="-66" rx="3" ry="5" fill="{INK}"/>',
"happy": f'<path d="M-6,-68 Q0,-62 6,-68" fill="none" stroke="{INK}" stroke-width="2"/>'}[mood]
return f'''<g transform="translate({x},{y}) scale({s})">
<ellipse cx="0" cy="2" rx="30" ry="5" fill="#000" opacity=".15"/>
<rect x="-20" y="-38" width="40" height="32" rx="8" fill="{color}" stroke="{INK}" stroke-width="2"/>
<rect x="-8" y="-18" width="16" height="10" rx="2" fill="#fff" stroke="{INK}" stroke-width="1.5"/>
<text x="0" y="-10" font-size="5.5" fill="{INK}" text-anchor="middle" font-family="Bangers">{label}</text>
<circle cx="0" cy="-48" r="18" fill="#fcfcfc" stroke="{INK}" stroke-width="2.5"/>
<circle cx="-6" cy="-51" r="2.5" fill="{INK}"/><circle cx="6" cy="-51" r="2.5" fill="{INK}"/>
{mouth}<path d="M20,-30 L30,-20" stroke="{INK}" stroke-width="2" fill="none"/></g>'''
def gatekeeper(x, y, s=1.0, mood="strict", color="#6a1b9a", label="D", sub="99"):
"""Regra de decisão / limiar."""
mouth = {"strict": f'<line x1="-8" y1="-65" x2="8" y2="-65" stroke="{INK}" stroke-width="2"/>',
"neutral": f'<path d="M-5,-65 Q0,-61 5,-65" fill="none" stroke="{INK}" stroke-width="2"/>'}[mood]
return f'''<g transform="translate({x},{y}) scale({s})">
<ellipse cx="0" cy="2" rx="25" ry="4" fill="#000" opacity=".15"/>
<rect x="-18" y="-35" width="36" height="28" rx="6" fill="{color}" stroke="{INK}" stroke-width="2"/>
<rect x="-10" y="-15" width="20" height="14" rx="2" fill="#fff" stroke="{INK}" stroke-width="1.5"/>
<text x="0" y="-5" font-size="7" fill="{INK}" text-anchor="middle" font-family="Bangers">{label}<tspan dy="8" font-size="4">{sub}</tspan></text>
<circle cx="0" cy="-45" r="16" fill="#f5f5f5" stroke="{INK}" stroke-width="2"/>
<circle cx="-5" cy="-48" r="2" fill="{INK}"/><circle cx="5" cy="-48" r="2" fill="{INK}"/>
{mouth}</g>'''
# ---------------------------------------------------------------- helpers
def dot_row(x0, y0, x1, n=30, glow=0, h=20):
"""Linha de pontos de x0,y0 a x1,y0 com n pontos. glow é raio do brilho (0 = desligado). h é altura do brilho."""
pts = "".join(f'<circle cx="{x0 + k*(x1-x0)/(n-1)}" cy="{y0}" r="1.5" fill="#0277bd"/>' for k in range(n))
if glow > 0:
glow_svg = (f'<defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1">'
f'<stop offset="0" stop-color="#0277bd" stop-opacity="{glow/100}"/>'
f'<stop offset="1" stop-color="#0277bd" stop-opacity="0"/></linearGradient></defs>'
f'<rect x="{x0}" y="{y0-h}" width="{x1-x0}" height="{h}" fill="url(#g)"/>')
else:
glow_svg = ""
return f'<g>{glow_svg}{pts}</g>'
# ---------------------------------------------------------------- panels and rendering
def panel(x, y, w, h, art="", items=None):
"""Painel na posição (x, y) mm, largura w mm, altura h mm.
art = SVG em unidades de painel (4 U/mm).
items = lista de tuplas (texto, x, y, largura, rabo, classe) ou objetos C/B."""
uw, uh = w*U, h*U
svg = f'<svg viewBox="0 0 {uw} {uh}" width="{w}mm" height="{h}mm" xmlns="http://www.w3.org/2000/svg">'
svg += f'<rect width="{uw}" height="{uh}" fill="#fff" stroke="{INK}" stroke-width="2"/>'
svg += art
if items:
for it in items:
if isinstance(it, tuple):
tx, ti_x, ti_y, ti_w, ti_tail, ti_cls = it
# simplificação: assume classe de balão
svg += f'<foreignObject x="{ti_x*U}" y="{ti_y*U}" width="{ti_w*U}" height="{uh/2}">'
svg += f'<div xmlns="http://www.w3.org/1999/xhtml" class="bubble {ti_cls}">'
svg += f'<div class="tail {ti_tail}"></div>{tx}</div></foreignObject>'
svg += '</svg>'
return f'<div style="position:absolute; left:{x}mm; top:{y}mm; width:{w}mm; height:{h}mm;">{svg}</div>'
def page(num, title, panels):
"""Página com número e título, contendo painéis."""
head = f'<div style="position:absolute; left:7mm; top:5mm; width:196mm; height:10mm; font-family: Comic Neue; font-size: 9pt; color: #555;"><b>{num}</b> · {title}</div>'
return f'<div style="position:relative; width:210mm; height:297mm; overflow:hidden;">{head}{"".join(panels)}</div>'
def cover(title, subtitle, art, tagline, credit):
"""Página de capa."""
return f'''<div style="position:relative; width:210mm; height:297mm; overflow:hidden; background:#fafafa;">
<div style="position:absolute; left:20mm; top:20mm; font-family:Bangers; font-size:36pt; color:#1a1a1a;">{title}</div>
<div style="position:absolute; left:20mm; top:55mm; font-family:Comic Neue; font-size:16pt; color:#333;">{subtitle}</div>
<div style="position:absolute; left:20mm; top:155mm;">{art}</div>
<div style="position:absolute; left:20mm; top:235mm; font-family:Comic Neue; font-size:12pt; color:#555;">{tagline}</div>
<div style="position:absolute; left:20mm; top:260mm; font-family:Comic Neue; font-size:8pt; color:#777;">{credit}</div>
</div>'''
def raw_mm(x, y, w, content):
"""Caixa de conteúdo bruto em mm."""
return f'<div style="position:absolute; left:{x}mm; top:{y}mm; width:{w}mm;">{content}</div>'
def C(x, y, w, text, tail="bl", cls=""):
"""Legenda (balão sem tail aparente) no painel."""
return (text, x, y, w, tail, cls)
def B(x, y, w, text, tail="bl", cls=""):
"""Balão de fala."""
return (text, x, y, w, tail, "bubble " + cls)
def render(pages, output, preview=True):
"""Renderiza páginas HTML para PDF via Playwright.
Escreve <output> e cria folhas de pré-visualização PNG."""
from playwright.sync_api import sync_playwright
import subprocess, os, shutil
fonts = ensure_fonts()
css = f'''
@font-face {{ font-family: Bangers; src: url("{fonts.get('bangers-latin-400-normal.woff2', '')}"); }}
@font-face {{ font-family: Comic Neue; src: url("{fonts.get('comic-neue-latin-400-normal.woff2', '')}"); }}
@font-face {{ font-family: Comic Neue; font-weight: bold; src: url("{fonts.get('comic-neue-latin-700-normal.woff2', '')}"); }}
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
.bubble {{ position: relative; display: inline-block; background: #fff; border: 2px solid #141414; border-radius: 8px; padding: 3px 6px; font-family: Comic Neue; font-size: 9.8pt; line-height: 1.3; color: #141414; }}
.bubble .tail {{ position: absolute; width: 0; height: 0; border: 6px solid transparent; }}
.bubble .tail.bl {{ bottom: -12px; left: 12px; border-top-color: #141414; }}
.bubble .tail.br {{ bottom: -12px; right: 12px; border-top-color: #141414; }}
.bubble .tail.bc {{ bottom: -12px; left: 50%; margin-left: -6px; border-top-color: #141414; }}
.bubble .tail.tl {{ top: -12px; left: 12px; border-bottom-color: #141414; }}
.bubble .tail.tr {{ top: -12px; right: 12px; border-bottom-color: #141414; }}
.bubble .tail.l {{ left: -12px; top: 50%; margin-top: -6px; border-right-color: #141414; }}
.bubble .tail.r {{ right: -12px; top: 50%; margin-top: -6px; border-left-color: #141414; }}
.bubble.mentor {{ background: #f3e5f5; border-color: #7e57c2; }}
.bubble.shout {{ background: #ffcdd2; border-color: #c62828; font-weight: bold; }}
.banner {{ font-family: Bangers; font-size: 18pt; text-align: center; color: #fff; background: #1565c0; padding: 4px 0; }}'''
html = f'<html><head><style>{css}</style></head><body>{ "".join(pages) }</body></html>'
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.set_content(html)
page.pdf(path=output, width="210mm", height="297mm")
browser.close()
if preview:
base = os.path.splitext(output)[0]
prev_dir = f"{base}_preview"
os.makedirs(prev_dir, exist_ok=True)
subprocess.run(["pdftoppm", "-png", "-r", "72", output, f"{prev_dir}/page"])
(O motor completo é fornecido como parte da skill; o trecho acima cobre as funções principais. Consulte o arquivo completo comic_engine.py para detalhes adicionais, como as funções de renderização completas e suporte a fontes.)