ADRIANOLAUREANO← Artigos

Como um PNG funciona por dentro

Pixels, cores, blocos internos, filtros e compressão na construção de um leitor de imagens PNG em Rust.

O COMPUTADOR RECEBE89 50 4E 47 0D 0A 1A 0A00 00 00 0D 49 48 44 5200 00 00 04 00 00 00 04assinatura · blocos · dados
A mesma imagem vista como aparência e como sequência de bytes.
8 bytesassinatura PNG
5 estratégiasreconstrução das linhas
RGB + RGBA8 bits por canal
1 saídaimagem observável

01 / DUAS FORMAS DE VER

Você enxerga uma imagem. O computador recebe uma sequência sem desenho algum.

Quando abrimos um PNG, vemos logo, screenshot, sprite ou ícone. No disco não existe uma miniatura escondida. Existem números. O trabalho do visualizador é interpretar esses números segundo um contrato compartilhado.

Onde estão largura e altura? Como quatro canais viram uma cor transparente? Onde começam os pixels? Por que um screenshot de milhões de pixels pode ocupar poucos kilobytes? Para responder, seguiremos o caminho real de um decoder: o componente que recebe os bytes codificados no arquivo e os transforma novamente em uma imagem. Neste artigo, também o chamaremos simplesmente de leitor.

A IDEIA CENTRAL

Um formato de arquivo é um acordo sobre o significado e a ordem dos bytes. Sem o acordo do PNG, 49 48 44 52 são apenas quatro números.

Este artigo continua a investigação de como o computador identifica arquivos e reaproveita a ideia de blocos apresentada em nosso compactador. Mesmo assim, tudo será reconstruído do zero aqui.

Mas antes de procurar chunks, filtros ou compressão, precisamos responder a uma pergunta mais básica: o que exatamente o PNG precisa representar?A resposta não começa no arquivo. Começa na própria imagem.

02 / A IMAGEM DIGITAL

Uma imagem é uma matriz: linhas, colunas e uma cor em cada posição.

Vamos esquecer o PNG por alguns minutos. Se quiséssemos descrever uma imagem usando apenas papel e números, precisaríamos informar quantas posições existem e qual cor ocupa cada uma. Essa grade de posições é a base de uma imagem digital.

Pixel é a menor amostra dessa grade. A coordenada (0, 0) fica no canto superior esquerdo. O eixo x cresce para a direita; o eixo y cresce para baixo. Uma imagem de 4 × 3 contém doze posições.

O arquivo organiza as amostras na ordem de leitura: primeira linha da esquerda para a direita, depois a segunda, até a última. “Largura” diz quantos pixels completam uma linha. “Altura” diz quantas linhas existem.

Vamos transformar uma imagem minúscula em uma lista

Imagine uma imagem de três pixels de largura por duas linhas de altura. Na tela, ela parece uma grade. Na memória, podemos guardar os pixels em uma lista única, começando pelo canto superior esquerdo:

linha 0: vermelho | verde   | azul
linha 1: branco   | preto   | amarelo

lista: [vermelho, verde, azul, branco, preto, amarelo]
012345
012345
A largura 3 informa onde a lista deve ser dobrada para reconstruir as duas linhas.

A lista perdeu o desenho da grade? Não, porque largura e altura permitem reconstruí-la. Sabendo que a largura é três, o leitor quebra a sequência depois de cada terceiro pixel. O índice linear de uma coordenada pode ser calculado comy × largura + x. Para (2, 1), temos1 × 3 + 2 = 5: o sexto elemento, amarelo.

Esse cálculo parece simples, mas é uma das ligações mais importantes do artigo. O arquivo não precisa guardar a coordenada de cada pixel. Ele guarda dimensões uma vez e depois coloca as amostras em uma ordem conhecida. Se cada pixel carregasse x e y, repetiríamos milhões de números desnecessários.

Pixel não é necessariamente um quadradinho físico

No contexto do arquivo, pixel é uma amostra da imagem. Quando ela aparece na tela, uma amostra pode ocupar um ponto físico, vários pontos por causa de escala ou até ser combinada com vizinhas por interpolação. O PNG descreve a imagem; quem decide como ampliá-la é o programa que a exibe.

NÃO CONFUNDA

Resolução informa quantas amostras existem. Tamanho físico depende também da densidade e de como o visualizador apresenta a imagem.

Até aqui usamos nomes como “vermelho”, “verde” e “azul” apenas para enxergar melhor o exemplo. Um arquivo de imagem não guarda esses nomes. Para registrar a cor de cada pixel, ele precisa representá-la usando números.

03 / CORES VIRAM NÚMEROS

Como representar uma cor usando números?

Uma tela produz cores combinando luz vermelha, verde e azul. Em vez de guardar o nome “laranja”, podemos dizer quanto de cada uma dessas três luzes participa da mistura. Esse modelo é chamado RGB: red,green e blue — vermelho, verde e azul.

Cada componente da mistura é chamado de canal. No caso mais comum do PNG, cada canal ocupa um byte e recebe um valor de 0 a 255. Zero significa nenhuma intensidade daquela luz; 255 significa intensidade máxima. Portanto, vermelho puro é 255 no canal vermelho e zero nos outros dois.

Juntos, R, G e B formam três bytes, ou 24 bits, por pixel e mais de 16 milhões de combinações. Em hexadecimal, cada canal cabe em dois dígitos.

#FF0000255, 0, 0vermelho
#00FF000, 255, 0verde
#0000FF0, 0, 255azul
#FFFFFF255, 255, 255branco

E se o pixel precisar ser transparente?

RGB resolve a cor, mas não responde quanto dela deve aparecer. Pense em um ícone com cantos arredondados colocado sobre fundos diferentes. Precisamos guardar a cor do pixel e também seu grau de transparência. Para isso acrescentamos um quarto canal chamado alpha, representado pela letra A.

O modelo passa a se chamar RGBA: os três canais de cor mais o canal alpha. A = 255 significa totalmente opaco;A = 0, totalmente transparente. Alpha não substitui nem altera os valores RGB; ele informa como aquela cor será combinada com o fundo.

RGBA237, 107, 50, 255
RGBA237, 107, 50, 128
RGBA237, 107, 50, 0

Por que o intervalo termina em 255?

Um byte possui oito bits. Cada bit pode valer zero ou um, então existem2⁸ = 256 combinações. Como começamos a contar em zero, o maior valor é 255. Em binário, zero é 00000000 e 255 é11111111. O valor 128, aproximadamente metade da intensidade, é10000000.

Dois dígitos hexadecimais também representam exatamente um byte. Cada dígito hexadecimal possui 16 possibilidades; 16 × 16 = 256. Por isso a cor CSS #ED6B32 pode ser separada em ED,6B e 32. Convertidos para decimal, eles viram 237, 107 e 50.

000000000nenhuma intensidade
25511111111intensidade máxima
Existem 256 posições na escala porque o zero também ocupa uma delas.
CanalHexadecimalDecimalBinário
RED23711101101
G6B10701101011
B325000110010

Alpha é uma proporção, não uma cor

Para apresentar um pixel com alpha sobre um fundo, o visualizador combina as duas cores. Com alpha 128, aproximadamente 50%, metade da contribuição vem da imagem e metade do fundo. Um canal pode ser calculado de forma simplificada comoorigem × alpha + fundo × (1 − alpha), usando alpha normalizado entre zero e um.

Um vermelho (255, 0, 0, 128) sobre branco não aparece como vermelho puro: torna-se rosa. O arquivo continua guardando vermelho e alpha separadamente. Isso permite colocar o mesmo PNG sobre fundos diferentes sem recortar uma nova imagem para cada situação.

Já sabemos quanto custa uma posição: três bytes quando usamos RGB e quatro quando usamos RGBA. Se repetirmos isso para todos os pixels de uma imagem grande, quanto espaço será necessário? Essa conta explica por que o PNG precisa compactar dados.

04 / O CUSTO DOS PIXELS

Quanto espaço os pixels ocupariam se fossem gravados sem compressão?

Vamos usar uma imagem Full HD porque suas dimensões são familiares: 1920 pixels de largura por 1080 de altura. Ela contém1920 × 1080 = 2.073.600 pixels. Se cada posição usar RGBA, cada pixel precisa dos quatro bytes que acabamos de conhecer.

LARGURA1920
×
ALTURA1080
×
RGBA4 bytes
=
TOTAL8.294.400 B

Isso representa cerca de 7,91 MiB para um único quadro. Um PNG tenta representar os mesmos valores de forma menor e reversível. JPEG costuma reduzir mais fotografias porque aceita perder informação; PNG é sem perdas e favorece áreas chapadas, bordas nítidas e repetições, como interfaces e screenshots.

Multiplique isso por uma sequência de imagens

Sessenta quadros Full HD em RGBA bruto ocupam aproximadamente 474 MiB. Um minuto a sessenta quadros por segundo passaria de 27 GiB. Vídeo precisa explorar repetição dentro de cada quadro e entre quadros. O PNG trabalha com uma imagem independente, mas o problema inicial é semelhante: valores crus repetem muita informação.

Uma tela de aplicativo pode ter milhares de pixels consecutivos com o mesmo fundo. Mesmo quando as cores mudam em um degradê, pixels vizinhos costumam ser parecidos. O PNG usa filtros para transformar “valores parecidos” em diferenças pequenas e depois entrega essa sequência ao compressor.

