FORMATOS BINÁRIOS · IMAGENS · RUST
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.
89 50 4E 47 0D 0A 1A 0A00 00 00 0D 49 48 44 5200 00 00 04 00 00 00 04assinatura · blocos · dados01 / 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.
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]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.
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.
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.
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.
| Canal | Hexadecimal | Decimal | Binário |
|---|---|---|---|
| R | ED | 237 | 11101101 |
| G | 6B | 107 | 01101011 |
| B | 32 | 50 | 00110010 |
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.
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.
| Imagem | Cálculo bruto | Bytes |
|---|---|---|
| Ícone 32 × 32 RGB | 32 × 32 × 3 | 3.072 |
| Foto 1920 × 1080 RGB | 1920 × 1080 × 3 | 6.220.800 |
| Screenshot 1920 × 1080 RGBA | 1920 × 1080 × 4 | 8.294.400 |
| Textura 4096 × 4096 RGBA | 4096 × 4096 × 4 | 67.108.864 |
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.
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.
| Chunk | Papel | Obrigatório? |
|---|---|---|
IHDR | dimensões e formato dos pixels | sim, primeiro |
PLTE | paleta RGB para imagens indexadas | depende do tipo de cor |
IDAT | fluxo zlib com scanlines | sim, um ou mais |
IEND | fim lógico do PNG | sim, último |
tEXt | texto associado à imagem | não |
gAMA | informação de gamma | nã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.
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.
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.
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.
| Offset | Bytes | Leitura |
|---|---|---|
| 0x08 | 00 00 00 0D | 13 bytes de dados |
| 0x0C | 49 48 44 52 | “IHDR” |
| 0x10 | 13 bytes | cabeçalho da imagem |
| 0x1D | 4 bytes | CRC do tipo + dados |
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 33O 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.
| Campo | Tamanho | Nosso suporte |
|---|---|---|
| Largura | 4 bytes | maior que zero |
| Altura | 4 bytes | maior que zero |
| Profundidade | 1 byte | 8 |
| Tipo de cor | 1 byte | 2 (RGB) ou 6 (RGBA) |
| Compressão | 1 byte | 0 |
| Filtro | 1 byte | 0 |
| Entrelaçamento | 1 byte | 0, 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: desativadoProfundidade é 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.
| Tipo | Significado | Profundidades permitidas | Bytes no nosso caso |
|---|---|---|---|
| 0 | tons de cinza | 1, 2, 4, 8, 16 | não suportado |
| 2 | RGB | 8, 16 | 3 por pixel |
| 3 | paleta indexada | 1, 2, 4, 8 | não suportado |
| 4 | cinza + alpha | 8, 16 | não suportado |
| 6 | RGBA | 8, 16 | 4 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, pretoPaleta 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.
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-32A 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.
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.
010110111O 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:
- 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.
| Valor | Filtro | Preditor |
|---|---|---|
| 0 | None | zero |
| 1 | Sub | byte à esquerda |
| 2 | Up | byte acima |
| 3 | Average | média de esquerda e acima |
| 4 | Paeth | escolhe esquerda, acima ou diagonal |
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 0Filtro 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 256Esse 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 = 108A 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: 100Em 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.
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 IENDMesmo 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.
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ênciaPortanto, 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.
O download contém todos os arquivos mostrados abaixo, os testes e as instruções para executar o Pingo.
pingo/
├── Cargo.toml
├── README.md
├── src/
│ ├── lib.rs
│ ├── main.rs
│ ├── error.rs
│ ├── chunk.rs
│ ├── header.rs
│ ├── decoder.rs
│ ├── filters.rs
│ └── image.rs
└── tests/
└── decode.rsA 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?”.
Cargo.tomlPacote 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"
src/lib.rsAPI 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;
src/error.rsFalhas 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())
}
}
src/chunk.rsLeitor 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)
}
src/header.rsContrato 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)
}
}
src/filters.rsReconstruçã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))
);
}
}
src/image.rsPixels 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(())
}
}
src/decoder.rsOrquestraçã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,
})
}
src/main.rsUtilitá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(())
}
tests/decode.rsPNG 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.
checked_add e checked_mul recusam contas que não cabem.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
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 Pingocd pingo
cargo test
cargo run -- imagem.png saida.ppmO 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.
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.ppmComo 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
15 / CURIOSIDADES
O PNG é simples de reconhecer, mas cheio de decisões de engenharia.
O pixel volta igual
Filtros e DEFLATE são reversíveis. Abrir e salvar não precisa introduzir artefatos como blocos ou halos.
Repetição ajuda
Interfaces têm fundos uniformes, texto e bordas. Predição e compressão encontram muitos padrões.
Ruído custa caro
Pequenas variações naturais tornam diferenças menos repetitivas; JPEG costuma ser mais econômico nesse caso.
A divisão é logística
Dez chunks IDAT ainda carregam um único fluxo zlib. O corte não separa linhas.
Chunks podem crescer
Chunks auxiliares permitem texto, gamma, perfis e informações que decoders antigos podem ignorar com segurança.
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.
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.
A CONVERSA CONTINUA
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.