Capas de blog geradas com L-system e sharp
Vitor Ayres (abre em nova aba)
Cada post deste blog tem uma capa e uma imagem de compartilhamento geradas no build. As duas mostram um pequeno jardim de plantas, desenhado a partir de uma semente própria do post. Este texto mostra como isso funciona e o que ficou pelo caminho. As imagens de teste usaram três posts como amostra: API da ANVISA, notas fiscais e API do Mercos. As variações de cada rodada foram escritas por um agente de IA de programação, a partir dos nossos comentários. A história do processo, com as imagens de cada rodada, está no post Sob medida até nas capas do blog.
Resumo
- A semente é o hash do primeiro commit do repositório de que o post fala, guardado num JSON. Sem repositório, é o SHA-1 do endereço do post.
- O desenho é um L-system em 3D: cinco espécies, regras com variantes, poda, gravidade e espessura pelo número de pontas.
- Tudo vira SVG, e o sharp transforma em WebP (capa) e PNG (compartilhamento).
- O título no PNG é escrito pelo próprio sharp. Ele não leu a fonte em WOFF2; com o TTF funcionou.
- Duas rotas estáticas do Astro geram os arquivos:
/blog/<post>/capa.webpe/blog/<post>/og.png.
Os requisitos
- Uma imagem diferente por post, sem desenhar à mão.
- A mesma imagem a cada build.
- Nada de rede no build. O site já tem um build de teste que roda sem acesso à internet.
- Nenhuma dependência nova, se possível.
- A identidade do site: fundo claro, traço em azul-escuro, um único acento rosa.
A semente
A primeira tentativa usou dados vivos do repositório: árvore de arquivos, datas dos commits. Repositório com poucos commits gera uma imagem vazia, e continua vazia. Por isso a semente passou a ser algo fixo: o hash do primeiro commit.
import firstCommits from "./first-commits.json";
export function seedFor(post: Post): Seed {
const repo = post.data.projects.find((name) => name in firstCommits) as keyof typeof firstCommits | undefined;
if (repo) return { hash: firstCommits[repo], source: repo };
return { hash: createHash("sha1").update(post.id).digest("hex"), source: "" };
}
O JSON foi preenchido uma vez, com a API do GitHub. O primeiro commit não muda, então não há o que atualizar.
Do hash sai um gerador de números pseudoaleatórios de 32 bits. Cada decisão do desenho consome o próximo número, sempre na mesma ordem.
function random(hash: string) {
let a = (parseInt(hash.slice(0, 8), 16) ^ parseInt(hash.slice(8, 16), 16)) | 0;
return () => {
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
Hash único não é desenho único
Uma das rodadas testou uma família de máquinas de desenho. Os dois espirógrafos abaixo vieram de hashes diferentes.

O espirógrafo depende de dois inteiros pequenos, os tamanhos das engrenagens. Do jeito que estava montado, havia só algumas dezenas de combinações, e várias davam a mesma estrela. O hash tem 160 bits, e mesmo os 32 que o gerador usa dão mais de 4 bilhões de sementes. Mas o desenho só sabia fazer poucas formas.
A regra que ficou: o desenho precisa de muitos parâmetros contínuos, e a avaliação de “parecido” é feita olhando as imagens, não comparando os números.
L-system em 3D
Um L-system é uma gramática que reescreve uma palavra várias vezes. Depois, uma tartaruga (como a da linguagem Logo) lê a palavra e desenha. Usamos os símbolos de The Algorithmic Beauty of Plants, de Prusinkiewicz e Lindenmayer:
| Símbolo | Ação |
|---|---|
F |
anda e desenha um galho |
+ - |
gira para os lados |
& ^ |
inclina para baixo e para cima |
\ / |
gira sobre o próprio eixo |
[ ] |
abre e fecha um ramo |
L |
ponto onde pode nascer uma flor |
A tartaruga guarda três vetores (direção, esquerda e cima), e cada giro é uma rotação em torno de um deles, pela fórmula de Rodrigues.
const turn = (v: V, k: V, a: number): V =>
add(add(mul(v, Math.cos(a)), mul(cross(k, v), Math.sin(a))), mul(k, dot(k, v) * (1 - Math.cos(a))));
Uma versão com uma regra por espécie gerou o problema de sempre: a mesma árvore em todos os posts, só em escala diferente. O que resolveu:
- Cinco espécies, com silhuetas diferentes: copa larga, arbusto (o da figura 1.25 do livro), chorão, cipreste e pinheiro.
- Variantes por regra. As regras principais têm duas a quatro reescritas possíveis, e o gerador escolhe uma a cada passo.
- Faixas em vez de valores fixos para o ângulo, a altura do tronco e o quanto os galhos pendem.
- Poda. Ao abrir um ramo, há uma chance de pular o ramo inteiro, o que deixa as copas irregulares.
- Espécies diferentes no mesmo jardim.
Dois detalhes deram o ar natural:
- Gravidade. Depois de cada
F, a direção gira um pouco em direção ao chão, proporcional ao quanto o galho está fora da vertical. - Espessura. Percorrendo os galhos de trás para frente, cada um recebe a soma das pontas que sustenta. A largura do traço cresce com a raiz dessa soma, até um limite.
for (let i = segments.length - 1; i >= 0; i--) {
const seg = segments[i];
seg.load = tips.get(key(seg.b)) ?? 1;
tips.set(key(seg.a), (tips.get(key(seg.a)) ?? 0) + seg.load);
}
Câmera e traço
A câmera olha quase de frente, girada até 8 graus para os lados, um pouco de cima, com perspectiva leve. Os galhos são desenhados do mais distante para o mais próximo, e os distantes ficam mais claros e mais finos.
A primeira versão usava transparência nos traços. Onde dois segmentos se encontram, as pontas arredondadas se sobrepunham e escureciam, e os troncos pareciam listrados. A solução foi usar cores sólidas, misturadas com a cor do fundo conforme a distância.
Para o SVG não ficar enorme, os segmentos com a mesma cor e a mesma largura vão para um único <path>.
Ficaram limitações conhecidas: o cipreste às vezes sai fino como um palito, e o pinheiro pode ficar ralo. Foram aceitas. O resultado cumpre os requisitos, e mais uma rodada de ajustes não mudaria isso.
O piso em perspectiva e as sombras ficaram de fora. Funcionavam tecnicamente, mas pareciam artificiais perto do traço fino do resto do site.
Do SVG ao arquivo
O sharp, que o Astro já usa para imagens, lê SVG e exporta qualquer formato.
export async function coverImage(seed: Seed) {
return sharp(svg(garden(seed.hash, box)), { density: 144 }).webp({ quality: 82 }).toBuffer();
}
Com density: 144, o SVG de 1200 × 630 vira uma imagem de 2400 × 1260, para telas de alta densidade. Cada capa ficou com cerca de 100 KB.
A imagem de compartilhamento precisa do título escrito em PNG, porque as redes sociais não aceitam SVG. Aqui houve um tropeço.
O título e a fonte
O sharp aceita texto como entrada e o desenha com o Pango, passando o arquivo da fonte em fontfile. Com o WOFF2 que o site serve, o resultado saiu numa fonte monoespaçada genérica, sem erro nenhum.
A solução foi baixar o TTF variável da Anybody, a mesma família, do repositório oficial do Google Fonts (licença OFL). Com ele, o Pango aceita os eixos da fonte variável na própria descrição:
const heading = await sharp({
text: {
// The title goes into Pango markup, so & and < must be escaped.
text: `<span foreground="#0e1a2b" letter_spacing="-820">${markup(title)}</span>`,
font: "Anybody 46 @wght=650,wdth=115",
fontfile: "src/assets/fonts/anybody-variable.ttf",
width: 540,
align: "centre",
rgba: true,
dpi: 72,
},
}).png().toBuffer({ resolveWithObject: true });
Três detalhes:
@wght=650,wdth=115aplica peso e largura, os mesmos do título do site. Conferimos comparando comwdth=112.5: a largura do texto muda.letter_spacingé medido em 1/1024 de ponto. Comdpi: 72, um ponto vale um pixel, então-820dá cerca de -0,8 px. O código usa 18 vezes o tamanho da fonte, para o espaço apertar junto com as letras.widthfaz o Pango quebrar as linhas. Para a última linha não ficar com uma palavra só, o código estreita a largura de 20 em 20 px enquanto o número de linhas não muda, e fica com a mais estreita.
Palavras com hífen, como NF-e, vão dentro de <span allow_breaks="false">. Sem isso, o Pango quebrava a linha no hífen e deixava “e” sozinho embaixo.
O título fica no centro, com uma planta de cada lado. Na prévia pequena, o WhatsApp recorta a imagem num quadrado do meio, e um título à esquerda saía cortado.
O título sob medida
O tamanho da fonte não é fixo. O código começa em 76 px e diminui de 2 em 2 até as letras caberem no quadrado do meio, em três linhas no máximo. Títulos curtos saem grandes, e os longos, menores.
Para saber o tamanho das letras, o código lê o canal de transparência da imagem do título e acha o primeiro e o último pixel pintados. A caixa que o Pango devolve não serve, porque inclui o espaço reservado acima e abaixo de cada linha.
Com essas medidas, a imagem ganha duas linhas de cota em rosa, como as do topo do site: uma em cima, com a largura das letras, e outra ao lado, com a altura. Os números são os pixels reais de cada título.
Atrás de tudo fica a mesma grade de desenho técnico do site, que some perto das bordas. Na moldura, quatro marcas curtas em rosa mostram onde fica o quadrado que o WhatsApp recorta.
O texto alternativo da imagem segue as mesmas medidas: diz o título, o tamanho das letras e de onde vieram as plantas.
O título vira uma imagem com fundo transparente e é colado sobre o desenho com composite. O nome do site, na moldura tracejada, segue o mesmo caminho.
O TTF tem 201 KB e é usado só no build. O navegador continua baixando o WOFF2.
As rotas no Astro
Cada arquivo é uma rota estática com getStaticPaths, ao lado da página do post:
// src/pages/blog/[slug]/og.png.ts
export const getStaticPaths = (async () => {
const posts = await getPosts();
return posts.map((post) => ({ params: { slug: post.id }, props: { post } }));
}) satisfies GetStaticPaths;
export const GET: APIRoute<{ post: Post }> = async ({ props }) => {
const image = await shareImage(props.post.data.title, seedFor(props.post));
return new Response(new Uint8Array(image), { headers: { "Content-Type": "image/png" } });
};
A página do post aponta og:image, twitter:image e o image dos dados estruturados para /blog/<post>/og.png, com um texto alternativo que explica de onde vem o desenho.
O que ficou de fora
| Ideia | Por quê |
|---|---|
| Árvore de arquivos como planta baixa | Boa, mas depende do tamanho do repositório |
| Commits numa régua, horários numa grade | Ficam vazias em repositórios pequenos |
| Céu na data do post | Bonito, mas não diz nada do post |
| Curvas de nível, fluxo, cordilheira | Parecidas demais entre si |
| Harmonógrafo e máquinas de desenho | O harmonógrafo era o favorito; algumas máquinas da família colidiam |
| Fractal, atrator, Voronoi, coloração de domínio | Chamativos, mas brigam com a paleta do site |
| Physarum | Algumas sementes se desfazem em poucos traços |
| Kolam, autômato celular, randomart do SSH | Bons conceitos; perderam para as plantas |
Referências
- Prusinkiewicz e Lindenmayer, The Algorithmic Beauty of Plants.
- Documentação do sharp: entrada de texto e composite.
- Pango: descrição de fontes, incluindo os eixos de fontes variáveis.
- Astro: rotas de arquivos estáticos.
- A fonte Anybody, no repositório do Google Fonts.
Perguntas
- Por que usar o hash do primeiro commit como semente?
- Porque ele é único por repositório, não muda nunca e pode ser guardado num arquivo. O build não precisa de rede e a imagem de um post nunca muda depois de publicada.
- Por que o sharp não escreveu o título com a fonte do site?
- O sharp escreve texto com o Pango. O arquivo WOFF2 que o site usa não foi lido, e o texto saiu numa fonte genérica, sem erro. Com o TTF da mesma família funcionou, e conferimos que o eixo de largura é aplicado.
- Hash único garante imagem única?
- Não. Ele garante números diferentes. Se o desenho só consegue produzir algumas dezenas de formas, códigos diferentes caem na mesma forma. É preciso escolher um desenho com muitos parâmetros contínuos.