ImagemCálculo brutoBytes
Ícone 32 × 32 RGB32 × 32 × 33.072
Foto 1920 × 1080 RGB1920 × 1080 × 36.220.800
Screenshot 1920 × 1080 RGBA1920 × 1080 × 48.294.400
Textura 4096 × 4096 RGBA4096 × 4096 × 467.108.864
COMPACTAR NÃO É DIMINUIR DIMENSÕES

Uma imagem 1920 × 1080 continua tendo 2.073.600 pixels depois de salva como PNG. O formato encontra outra maneira de representar os mesmos valores.

Temos, então, duas categorias de informação: uma pequena descrição da imagem — largura, altura e modo de cor — e uma quantidade potencialmente enorme de dados de pixels. O PNG precisa organizar essas partes de maneira que um leitor saiba onde cada uma começa e termina.

05 / O MAPA DO ARQUIVO

O PNG começa com uma assinatura e organiza o restante em blocos identificados.

A solução escolhida pelo formato é dividir o arquivo em blocos identificados. No PNG esses blocos são chamados de chunks. Pense em cada chunk como uma caixa com etiqueta: a etiqueta diz o tipo de conteúdo e a caixa informa seu próprio tamanho. Assim, o leitor consegue atravessar o arquivo sem confundir metadados com pixels.

Estrutura lógica de um PNG
8 Bassinatura
25 BIHDR
variávelchunks auxiliares
variávelIDAT…
12 BIEND

IHDR descreve como interpretar pixels. PLTE, abreviação depalette, pode guardar uma paleta. Um ou mais IDAT transportam o fluxo comprimido. IEND encerra logicamente a imagem. Chunks auxiliares podem guardar gamma, perfil de cor, texto e outras informações.

Um contêiner extensível em vez de um cabeçalho gigante

O PNG poderia ter colocado todos os campos possíveis em um cabeçalho fixo: dimensões, perfil de cor, comentários, data, transparência, paleta e assim por diante. Isso desperdiçaria espaço em imagens simples e tornaria qualquer evolução difícil. Chunks resolvem o problema: cada bloco declara seu tipo e tamanho.

Um decoder encontra um chunk auxiliar que não conhece, usa o tamanho para atravessá-lo e continua no próximo. Já um chunk crítico desconhecido impede a renderização correta. A primeira letra do tipo ajuda a comunicar essa diferença: maiúscula indica crítico; minúscula indica auxiliar.

ChunkPapelObrigatório?
IHDRdimensões e formato dos pixelssim, primeiro
PLTEpaleta RGB para imagens indexadasdepende do tipo de cor
IDATfluxo zlib com scanlinessim, um ou mais
IENDfim lógico do PNGsim, último
tEXttexto associado à imagemnão
gAMAinformação de gammanão

“Auxiliar” não significa inútil. Ignorar gerenciamento de cores pode mudar a aparência; ignorar texto pode perder autoria ou descrição. Significa apenas que os pixels básicos ainda podem ser decodificados sem compreender aquele chunk.

Mas existe um problema anterior: antes de interpretar qualquer caixa, o programa precisa confirmar que recebeu mesmo um PNG. Essa é a função dos oito primeiros bytes do arquivo.

06 / ASSINATURA PNG

Os primeiros oito bytes foram escolhidos para identificar o formato e denunciar erros comuns.

Quando um programa recebe um arquivo, a extensão pode estar errada. Por isso o Pingo não começa procurando pixels. Ele compara os primeiros oito bytes com uma sequência fixa definida pelo PNG. Essa sequência é a assinatura — também chamada de magic bytes.

89alto
50P
4EN
47G
0DCR
0ALF
1ASUB
0ALF

50 4E 47 forma “PNG” em ASCII. Os bytes de quebra de linha ajudam a perceber conversões indevidas de texto. A assinatura identifica o contêiner; ainda precisamos validar cada estrutura interna.

const PNG_SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n";

if bytes.get(..8) != Some(PNG_SIGNATURE) {
    return Err(PngError::InvalidSignature);
}

Se a comparação falhar, paramos: interpretar o restante com regras de PNG seria perigoso e produziria erros sem sentido. Se funcionar, o cursor está exatamente no byte 8. A partir dessa posição começa o primeiro chunk. Agora precisamos aprender a abrir uma dessas caixas.

07 / ANATOMIA DE UM CHUNK

Todo chunk repete quatro campos, então o parser sempre sabe onde procurar o próximo.

Estamos no byte 8, logo depois da assinatura. O programa ainda não sabe se o próximo bloco descreve dimensões ou carrega outra informação. Ele só conhece a regra comum a todos os chunks: tamanho, tipo, dados e uma verificação final.

A parte do programa que percorre uma estrutura e transforma bytes em informações organizadas é chamada de parser. Nosso parser manterá um cursor, isto é, um número que indica a posição atual da leitura.

cursor
4 Btamanho
4 Btipo
N Bdados
4 BCRC
4 Btamanho
4 Btipo
M Bdados
4 BCRC
O tamanho do primeiro bloco permite calcular exatamente onde o campo “tamanho” do próximo começa.

Mas o que significa “chunk”?

Chunk é uma palavra inglesa que significa pedaço ou bloco. No PNG, um chunk é um bloco autocontido de informação dentro do arquivo. Ele não é outro arquivo e não é necessariamente uma imagem: pode descrever dimensões, carregar dados compactados, guardar texto ou apenas marcar o final.

A vantagem do chunk é ter uma fronteira clara. O próprio bloco informa “meus dados ocupam N bytes” e “meu tipo é este”. Assim o programa sabe quantos bytes consumir e qual regra usar para interpretá-los. Depois, o cursor cai exatamente no início do chunk seguinte.

4 BYTESTamanhoapenas os dados
4 BYTESTipoASCII: IHDR
N BYTESDadossignificado depende do tipo
4 BYTESCRCtipo + dados

Se o tamanho vale 13, o parser lê quatro bytes de tipo, treze de dados e quatro de CRC. O próximo chunk começa exatamente depois disso. O tamanho não inclui a si próprio, o tipo nem o CRC.

A posição de um byte contada a partir do início do arquivo também é chamada deoffset. Offset zero é o primeiro byte; offset oito é o primeiro byte depois da assinatura. Quando a tabela abaixo mostra 0x08, ela está indicando essa posição em hexadecimal.

O que é o CRC no final da caixa?

CRC significa Cyclic Redundancy Check, ou verificação cíclica de redundância. É uma conta feita com os bytes do tipo e dos dados. Quem cria o PNG grava o resultado no final do chunk. Quem lê repete a conta: se os valores divergem, algum byte mudou ou foi corrompido.

OffsetBytesLeitura
0x0800 00 00 0D13 bytes de dados
0x0C49 48 44 52“IHDR”
0x1013 bytescabeçalho da imagem
0x1D4 bytesCRC do tipo + dados
CRC NÃO É CRIPTOGRAFIA

Ele detecta corrupção acidental. Quem altera dados propositalmente pode recalcular o CRC. Autenticidade exige assinatura ou MAC.

Atravessando um chunk manualmente

Vamos fingir que o cursor está no offset 8, logo depois da assinatura. Os quatro primeiros bytes são 00 00 00 0D. Lidos em big-endian, eles formam 13. Agora sabemos que há quatro bytes de tipo, treze de dados e quatro de CRC. O tamanho total ocupado pelo chunk é 4 + 4 + 13 + 4 = 25 bytes.

O tipo 49 48 44 52 vira “IHDR” quando cada byte é interpretado como ASCII, uma tabela que associa números a letras e outros caracteres. Isso é útil para seres humanos em editores hexadecimais, mas o programa compara os quatro bytes diretamente com b"IHDR". Não há necessidade de alocar uma String para decidir o comportamento.

offset inicial: 8

8  + 4 bytes de tamanho = 12
12 + 4 bytes de tipo    = 16
16 + 13 bytes de dados  = 29
29 + 4 bytes de CRC     = 33

próximo chunk começa no offset 33

O cursor transforma essa conta em estado. A cada leitura válida ele avança. Quando chega a 33, o mesmo algoritmo pode ler o chunk seguinte sem saber se será PLTE, IDAT, um metadado ou IEND. É por isso que formatos baseados em blocos são extensíveis: a mecânica de navegação não muda quando aparece um tipo novo.

O nome do chunk também carrega propriedades

As letras maiúsculas e minúsculas não são apenas estilo. O primeiro caractere informa se o chunk é crítico ou auxiliar. O segundo diferencia tipos públicos e privados. O quarto informa se o chunk pode ser copiado por um editor que não o reconhece quando modifica a imagem. Essas propriedades usam um bit já presente em cada letra ASCII, sem adicionar campos ao arquivo.

Nosso Pingo reconhece os chunks críticos necessários ao escopo e ignora auxiliares desconhecidos depois de validar seus limites e CRC. Ignorar não significa ler de qualquer maneira: ainda precisamos atravessar o bloco com segurança para encontrar o próximo.

A mecânica de navegação está resolvida. Falta interpretar o significado de cada caixa. A especificação exige que o primeiro chunk seja IHDR. Ele responde às perguntas que surgiram lá no começo: qual é a largura, qual é a altura e quantos bytes formam cada pixel?

08 / IHDR E BIG-ENDIAN

Treze bytes definem dimensões e o modo de interpretar cada amostra.

