Capas de blog geradas com L-system e sharp

Vitor Ayres (abre em nova aba)

Desenho de plantas gerado a partir do endereço deste post.
Plantas geradas pelo endereço deste post (1346e30).

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.webp e /blog/<post>/og.png.

Os requisitos

  1. Uma imagem diferente por post, sem desenhar à mão.
  2. A mesma imagem a cada build.
  3. Nada de rede no build. O site já tem um build de teste que roda sem acesso à internet.
  4. Nenhuma dependência nova, se possível.
  5. 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.

Dois espirógrafos gerados de hashes diferentes, quase idênticos

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=115 aplica peso e largura, os mesmos do título do site. Conferimos comparando com wdth=112.5: a largura do texto muda.
  • letter_spacing é medido em 1/1024 de ponto. Com dpi: 72, um ponto vale um pixel, então -820 dá cerca de -0,8 px. O código usa 18 vezes o tamanho da fonte, para o espaço apertar junto com as letras.
  • width faz 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

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.

Como podemos ajudar?

Oi (abre em nova aba)