IHDR é a abreviação usada pelo PNG paraimage header, ou cabeçalho da imagem. As letras aparecem sem vogais para caber no tipo fixo de quatro bytes do chunk. Ele não contém pixels. Ele contém as instruções necessárias para interpretar os pixels que aparecerão depois. Sem o IHDR, uma sequência de seis bytes poderia significar dois pixels RGB, um pixel de 16 bits por canal ou índices de uma paleta.

Todo PNG possui exatamente um IHDR, e ele deve ser o primeiro chunk depois da assinatura. Pense nele como a etiqueta externa de uma caixa de peças: antes de montar qualquer coisa, lemos qual modelo está ali e quantas peças esperar.

CampoTamanhoNosso suporte
Largura4 bytesmaior que zero
Altura4 bytesmaior que zero
Profundidade1 byte8
Tipo de cor1 byte2 (RGB) ou 6 (RGBA)
Compressão1 byte0
Filtro1 byte0
Entrelaçamento1 byte0, sem Adam7

Inteiros de vários bytes no PNG usam big-endian: o byte mais significativo vem primeiro. A largura 1920 é 00 00 07 80. Rust torna a intenção explícita:

let width_bytes = [0x00, 0x00, 0x07, 0x80];
let width = u32::from_be_bytes(width_bytes);
assert_eq!(width, 1920);

PLTE funciona de outra forma: cada pixel pode ser apenas um índice, e o chunk armazena a tabela RGB correspondente. Essa estratégia é excelente para imagens com poucas cores, mas nossa primeira versão rejeita o tipo 3 para manter o percurso de pixels direto e observável.

Decodificando um IHDR real, campo por campo

Considere os treze bytes abaixo. Eles descrevem uma imagem de 800 × 600 pixels, RGBA, com oito bits por canal e sem entrelaçamento:

00 00 03 20   largura:  0x00000320 = 800
00 00 02 58   altura:   0x00000258 = 600
08            profundidade: 8 bits por amostra
06            tipo de cor: RGB + alpha
00            compressão: método 0
00            filtro: método 0
00            entrelaçamento: desativado

Profundidade é por amostra, não por pixel. No tipo 6 existem quatro amostras: vermelho, verde, azul e alpha. Portanto, oito bits por amostra produzem8 × 4 = 32 bits, ou quatro bytes, por pixel. No tipo 2 existem três amostras e o pixel ocupa 24 bits.

TipoSignificadoProfundidades permitidasBytes no nosso caso
0tons de cinza1, 2, 4, 8, 16não suportado
2RGB8, 163 por pixel
3paleta indexada1, 2, 4, 8não suportado
4cinza + alpha8, 16não suportado
6RGBA8, 164 por pixel

Não basta aceitar qualquer profundidade combinada com qualquer tipo. Uma imagem indexada de 16 bits, por exemplo, não é uma combinação válida do PNG. Um decoder completo implementa a matriz de possibilidades da especificação. O Pingo declara seu contrato menor: somente profundidade 8 com tipos 2 e 6.

Como uma paleta economiza espaço

Imagine um sprite que usa apenas quatro cores. Guardar RGB em cada posição custa três bytes por pixel, embora as mesmas combinações se repitam. Com PLTE, o arquivo guarda as quatro cores uma vez e cada pixel guarda um índice: zero, um, dois ou três. Com profundidade de dois bits, quatro índices cabem em um byte.

PLTE:
índice 0 → 00 00 00  (preto)
índice 1 → FF FF FF  (branco)
índice 2 → ED 6B 32  (laranja)
índice 3 → 80 CB D0  (ciano)

pixels:
02 02 03 00  → laranja, laranja, ciano, preto
PIXELS GUARDAM ÍNDICES2230
0preto
1branco
2laranja
3ciano
O pixel guarda apenas o número; a cor RGB completa aparece uma vez na tabela PLTE.

Paleta não é a mesma coisa que compressão DEFLATE. Ela muda a representação dos pixels antes da etapa de filtros e compressão. Depois, os índices também podem ser filtrados e comprimidos. Técnicas diferentes se acumulam porque atacam redundâncias diferentes.

Por que rejeitamos Adam7 nesta primeira versão?

Sem entrelaçamento, as linhas aparecem na ordem final. Adam7 divide os pixels em sete passagens. As primeiras carregam amostras espalhadas e permitem mostrar uma prévia grosseira antes de receber todo o arquivo. Para decodificar, precisaríamos calcular dimensões, filtros e posições separadamente para cada passagem.

Rejeitar o valor 1 é uma decisão de engenharia honesta. Aceitá-lo e tratar os dados como linhas normais geraria pixels embaralhados. Suporte parcial deve ser explícito: ou implementamos a regra, ou devolvemos um erro que explica a limitação.

Ao terminar o IHDR, o Pingo já sabe o formato esperado da imagem, mas ainda não encontrou os canais de cor. Eles ficam nos chunks IDAT — só que não aparecem como RGB legível. Antes de chegar aos pixels, teremos de atravessar duas camadas de compressão e uma transformação por linhas.

09 / IDAT, ZLIB E DEFLATE

Os pixels não ficam soltos no IDAT: ali existe um fluxo zlib com scanlines filtradas.

IDAT é a abreviação de image data: dados da imagem. Novamente, o nome foi reduzido a quatro letras para ocupar o campo de tipo do chunk. Seria tentador pegar seus bytes de três em três e chamá-los de RGB, mas isso não funcionaria. Para ocupar menos espaço, o encoder transformou as linhas e compactou o resultado antes de colocá-lo nesses chunks.

Pode existir um IDAT grande ou vários IDAT pequenos. Todos pertencem à mesma imagem. O leitor retira os dados de cada chunk, preserva a ordem e une os pedaços como se fossem uma única fita de bytes.

Portanto, a leitura acontece ao contrário da gravação: primeiro reunimos todos os pedaços de IDAT; depois removemos a compressão; em seguida desfazemos a transformação das linhas; somente então recuperamos os canais de cada pixel.

01IDAT + IDATconcatenar dados
02zlibenvelope + checksum
03DEFLATELZ77 + Huffman
04scanlinesbytes filtrados
05pixelsRGB ou RGBA

Pode haver vários IDAT, mas suas fronteiras não têm significado visual: não indicam linha, quadro ou bloco DEFLATE. O decoder concatena o conteúdo de todos eles e entrega um único fluxo ao descompressor.

zlib e DEFLATE não são sinônimos. DEFLATE é o método de compressão que combina referências a dados anteriores, no espírito do LZ77, com códigos Huffman. zlib é o envelope que identifica parâmetros e acrescenta uma verificação do fluxo. Reimplementar tudo desviaria o artigo; usamos flate2 para essa etapa e construímos o restante.

Três camadas que costumam ser confundidas

IDAT é a embalagem do PNG. zlib é a embalagem da compressão. DEFLATE é o método usado lá dentro. Quando o Pingo encontra três chunks IDAT, remove comprimento, tipo e CRC de cada um e concatena somente seus campos de dados. O resultado deve começar como um fluxo zlib e terminar com a verificação Adler-32 desse fluxo.

O CRC do chunk e o Adler-32 do zlib ficam em camadas diferentes. O CRC protege o tipo e os dados de cada IDAT individualmente. Adler-32 verifica o conteúdo descompactado do fluxo zlib completo. Nosso código valida CRCs e a implementação de zlib usada por flate2 cuida de sua própria estrutura.

PNG
└── IDAT 1 ─┐
    IDAT 2 ─┼─ dados concatenados
    IDAT 3 ─┘
       └── zlib
           ├── cabeçalho
           ├── blocos DEFLATE
           └── Adler-32

A intuição por trás do LZ77

Suponha que a sequência contenhaAZUL-AZUL-AZUL-AZUL. Em vez de repetir todos os símbolos, um compressor pode dizer “copie novamente o trecho que apareceu há pouco”. LZ77 procura correspondências em uma janela de dados anteriores e grava pares que representam distância e comprimento.

Filtros ajudam porque pixels vizinhos podem não repetir bytes exatamente, mas suas diferenças repetem. Um degradê com valores 100, 101, 102, 103 vira 100, 1, 1, 1 usando Sub. Agora o compressor encontra uma sequência óbvia de uns.

JÁ PASSOU PELA JANELAAZUL-AZUL-AZUL-AZUL
FORMA COMPACTAAZUL-volte 5 · copie 15
Em vez de reescrever o trecho repetido, a saída aponta para bytes que o decoder acabou de reconstruir.

A intuição por trás de Huffman

Depois de encontrar literais e referências, DEFLATE usa códigos menores para símbolos frequentes e maiores para símbolos raros. É semelhante a criar um alfabeto em que a letra mais comum pode ser escrita com poucos bits. Como nenhum código é prefixo completo de outro, o decoder consegue separar a sequência sem marcadores entre símbolos.

A8 vezes0
B3 vezes10
C1 vez110
D1 vez111
O símbolo mais comum recebe o caminho mais curto; os raros podem gastar mais bits.

O objetivo deste artigo não é esconder DEFLATE como “mágica”. É delimitar a fronteira. Nós entendemos o que entra — scanlines filtradas — e o que deve sair. Delegamos o algoritmo de compressão a uma biblioteca madura, da mesma forma que um projeto real pode implementar o formato PNG sem reescrever toda a teoria de compressão.

Depois que flate2 remove zlib e DEFLATE, ainda não recebemos a matriz final. Recebemos scanlines filtradas. Scanline é apenas o nome dado a uma linha de pixels armazenada no fluxo. Cada uma começa com um byte que explica como reconstruir os valores seguintes.

10 / SCANLINES E FILTROS

Antes de comprimir, o PNG transforma cada linha para deixar os números mais previsíveis.

Agora o termo scanline já tem um lugar claro: é uma linha da matriz original, apresentada na ordem de cima para baixo. Cada scanline descompactada começa com um byte de filtro. Esse primeiro byte é uma instrução; os restantes representam os canais daquela linha.

O filtro não altera a imagem; ele armazena diferenças em relação ao pixel anterior ou à linha acima. O decoder desfaz a operação exatamente.

O que significam os nomes None, Sub, Up, Average e Paeth?

São os cinco tipos de filtro definidos pelo PNG. Os nomes descrevem de onde vem o palpite usado para representar o próximo byte:

diagonalC
acimaB
esquerdaA
byte atualX
Sub consulta A; Up consulta B; Average usa A e B; Paeth compara A, B e C.
  • None significa “nenhum”: não usa vizinho como palpite.
  • Sub vem de “subtração”: compara com o pixel à esquerda.
  • Up significa “acima”: compara com o byte da linha anterior.
  • Average significa “média”: combina esquerda e acima.
  • Paeth é o nome do preditor que escolhe entre esquerda, acima e diagonal.

O encoder escolhe um desses cinco para cada linha e grava um número de 0 a 4 no início dela. O decoder lê o número, executa a regra correspondente e recupera os bytes originais.

ValorFiltroPreditor
0Nonezero
1Subbyte à esquerda
2Upbyte acima
3Averagemédia de esquerda e acima
4Paethescolhe esquerda, acima ou diagonal
PIXELS ORIGINAIS100 · 105 · 110 · 115
SUB →
VALORES GRAVADOS100 · 5 · 5 · 5

No exemplo, o primeiro valor permanece 100. Depois guardamos diferenças pequenas. Uma sequência de números próximos costuma ser mais amigável ao compressor. Em RGB, “esquerda” significa três bytes antes; em RGBA, quatro. Por isso o decoder precisa conhecer bytes por pixel.

Paeth calcula uma estimativa usando esquerda + acima − diagonal e escolhe o vizinho mais próximo. Não é uma média visual; é uma heurística inteira, rápida e reversível.

Primeiro: como uma scanline aparece após o zlib

Para uma imagem RGB de dois pixels, cada linha tem seis bytes de canais. Após descompactar, encontramos sete bytes: um seletor de filtro e os seis valores transformados. O byte de filtro não pertence a nenhum pixel.

00  FF 00 00  00 FF 00
│   └──pixel 0┘  └──pixel 1┘
└── filtro None

resultado:
pixel 0 = RGB(255, 0, 0)
pixel 1 = RGB(0, 255, 0)

O próximo byte depois do verde não é necessariamente o filtro da linha seguinte no arquivo. É o próximo byte no fluxo já descompactado. No PNG original, blocos DEFLATE e limites de IDAT podem cortar essa sequência em qualquer ponto.

Filtro 0 — None: não prever nada

None guarda o próprio byte. Para reconstruir, somamos zero. É simples e pode ser eficiente em dados que já não apresentam uma relação útil com vizinhos. Também é excelente para entender o formato e gerar casos de teste mínimos.

original:  20  40  60  80
gravado:   20  40  60  80
preditor:   0   0   0   0

Filtro 1 — Sub: usar o byte correspondente do pixel anterior

Sub calcula atual − esquerda durante a escrita. Na leitura fazemosfiltrado + esquerda. Para RGB, os três primeiros bytes não possuem pixel anterior e usam zero. A partir do quarto byte, cada canal olha três posições para trás.

pixels RGB originais:
(10, 20, 30) (15, 25, 35)

bytes gravados com Sub:
10 20 30  05 05 05

reconstrução do segundo vermelho:
5 + vermelho à esquerda (10) = 15

“Esquerda” não significa o byte imediatamente anterior. Para o verde do segundo pixel, o byte imediatamente anterior é o vermelho do mesmo pixel. O vizinho correto é o verde do pixel anterior, localizado bytes_per_pixelposições antes.

Filtro 2 — Up: usar o byte da linha anterior

Up é útil quando linhas consecutivas são parecidas, algo comum em fundos, gradientes verticais e regiões de uma screenshot. Na primeira linha não existe “acima”, então o preditor vale zero.

linha anterior:  100 110 120
linha atual:     102 111 119

gravado com Up:    2   1 255

por que 255?
119 - 120 = -1
em um byte, -1 módulo 256 = 255

na leitura:
255 + 120 = 119, com retorno módulo 256

Esse retorno explica wrapping_add. O comportamento circular não é tolerância a erro; é a aritmética definida pelo filtro.

Filtro 3 — Average: combinar esquerda e acima

Average usa a parte inteira de (esquerda + acima) / 2. Se esquerda vale 100 e acima vale 110, o preditor é 105. Um valor atual 108 é armazenado como 3. No começo da linha, esquerda é zero; na primeira linha, acima é zero.

esquerda = 100
acima    = 110

preditor = floor((100 + 110) / 2) = 105
atual    = 108
gravado  = 108 - 105 = 3

decoder  = 3 + 105 = 108

A soma precisa acontecer em um tipo maior que u8. Se ambos valem 200, a soma é 400. Somar como byte descartaria informação antes da divisão. Nosso código converte para u16, soma, divide e volta parau8.

Filtro 4 — Paeth: escolher o vizinho mais promissor

Paeth observa esquerda, acima e diagonal superior esquerda. Primeiro calculap = esquerda + acima − diagonal. Depois mede a distância entre p e cada candidato e escolhe o mais próximo. Em uma região com tendência horizontal, esquerda costuma vencer; em uma borda vertical, acima pode ser melhor.

esquerda = 90
acima = 100
diagonal = 92

p = 90 + 100 - 92 = 98

distância até esquerda = |98 - 90|  = 8
distância até acima    = |98 - 100| = 2
distância até diagonal = |98 - 92|  = 6

Paeth escolhe acima: 100

Em caso de empate, a ordem de preferência é definida e precisa ser respeitada. “Escolher qualquer um dos menores” parece equivalente, mas encoder e decoder precisam tomar exatamente a mesma decisão para a operação ser reversível.

Quem escolhe o filtro?

O encoder pode escolher um filtro diferente para cada linha. Ele costuma testar candidatos e estimar qual resultado será mais compressível. O decoder não adivinha: o primeiro byte de cada scanline informa a escolha. Isso cria uma assimetria útil. O encoder pode gastar mais tempo procurando uma boa opção; qualquer decoder apenas executa a opção registrada.

FILTRO NÃO É EFEITO VISUAL

Ele não borra, aguça ou muda cores. É uma transformação reversível dos números antes da compressão.

A jornada teórica chegou aos pixels: assinatura, chunks, IHDR, IDAT, zlib, scanlines e filtros agora formam uma sequência única. É hora de transformar essa sequência em programa. Vamos construir o Pingo na mesma ordem em que ele aprende a confiar no arquivo.

11 / IEND

Como o leitor sabe que o PNG terminou?

O último chunk obrigatório se chama IEND, abreviação deimage end: fim da imagem. Ele não possui dados. Sua presença informa que a sequência lógica de chunks terminou corretamente.

00 00 00 00   tamanho dos dados: 0
49 45 4E 44   tipo: "IEND"
AE 42 60 82   CRC do tipo IEND

Mesmo sem dados, o chunk continua seguindo a anatomia comum: quatro bytes de tamanho, quatro de tipo e quatro de CRC. Por isso ocupa doze bytes. O CRC é calculado sobre as letras IEND.

Poderíamos simplesmente parar quando acabassem os bytes do arquivo, mas isso não distinguiria um PNG completo de um arquivo cortado logo depois do último IDAT. Exigir IEND permite detectar truncamento e confirma que o escritor encerrou a estrutura de propósito.

ARQUIVO COMPLETOIHDRIDATIEND✓ final confirmado
ARQUIVO CORTADOIHDRIDAT?× IEND ausente
“Os bytes acabaram” pode ser acidente. Encontrar IEND é uma confirmação explícita.
RESUMO DOS QUATRO CHUNKS PRINCIPAIS

IHDR explica a imagem; PLTE pode definir uma paleta; IDAT transporta as linhas compactadas; IEND confirma o final.

Agora percorremos um PNG inteiro, da assinatura ao marcador final. Com o formato compreendido, podemos construir o programa que repetirá esse caminho.

12 / CONSTRUINDO O LEITOR

Conheça o Pingo: nosso leitor de PNG em Rust.

Todo projeto merece um nome quando deixa de ser um conjunto de exemplos e passa a resolver um caminho completo. O nosso se chama Pingo: pequeno, direto e fácil de lembrar, como uma gota formada por pixels. Ele receberá um PNG, desmontará o arquivo com cuidado e devolverá uma imagem observável.

O nome também ajuda durante a leitura. Em vez de dizer “o parser” ou “o programa” o tempo inteiro, podemos perguntar: o que o Pingo já sabe neste ponto? Primeiro, apenas reconhecer a assinatura. Depois, atravessar chunks. Por fim, reconstruir pixels. Essa progressão é exatamente a que seguiremos no código.

O que o Pingo produzirá no final?

Ler os pixels corretamente não adianta se não conseguirmos verificar o resultado. Poderíamos abrir uma janela gráfica, mas isso exigiria explicar sistema de janelas, eventos e bibliotecas que não fazem parte do PNG. Em vez disso, o Pingo salvará os pixels em outro formato de imagem, muito mais simples: PPM.

PPM significa Portable Pixmap, ou mapa de pixels portátil. Na variação binária P6, ele possui um pequeno cabeçalho textual com largura, altura e valor máximo de cada canal. Depois vêm os bytes RGB sem compressão. Não existem chunks, filtros nem DEFLATE.

P6
2 1
255
[255, 0, 0] [0, 255, 0]

P6   → variante binária do PPM
2 1  → largura 2, altura 1
255  → maior valor possível de cada canal
...  → pixels RGB em sequência

Portanto, PPM não participa da leitura do PNG. Ele é apenas nossa saída de conferência: o Pingo desmonta o formato complexo e grava os pixels em uma embalagem simples que vários visualizadores conseguem abrir.

PROJETO COMPLETO

O download contém todos os arquivos mostrados abaixo, os testes e as instruções para executar o Pingo.

Baixar o projeto Pingo completo
pingo/
├── Cargo.toml
├── README.md
├── src/
│   ├── lib.rs
│   ├── main.rs
│   ├── error.rs
│   ├── chunk.rs
│   ├── header.rs
│   ├── decoder.rs
│   ├── filters.rs
│   └── image.rs
└── tests/
    └── decode.rs
chunklimites + CRC
headerIHDR
decoderorquestração
filtersscanlines
imagePPM

A ordem importa. Primeiro provamos que um chunk cabe no arquivo. Depois validamos seu CRC. Só então interpretamos seus dados como IHDR ou IDAT. Essa separação evita misturar “há bytes suficientes?” com “o tipo de cor é suportado?”.

01
Cargo.toml

Pacote e dependências

flate2 abre o fluxo zlib; crc32fast valida cada chunk. Não usamos uma biblioteca de PNG, porque a estrutura do formato é justamente o que queremos construir.

[package]
name = "pingo"
version = "0.1.0"
edition = "2024"

[dependencies]
crc32fast = "1.5"
flate2 = "1.1"

[lib]
name = "pingo"
path = "src/lib.rs"

[[bin]]
name = "pingo"
path = "src/main.rs"
02
src/lib.rs

API pública

Os módulos separam leitura binária, cabeçalho, filtros, imagem e erros. decode é a única porta de entrada necessária para quem usa a biblioteca.

pub mod chunk;
pub mod decoder;
pub mod error;
pub mod filters;
pub mod header;
pub mod image;

pub use decoder::decode;
pub use error::PngError;
pub use image::Image;
03
src/error.rs

Falhas explicáveis

Cada entrada malformada vira um erro específico. O parser nunca deve esconder um arquivo truncado atrás de um panic ou continuar depois de um CRC inválido.

use std::fmt;

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PngError {
    InvalidSignature,
    Truncated(&'static str),
    InvalidChunkType,
    ChunkTooLarge(u32),
    InvalidCrc { chunk: String },
    MissingIhdr,
    MissingIdat,
    MissingIend,
    DuplicateIhdr,
    InvalidDimensions,
    UnsupportedBitDepth(u8),
    UnsupportedColorType(u8),
    UnsupportedCompression(u8),
    UnsupportedFilterMethod(u8),
    UnsupportedInterlace(u8),
    InvalidFilter(u8),
    InvalidDataLength { expected: usize, actual: usize },
    AllocationTooLarge,
    Inflate(String),
    Io(String),
}

impl fmt::Display for PngError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::InvalidSignature => write!(f, "assinatura PNG inválida"),
            Self::Truncated(part) => write!(f, "arquivo truncado ao ler {part}"),
            Self::InvalidChunkType => write!(f, "tipo de chunk inválido"),
            Self::ChunkTooLarge(size) => write!(f, "chunk grande demais: {size} bytes"),
            Self::InvalidCrc { chunk } => write!(f, "CRC inválido no chunk {chunk}"),
            Self::MissingIhdr => write!(f, "chunk IHDR ausente"),
            Self::MissingIdat => write!(f, "chunk IDAT ausente"),
            Self::MissingIend => write!(f, "chunk IEND ausente"),
            Self::DuplicateIhdr => write!(f, "mais de um chunk IHDR"),
            Self::InvalidDimensions => write!(f, "largura e altura devem ser maiores que zero"),
            Self::UnsupportedBitDepth(value) => {
                write!(f, "profundidade de bits não suportada: {value}")
            }
            Self::UnsupportedColorType(value) => {
                write!(f, "tipo de cor não suportado: {value}")
            }
            Self::UnsupportedCompression(value) => {
                write!(f, "método de compressão não suportado: {value}")
            }
            Self::UnsupportedFilterMethod(value) => {
                write!(f, "método de filtro não suportado: {value}")
            }
            Self::UnsupportedInterlace(value) => {
                write!(f, "entrelaçamento não suportado: {value}")
            }
            Self::InvalidFilter(value) => write!(f, "filtro de scanline inválido: {value}"),
            Self::InvalidDataLength { expected, actual } => {
                write!(f, "dados inconsistentes: esperados {expected} bytes, recebidos {actual}")
            }
            Self::AllocationTooLarge => write!(f, "imagem excede o limite seguro de memória"),
            Self::Inflate(message) => write!(f, "falha ao descompactar zlib: {message}"),
            Self::Io(message) => write!(f, "erro de entrada ou saída: {message}"),
        }
    }
}

impl std::error::Error for PngError {}

impl From<std::io::Error> for PngError {
    fn from(error: std::io::Error) -> Self {
        Self::Io(error.to_string())
    }
}
04
src/chunk.rs

Leitor de chunks

read_chunk lê tamanho, tipo, dados e CRC. checked_add e get protegem os limites; MAX_CHUNK_SIZE impede que um tamanho hostil provoque uma alocação absurda.

use crate::PngError;

pub const SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n";
pub const MAX_CHUNK_SIZE: u32 = 64 * 1024 * 1024;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Chunk<'a> {
    pub kind: [u8; 4],
    pub data: &'a [u8],
    pub offset: usize,
}

impl Chunk<'_> {
    pub fn name(&self) -> String {
        String::from_utf8_lossy(&self.kind).into_owned()
    }
}

pub fn read_chunk<'a>(bytes: &'a [u8], cursor: &mut usize) -> Result<Chunk<'a>, PngError> {
    let offset = *cursor;
    let length_bytes = take(bytes, cursor, 4, "tamanho do chunk")?;
    let length = u32::from_be_bytes(length_bytes.try_into().unwrap());
    if length > MAX_CHUNK_SIZE {
        return Err(PngError::ChunkTooLarge(length));
    }

    let kind_slice = take(bytes, cursor, 4, "tipo do chunk")?;
    if !kind_slice.iter().all(u8::is_ascii_alphabetic) {
        return Err(PngError::InvalidChunkType);
    }
    let kind: [u8; 4] = kind_slice.try_into().unwrap();
    let data = take(bytes, cursor, length as usize, "dados do chunk")?;
    let stored_crc = u32::from_be_bytes(
        take(bytes, cursor, 4, "CRC do chunk")?.try_into().unwrap(),
    );

    let mut crc = crc32fast::Hasher::new();
    crc.update(&kind);
    crc.update(data);
    if crc.finalize() != stored_crc {
        return Err(PngError::InvalidCrc {
            chunk: String::from_utf8_lossy(&kind).into_owned(),
        });
    }

    Ok(Chunk { kind, data, offset })
}

fn take<'a>(
    bytes: &'a [u8],
    cursor: &mut usize,
    amount: usize,
    part: &'static str,
) -> Result<&'a [u8], PngError> {
    let end = cursor
        .checked_add(amount)
        .ok_or(PngError::Truncated(part))?;
    let slice = bytes.get(*cursor..end).ok_or(PngError::Truncated(part))?;
    *cursor = end;
    Ok(slice)
}
05
src/header.rs

Contrato do IHDR

A primeira versão aceita apenas RGB e RGBA, 8 bits e sem entrelaçamento. Qualquer combinação fora desse contrato é rejeitada claramente.

use crate::PngError;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColorType {
    Rgb,
    Rgba,
}

impl ColorType {
    pub fn channels(self) -> usize {
        match self {
            Self::Rgb => 3,
            Self::Rgba => 4,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Header {
    pub width: u32,
    pub height: u32,
    pub bit_depth: u8,
    pub color_type: ColorType,
}

impl Header {
    pub fn parse(data: &[u8]) -> Result<Self, PngError> {
        if data.len() != 13 {
            return Err(PngError::Truncated("IHDR"));
        }

        let width = u32::from_be_bytes(data[0..4].try_into().unwrap());
        let height = u32::from_be_bytes(data[4..8].try_into().unwrap());
        if width == 0 || height == 0 {
            return Err(PngError::InvalidDimensions);
        }
        if data[8] != 8 {
            return Err(PngError::UnsupportedBitDepth(data[8]));
        }
        let color_type = match data[9] {
            2 => ColorType::Rgb,
            6 => ColorType::Rgba,
            value => return Err(PngError::UnsupportedColorType(value)),
        };
        if data[10] != 0 {
            return Err(PngError::UnsupportedCompression(data[10]));
        }
        if data[11] != 0 {
            return Err(PngError::UnsupportedFilterMethod(data[11]));
        }
        if data[12] != 0 {
            return Err(PngError::UnsupportedInterlace(data[12]));
        }

        Ok(Self {
            width,
            height,
            bit_depth: 8,
            color_type,
        })
    }

    pub fn row_bytes(self) -> Result<usize, PngError> {
        let width = usize::try_from(self.width).map_err(|_| PngError::AllocationTooLarge)?;
        width
            .checked_mul(self.color_type.channels())
            .ok_or(PngError::AllocationTooLarge)
    }
}
06
src/filters.rs

Reconstrução das scanlines

Cada byte filtrado é somado ao preditor com wrapping_add. Os cinco filtros oficiais são implementados e a linha anterior já reconstruída serve de contexto.

use crate::PngError;

pub fn reconstruct(
    filtered: &[u8],
    width: usize,
    height: usize,
    bytes_per_pixel: usize,
) -> Result<Vec<u8>, PngError> {
    let row_bytes = width
        .checked_mul(bytes_per_pixel)
        .ok_or(PngError::AllocationTooLarge)?;
    let stride = row_bytes
        .checked_add(1)
        .ok_or(PngError::AllocationTooLarge)?;
    let expected = stride
        .checked_mul(height)
        .ok_or(PngError::AllocationTooLarge)?;
    if filtered.len() != expected {
        return Err(PngError::InvalidDataLength {
            expected,
            actual: filtered.len(),
        });
    }

    let output_len = row_bytes
        .checked_mul(height)
        .ok_or(PngError::AllocationTooLarge)?;
    let mut output = vec![0; output_len];

    for row in 0..height {
        let input_start = row * stride;
        let output_start = row * row_bytes;
        let filter = filtered[input_start];
        let raw = &filtered[input_start + 1..input_start + stride];

        for column in 0..row_bytes {
            let left = if column >= bytes_per_pixel {
                output[output_start + column - bytes_per_pixel]
            } else {
                0
            };
            let up = if row > 0 {
                output[output_start + column - row_bytes]
            } else {
                0
            };
            let upper_left = if row > 0 && column >= bytes_per_pixel {
                output[output_start + column - row_bytes - bytes_per_pixel]
            } else {
                0
            };

            let predictor = match filter {
                0 => 0,
                1 => left,
                2 => up,
                3 => ((u16::from(left) + u16::from(up)) / 2) as u8,
                4 => paeth(left, up, upper_left),
                value => return Err(PngError::InvalidFilter(value)),
            };
            output[output_start + column] = raw[column].wrapping_add(predictor);
        }
    }

    Ok(output)
}

fn paeth(left: u8, up: u8, upper_left: u8) -> u8 {
    let left = i32::from(left);
    let up = i32::from(up);
    let upper_left = i32::from(upper_left);
    let estimate = left + up - upper_left;
    let distance_left = (estimate - left).abs();
    let distance_up = (estimate - up).abs();
    let distance_upper_left = (estimate - upper_left).abs();

    if distance_left <= distance_up && distance_left <= distance_upper_left {
        left as u8
    } else if distance_up <= distance_upper_left {
        up as u8
    } else {
        upper_left as u8
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn reconstructs_sub_filter() {
        let filtered = [1, 10, 20, 30, 5, 5, 5];
        let pixels = reconstruct(&filtered, 2, 1, 3).unwrap();
        assert_eq!(pixels, [10, 20, 30, 15, 25, 35]);
    }

    #[test]
    fn rejects_unknown_filter() {
        assert_eq!(
            reconstruct(&[9, 0, 0, 0], 1, 1, 3),
            Err(PngError::InvalidFilter(9))
        );
    }
}
07
src/image.rs

Pixels e saída PPM

PPM mantém a etapa final observável: cabeçalho textual seguido por RGB bruto. Em RGBA, o alpha é ignorado somente na exportação, não na decodificação.

use std::{fs, path::Path};

use crate::{header::ColorType, PngError};

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Image {
    pub width: u32,
    pub height: u32,
    pub color_type: ColorType,
    pub pixels: Vec<u8>,
}

impl Image {
    pub fn save_ppm(&self, path: impl AsRef<Path>) -> Result<(), PngError> {
        let pixel_count = usize::try_from(self.width)
            .ok()
            .and_then(|width| {
                usize::try_from(self.height)
                    .ok()
                    .and_then(|height| width.checked_mul(height))
            })
            .ok_or(PngError::AllocationTooLarge)?;
        let mut ppm = format!("P6\n{} {}\n255\n", self.width, self.height).into_bytes();
        ppm.reserve(pixel_count.checked_mul(3).ok_or(PngError::AllocationTooLarge)?);

        for pixel in self.pixels.chunks_exact(self.color_type.channels()) {
            ppm.extend_from_slice(&pixel[..3]);
        }
        fs::write(path, ppm)?;
        Ok(())
    }
}
08
src/decoder.rs

Orquestração segura

O decoder exige IHDR, IDAT e IEND, concatena todos os IDAT, limita a saída descompactada e só então remove os filtros.

use std::io::Read;

use flate2::read::ZlibDecoder;

use crate::{
    chunk::{read_chunk, SIGNATURE},
    filters,
    header::Header,
    Image, PngError,
};

pub fn decode(bytes: &[u8]) -> Result<Image, PngError> {
    if bytes.get(..SIGNATURE.len()) != Some(SIGNATURE) {
        return Err(PngError::InvalidSignature);
    }

    let mut cursor = SIGNATURE.len();
    let mut header = None;
    let mut compressed = Vec::new();
    let mut saw_iend = false;

    while cursor < bytes.len() {
        let chunk = read_chunk(bytes, &mut cursor)?;
        match &chunk.kind {
            b"IHDR" => {
                if header.is_some() {
                    return Err(PngError::DuplicateIhdr);
                }
                if chunk.offset != SIGNATURE.len() {
                    return Err(PngError::MissingIhdr);
                }
                header = Some(Header::parse(chunk.data)?);
            }
            b"IDAT" => compressed.extend_from_slice(chunk.data),
            b"IEND" => {
                if !chunk.data.is_empty() {
                    return Err(PngError::InvalidDataLength {
                        expected: 0,
                        actual: chunk.data.len(),
                    });
                }
                saw_iend = true;
                break;
            }
            _ => {}
        }
    }

    let header = header.ok_or(PngError::MissingIhdr)?;
    if compressed.is_empty() {
        return Err(PngError::MissingIdat);
    }
    if !saw_iend {
        return Err(PngError::MissingIend);
    }

    let row_bytes = header.row_bytes()?;
    let height = usize::try_from(header.height).map_err(|_| PngError::AllocationTooLarge)?;
    let expected = row_bytes
        .checked_add(1)
        .and_then(|stride| stride.checked_mul(height))
        .ok_or(PngError::AllocationTooLarge)?;
    if expected > 512 * 1024 * 1024 {
        return Err(PngError::AllocationTooLarge);
    }

    let mut inflated = Vec::with_capacity(expected);
    ZlibDecoder::new(compressed.as_slice())
        .take((expected + 1) as u64)
        .read_to_end(&mut inflated)
        .map_err(|error| PngError::Inflate(error.to_string()))?;
    let pixels = filters::reconstruct(
        &inflated,
        usize::try_from(header.width).map_err(|_| PngError::AllocationTooLarge)?,
        height,
        header.color_type.channels(),
    )?;

    Ok(Image {
        width: header.width,
        height: header.height,
        color_type: header.color_type,
        pixels,
    })
}
09
src/main.rs

Utilitário de terminal

O executável lê o arquivo, chama a biblioteca, mostra as dimensões e grava saida.ppm. A lógica do formato continua testável fora do terminal.

use std::{env, fs, process};

use pingo::decode;

fn main() {
    if let Err(error) = run() {
        eprintln!("erro: {error}");
        process::exit(1);
    }
}

fn run() -> Result<(), Box<dyn std::error::Error>> {
    let mut args = env::args().skip(1);
    let input = args.next().ok_or("uso: pingo <entrada.png> [saida.ppm]")?;
    let output = args.next().unwrap_or_else(|| "saida.ppm".to_owned());
    if args.next().is_some() {
        return Err("argumentos demais".into());
    }

    let bytes = fs::read(&input)?;
    let image = decode(&bytes)?;
    println!(
        "{} × {} pixels, {:?}, {} bytes reconstruídos",
        image.width,
        image.height,
        image.color_type,
        image.pixels.len()
    );
    image.save_ppm(&output)?;
    println!("imagem salva em {output}");
    Ok(())
}
10
tests/decode.rs

PNG mínimo gerado nos testes

Os testes constroem PNGs RGB e RGBA válidos byte por byte, além de corromper assinatura, CRC, tipo de cor e truncar chunks.

use std::io::Write;

use flate2::{write::ZlibEncoder, Compression};
use pingo::{decode, PngError};

fn chunk(kind: &[u8; 4], data: &[u8]) -> Vec<u8> {
    let mut bytes = Vec::new();
    bytes.extend_from_slice(&(data.len() as u32).to_be_bytes());
    bytes.extend_from_slice(kind);
    bytes.extend_from_slice(data);
    let mut crc = crc32fast::Hasher::new();
    crc.update(kind);
    crc.update(data);
    bytes.extend_from_slice(&crc.finalize().to_be_bytes());
    bytes
}

fn png(color_type: u8, scanlines: &[u8]) -> Vec<u8> {
    let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
    encoder.write_all(scanlines).unwrap();
    let compressed = encoder.finish().unwrap();

    let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec();
    let mut ihdr = Vec::new();
    ihdr.extend_from_slice(&2_u32.to_be_bytes());
    ihdr.extend_from_slice(&1_u32.to_be_bytes());
    ihdr.extend_from_slice(&[8, color_type, 0, 0, 0]);
    bytes.extend(chunk(b"IHDR", &ihdr));
    bytes.extend(chunk(b"IDAT", &compressed));
    bytes.extend(chunk(b"IEND", &[]));
    bytes
}

#[test]
fn decodes_rgb_png() {
    let bytes = png(2, &[0, 255, 0, 0, 0, 255, 0]);
    let image = decode(&bytes).unwrap();
    assert_eq!((image.width, image.height), (2, 1));
    assert_eq!(image.pixels, [255, 0, 0, 0, 255, 0]);
}

#[test]
fn decodes_rgba_png() {
    let bytes = png(6, &[0, 255, 0, 0, 128, 0, 0, 255, 255]);
    let image = decode(&bytes).unwrap();
    assert_eq!(image.pixels, [255, 0, 0, 128, 0, 0, 255, 255]);
}

#[test]
fn rejects_invalid_signature() {
    assert_eq!(decode(b"not a png"), Err(PngError::InvalidSignature));
}

#[test]
fn rejects_truncated_chunk() {
    let mut bytes = png(2, &[0, 1, 2, 3, 4, 5, 6]);
    bytes.truncate(20);
    assert!(matches!(decode(&bytes), Err(PngError::Truncated(_))));
}

#[test]
fn rejects_invalid_crc() {
    let mut bytes = png(2, &[0, 1, 2, 3, 4, 5, 6]);
    bytes[29] ^= 1;
    assert!(matches!(decode(&bytes), Err(PngError::InvalidCrc { .. })));
}

#[test]
fn rejects_unsupported_color_type() {
    let bytes = png(3, &[0, 0, 1]);
    assert_eq!(decode(&bytes), Err(PngError::UnsupportedColorType(3)));
}

Leitura guiada: o cursor é a fronteira entre o arquivo e o programa

Repare que read_chunk não recebe um arquivo aberto. Ele recebe uma fatia imutável com todos os bytes e um cursor que representa a próxima posição. Essa escolha deixa a função determinística: para os mesmos bytes e o mesmo cursor, o resultado é sempre igual. Também simplifica os testes, porque não precisamos criar arquivos temporários para exercitar cada fronteira.

A função take concentra a regra mais repetida de um parser binário. Primeiro calcula o fim com checked_add. Depois pede a região comget. Somente se as duas operações funcionarem o cursor avança. Imagine o caso contrário: avançar primeiro e descobrir depois que faltavam bytes deixaria o estado parcialmente modificado. O erro seguinte apontaria para a posição errada e tornaria o diagnóstico confuso.

O comprimento do chunk chega como u32, pois esse é o campo definido pelo PNG. Para indexar memória, convertemos para usize. Antes disso, aplicamos um limite didático de 64 MiB por chunk. O formato permite valores maiores, mas um programa pode impor limites coerentes com seu produto. Um gerador de miniaturas, por exemplo, provavelmente deve aceitar menos memória que um editor profissional.

Por que validar o CRC antes de interpretar o conteúdo?

O CRC inclui os quatro bytes do tipo e todos os dados, mas não inclui o campo de tamanho. Isso significa que a sequência usada no cálculo para IHDR começa em49 48 44 52 e continua pelos treze bytes do cabeçalho. Se um único bit da largura mudar durante a transmissão, o valor calculado tende a divergir do valor armazenado no final do chunk.

Interpretar primeiro e validar depois é perigoso. Uma largura corrompida pode participar de multiplicações e alocações antes que o programa perceba a falha. Nosso leitor calcula o CRC enquanto ainda enxerga o conteúdo apenas como bytes. Só um chunk íntegro pode chegar a Header::parse. A ordem das validações também faz parte da arquitetura de segurança.

O CRC de 32 bits tem colisões possíveis e não conhece nenhuma chave secreta. Portanto, ele responde “estes bytes provavelmente mudaram sem querer?”, não “quem produziu estes bytes?”. Uma assinatura digital resolveria outro problema: provar autoria e impedir alterações não autorizadas.

Como o IHDR define o tamanho exato da saída descompactada

Depois de conhecer largura, altura e canais, já sabemos quantos bytes devem aparecer após o zlib. Em RGB de 8 bits, cada linha temlargura × 3 bytes de pixels mais um byte de filtro. Em RGBA, usamoslargura × 4 + 1. Multiplicamos esse stride pela altura e obtemos um tamanho exato, não uma estimativa.

Essa conta é importante para correção e segurança. Se o fluxo terminar antes, faltam pixels. Se produzir bytes depois do final previsto, existe conteúdo inconsistente ou uma tentativa de expansão excessiva. O adaptadortake(expected + 1) permite ler no máximo um byte além do esperado: esse byte extra é suficiente para provar que o fluxo não respeita o cabeçalho sem permitir crescimento ilimitado da memória.

Todas as multiplicações usam operações verificadas. Em uma máquina de 64 bits é difícil estourar usize com dimensões comuns, mas o arquivo é uma entrada controlável. Segurança não deve depender da expectativa de que “ninguém enviará um número tão grande”.

Reconstruindo um filtro byte por byte

A variável raw contém o que veio do fluxo descompactado, ainda filtrado. output contém linhas já reconstruídas. Para cada posição, buscamos até três vizinhos: left, up eupper_left. Quando o vizinho não existe — primeira linha ou começo da linha — seu valor é zero, exatamente como determina o formato.

No filtro Sub, o primeiro pixel não possui vizinho à esquerda. Em RGB, seus três canais usam zero como preditor. No segundo pixel, o canal vermelho olha três posições para trás e encontra o vermelho anterior; verde encontra verde; azul encontra azul. Usar simplesmente column - 1 misturaria canais e produziria cores erradas.

A soma usa wrapping_add porque o cálculo acontece módulo 256. Se o byte filtrado for 250 e o preditor for 10, o resultado matemático 260 volta a 4. Isso não é um overflow acidental: é parte do contrato reversível para amostras de 8 bits. Usar uma soma que causa panic em debug quebraria PNGs perfeitamente válidos.

Average promove os operandos para u16 antes da soma, evitando perder o nono bit, e divide o resultado inteiro por dois. Paeth promove parai32 porque trabalha com diferenças que podem ser negativas. Esses tipos intermediários mostram uma regra geral: o tipo usado no arquivo nem sempre é o melhor tipo para fazer a conta.

Por que exportar PPM em vez de abrir uma janela?

Depois de reconstruir RGB ou RGBA, poderíamos usar uma biblioteca gráfica para criar uma janela. Isso acrescentaria eventos, sistema de janelas e diferenças entre plataformas ao momento em que queremos apenas confirmar os pixels. O PPM no modo P6 exige um cabeçalho curto e os canais RGB em sequência.

Para uma imagem RGBA, a estrutura Image preserva os quatro canais. Somente save_ppm ignora alpha, porque PPM não o representa. Essa perda fica localizada na saída de demonstração; o decoder continua correto. Uma evolução poderia combinar o pixel com um fundo escolhido pelo usuário ou gerar PAM, que aceita alpha.

Os testes constroem o arquivo que o leitor precisa desmontar

A função auxiliar chunk dos testes é um mini escritor: grava o tamanho em big-endian, o tipo, os dados e o CRC. A função png monta assinatura, IHDR, IDAT e IEND. Como o arquivo nasce dentro do teste, sabemos exatamente quais pixels devem sair do outro lado.

O teste RGB usa dois pixels: vermelho e verde. O byte zero no início da scanline seleciona o filtro None. O teste RGBA acrescenta valores de alpha diferentes. Depois vêm mutações controladas: assinatura incorreta, arquivo cortado, um bit do CRC trocado e tipo de cor não suportado. Um bom teste de parser não verifica apenas o caminho feliz; ele prova que entradas ruins falham pelo motivo correto.

O Pingo agora funciona no caminho esperado. Mas arquivos não chegam apenas em condições perfeitas: podem estar incompletos, corrompidos ou até preparados para consumir recursos demais. Antes de executá-lo, precisamos entender por que cada limite do código existe.

13 / ARQUIVOS SÃO ENTRADAS NÃO CONFIÁVEIS

Um parser seguro precisa desconfiar de cada tamanho antes de alocar ou avançar.

O campo de tamanho pertence ao próprio arquivo. Um atacante pode escrever quatro bytes dizendo que o próximo chunk tem gigabytes. Antes de somar cursor + tamanho usamos operações verificadas; antes de cortar uma fatia usamos get; antes de inflar calculamos o tamanho exato esperado.

Overflowchecked_add e checked_mul recusam contas que não cabem.
Chunk giganteUm limite explícito evita consumo arbitrário de memória.
Expansão maliciosaUm conteúdo compacto não pode crescer sem limite ao ser descompactado.
TruncamentoNenhuma fatia é criada sem provar início e fim.
CRC inválidoOs dados não são interpretados depois de falhar a integridade.
Formato parcialIHDR, IDAT e IEND são obrigatórios e validados.

Nosso escopo também é uma barreira de segurança: tipos de cor, profundidades e Adam7 não implementados falham cedo. “Tentar adivinhar” cria decoders permissivos que componentes diferentes interpretam de maneiras diferentes.

Por que cada erro tem um nome próprio?

Retornar apenas “arquivo inválido” facilita escrever o código, mas dificulta aprender e corrigir problemas. InvalidSignature diz que nem sequer entramos no contêiner. InvalidCrc identifica corrupção em um chunk.UnsupportedColorType informa que o arquivo pode ser perfeitamente válido, mas está fora do escopo atual do Pingo.

Essa distinção também muda decisões do programa. Um serviço pode rejeitar uma assinatura inválida imediatamente, encaminhar um tipo ainda não suportado para outro decoder e registrar corrupção como possível falha de armazenamento. Tipos de erro não existem apenas para produzir mensagens bonitas; eles preservam informação sobre o ponto em que a confiança foi quebrada.

A ordem completa das defesas

01Assinaturaprova que os oito primeiros bytes são PNG
02Limite do chunkprova que tamanho, tipo, dados e CRC cabem no arquivo
03CRCprova integridade antes da interpretação
04IHDRprova dimensões, profundidade, cor e métodos suportados
05Tamanho esperadolimita alocação e expansão do zlib
06Scanlinesprova filtros e quantidade exata de bytes

Cada etapa reduz a incerteza. Antes da assinatura, temos bytes quaisquer. Depois do IHDR, sabemos qual formato de pixel esperar. Depois do inflate, ainda não temos pixels: temos linhas filtradas que precisam respeitar o stride calculado. Segurança nasce dessa progressão, não de uma verificação única no começo.

Inflate é o nome tradicional da operação que desfaz DEFLATE; aqui ele significa simplesmente descompactar. Stride é a quantidade de bytes ocupada por uma linha completa — no fluxo do PNG, os canais da linha mais o byte inicial do filtro.

Com o caminho feliz e as falhas compreendidos, finalmente podemos usar o programa como um leitor faria no dia a dia: fornecer um PNG, reconstruir os pixels e abrir o resultado.

14 / PROJETO FINAL

Um comando transforma arquivo.png em pixels reconstruídos e uma saída que qualquer visualizador PPM abre.

Agora o nome PPM não é mais uma surpresa: ele é o formato simples usado para observar os pixels que recuperamos. Baixe o projeto completo, extraia a pasta e abra o terminal dentro dela.

Baixar o código completo do Pingo
cd pingo
cargo test
cargo run -- imagem.png saida.ppm

O programa mostra dimensões, tipo de cor e quantidade de bytes. Depois grava PPM no modo P6: uma linha de identificação, largura, altura, valor máximo 255 e os canais RGB brutos. Essa saída simples confirma que chegamos aos pixels sem introduzir outra biblioteca de imagem.

01arquivo.pngbytes externos
02chunksestrutura validada
03inflatescanlines
04unfilterRGB / RGBA
05saida.ppmimagem visível

O que ainda não suportamos

Imagens em tons de cinza, paleta PLTE, 1, 2, 4 ou 16 bits, transparência por tRNS, Adam7, gamma, perfis de cor e APNG ficam fora da versão didática. Um decoder de produção também deve aplicar regras completas de ordem dos chunks e gerenciamento de cores.

Acompanhe uma execução completa do Pingo

Suponha um PNG RGB de 2 × 1. fs::read carrega o arquivo. A assinatura é aceita. O primeiro chunk tem tipo IHDR e treze bytes; largura vira dois, altura vira um, profundidade é oito e tipo de cor é dois. Com isso, calculamosrow_bytes = 2 × 3 = 6 e esperamos sete bytes após o inflate: um filtro e seis canais.

O loop encontra IDAT e acrescenta seus dados ao vetor comprimido. Se outro IDAT aparecer, seus bytes entram logo depois. Ao chegar em IEND, a estrutura externa terminou. O zlib devolve 00 FF 00 00 00 FF 00. O primeiro zero seleciona None; os seis bytes restantes viram vermelho e verde.

A estrutura Image recebe largura 2, altura 1, tipo RGB e o vetor[255, 0, 0, 0, 255, 0]. save_ppm escreve o cabeçalhoP6\n2 1\n255\n e anexa exatamente esses canais. Um visualizador PPM desenha dois pixels. Nada nessa última etapa precisa conhecer chunks, CRC ou filtros: essas camadas já cumpriram seu trabalho.

imagem.png
  ↓ fs::read
bytes do arquivo
  ↓ assinatura + chunks + CRC
IHDR = 2 × 1 RGB
IDAT = fluxo zlib
  ↓ inflate
00 | FF 00 00 | 00 FF 00
  ↓ filtro None
[vermelho, verde]
  ↓ save_ppm
saida.ppm

Como abrir a saída

Muitos editores e ferramentas como ImageMagick, GIMP e visualizadores de imagem entendem PPM. O arquivo é deliberadamente simples e tende a ser maior que o PNG, pois guarda RGB sem compressão. Isso é esperado: PPM é nosso microscópio para verificar o resultado, não o formato final de distribuição.

Próximos experimentos

01 / PLTETransforme índices em RGB.
02 / ENCODERFaça o caminho inverso.
03 / FILTROSCompare o tamanho produzido por cada um.
04 / FUZZINGGere entradas malformadas automaticamente.

15 / CURIOSIDADES

O PNG é simples de reconhecer, mas cheio de decisões de engenharia.

SEM PERDAS

O pixel volta igual

Filtros e DEFLATE são reversíveis. Abrir e salvar não precisa introduzir artefatos como blocos ou halos.

SCREENSHOTS

Repetição ajuda

Interfaces têm fundos uniformes, texto e bordas. Predição e compressão encontram muitos padrões.

FOTOGRAFIAS

Ruído custa caro

Pequenas variações naturais tornam diferenças menos repetitivas; JPEG costuma ser mais econômico nesse caso.

IDAT

A divisão é logística

Dez chunks IDAT ainda carregam um único fluxo zlib. O corte não separa linhas.

METADADOS

Chunks podem crescer

Chunks auxiliares permitem texto, gamma, perfis e informações que decoders antigos podem ignorar com segurança.

UM PIXEL

Muitos bytes podem mudar

Alterar uma amostra muda a predição e pode reorganizar a representação comprimida, embora o restante visual permaneça igual.

16 / FONTES TÉCNICAS

O projeto simplifica o suporte, não as regras que implementa.

Os campos, filtros, ordem crítica e CRC seguem a especificação PNG, terceira edição. O envelope zlib e o algoritmo DEFLATE são definidos pelos RFC 1950 e RFC 1951.

17 / CONCLUSÃO

Decodificar uma imagem é transformar significado em etapas pequenas e verificáveis.

01Pixels
02Canais
03Scanlines
04Filtros + compressão
05Chunks PNG

No arquivo, percorremos essa seta de baixo para cima. A assinatura confirma a família; chunks oferecem fronteiras; IHDR define o contrato; IDAT fornece o fluxo; zlib devolve scanlines; os filtros restauram canais; largura e altura reorganizam tudo como imagem.

É a mesma filosofia dos projetos anteriores: reduzir uma tecnologia real a peças que podemos observar, implementar e testar — sem fingir que sua versão industrial é pequena.

18 / FAQ

Perguntas frequentes sobre PNG, chunks e compressão.

PNG usa compressão com perdas?

Não. O PNG preserva exatamente os valores dos pixels. Seus filtros e a compressão DEFLATE mudam a representação dos bytes, não a informação visual.

PNG e zlib são a mesma coisa?

Não. PNG é o formato de imagem. Dentro dos chunks IDAT existe um fluxo zlib, que por sua vez transporta dados comprimidos com DEFLATE.

O que fica dentro do IDAT?

Partes de um único fluxo zlib contendo todas as scanlines filtradas. As fronteiras entre chunks IDAT não representam linhas ou pixels.

Por que podem existir vários chunks IDAT?

Para permitir escrita e leitura em blocos. O decoder concatena seus dados na ordem e trata o resultado como um único fluxo comprimido.

O que acontece se o CRC estiver errado?

O arquivo sofreu alteração ou corrupção. Um decoder cuidadoso interrompe a leitura do chunk antes de confiar em seus dados.

PNG sempre usa RGBA?

Não. O formato também aceita tons de cinza, RGB, paleta indexada e combinações com alpha, em diferentes profundidades permitidas.

Por que fotografias costumam ficar grandes em PNG?

Fotos têm variações e ruído que reduzem repetições previsíveis. Formatos com perdas, como JPEG, podem descartar detalhes pouco perceptíveis e comprimir melhor esse conteúdo.

É possível abrir PNG sem biblioteca pronta?

Sim. Este projeto interpreta o contêiner e os filtros. Ele usa uma biblioteca apenas para zlib/DEFLATE, um algoritmo independente e complexo.

O que é Adam7?

É o método de entrelaçamento do PNG que divide a imagem em sete passagens, permitindo uma prévia progressiva. Nosso leitor rejeita Adam7 explicitamente.

APNG é um formato completamente diferente?

APNG amplia a estrutura de chunks do PNG para guardar quadros e temporização, preservando a compatibilidade com decoders PNG que ignoram chunks auxiliares desconhecidos.

O que este artigo fez você pensar?

Dúvidas, experiências e contrapontos ajudam a próxima pessoa a enxergar o assunto por outro ângulo.

Todos passam por moderação. Ao enviar, você concorda com a política de privacidade.

Receba os próximos artigos.

Uma mensagem quando uma nova investigação estiver pronta. Só isso.