Pitch: O que aprendi construindo um divisor de imagens 100% no navegador
Canvas, Web Workers e matemática de pixels: os limites que aparecem quando o navegador vira o backend.
Quando comecei a construir o dividirimagem.com, a interface parecia a parte difícil. Na verdade, ela foi a mais simples. O problema era fazer o navegador cortar uma imagem de 12 megapixels em nove partes sem travar a aba, sem perder pixels e sem enviar nada para lugar nenhum.
Este texto não é um tutorial de Canvas. É um relato das decisões técnicas, dos limites que encontrei e do que ainda não está resolvido. Se você já tentou processar imagens no cliente, provavelmente vai reconhecer alguns desses problemas.
O fluxo que funcionou
O caminho feliz é este:
- Ler o arquivo com
createImageBitmap, usandoimageOrientation: "from-image"para respeitar a rotação EXIF. Sem isso, foto de celular aparece deitada. - Gerar uma prévia pequena, com no máximo 1200 px no maior lado. O bitmap original fica inteiro para o corte.
- Planejar a grade com coordenadas inteiras antes de desenhar qualquer coisa.
- Para cada parte, criar um
OffscreenCanvasdo tamanho exato da célula, desenhar o recorte e chamarconvertToBlob. - Entregar os blobs para a thread principal, que cria os downloads, o ZIP ou o PDF.
- Fechar o bitmap, encerrar o worker e revogar as URLs de objeto.
A parte do worker não é só performance. Decodificar e recodificar imagens grandes na thread principal trava a interface: o usuário não consegue nem clicar em “cancelar”. Com Web Workers e OffscreenCanvas, o corte acontece fora da thread de renderização.
Ainda assim, mantive um caminho de fallback na thread principal para navegadores que não conseguem criar o worker. Ele é mais lento e avisa o usuário, mas evita uma tela morta.
O corte em si é drawImage com retângulo de origem. Nada de WebAssembly ou OpenCV.js para uma operação que o Canvas já faz.
Pixels não se dividem no meio
Aqui está a parte que parece trivial e não é. Se você dividir uma imagem de 1001 px em três colunas e usar 1001 / 3 em cada uma, vai obter 333,67 px. Não existe fração de pixel.
Arredondar cada parte para 333 px deixa 2 px de fora; arredondar para 334 px cria sobreposição. A regra que usei foi calcular as bordas, não as larguras:
const x0 = Math.floor((column * width) / columns)
const x1 = Math.floor(((column + 1) * width) / columns)
const tileWidth = x1 - x0
Para 1001 px e 3 colunas, as bordas ficam em 0, 333, 667 e 1001. As larguras são 333, 334 e 334. Nenhum pixel fica de fora e nenhum aparece em duas partes.
A diferença de 1 px é o preço de preservar a imagem inteira. O alternativo seria descartar ou duplicar pixels para fingir que todas as partes têm exatamente o mesmo tamanho.
O mesmo vale para as linhas. E o último corte sempre termina na borda da imagem, porque a última fronteira é calculada com o índice final da grade.
O limite real não é o Canvas. É a memória.
Meu primeiro instinto foi aceitar qualquer imagem. O segundo foi perceber que 16 megapixels já são cerca de 64 MB se o bitmap for mantido em RGBA. Durante o corte, você tem o bitmap original, o canvas da parte, o blob codificado e, dependendo do caminho, o ZIP montado. O pico de memória é várias vezes o tamanho do arquivo.
Por isso, o limite inicial é conservador: 16 megapixels por imagem, até 10 linhas e 10 colunas, no máximo 100 partes. Cada parte precisa ter pelo menos 1 px. Não é uma promessa de que todo dispositivo aguenta; é uma proteção para falhar com uma mensagem clara em vez de matar a aba.
O que ajuda de verdade:
- fechar o
ImageBitmapassim que ele não é mais necessário; - revogar URLs de objeto com
URL.revokeObjectURL()depois do download; - cancelar tarefas antigas quando o usuário troca de imagem;
- encerrar o worker em vez de deixá-lo vivo entre imagens;
- limitar a prévia, para não redimensionar a imagem inteira várias vezes.
Ainda assim, o mesmo arquivo pode funcionar em um desktop e falhar em um celular de entrada. Limitar por pixels é mais previsível do que limitar por MB, mas não elimina a variação de memória e de implementação de Canvas entre navegadores.
ZIP e PDF sem biblioteca
Duas decisões que parecem teimosia, mas fizeram sentido no escopo.
O ZIP usa STORE, ou seja, sem compressão. JPEG, PNG e WebP já são formatos comprimidos; aplicar deflate de novo gasta CPU e raramente reduz o tamanho. Como o ZIP só precisa empacotar arquivos já codificados, escrever os cabeçalhos e o CRC32 na mão foi suficiente. Isso também deixa o download mais previsível em dispositivos lentos.
O PDF segue a mesma lógica. O arquivo é PDF 1.4, com uma página por folha, MediaBox no tamanho físico de A4 ou A3 e a imagem embutida como JPEG via DCTDecode. Não usei pdf-lib. Isso mantém o bundle menor e o controle sobre o tamanho da página direto. O custo é manter código de baixo nível e testar cada combinação de papel, orientação e margem.
Não estou dizendo que todo mundo deveria escrever ZIP e PDF na mão. Se o produto precisa de fontes, compressão, metadados, assinatura ou formulários, uma biblioteca provavelmente é a escolha certa. No meu caso, o formato era pequeno e central; escrever o mínimo necessário me deu menos dependências e mais controle.
O que ainda não está resolvido
Alguns limites são explícitos:
- Formatos de entrada: JPEG, PNG e WebP que o navegador consiga decodificar. HEIC de iPhone nem sempre abre; depende do navegador e do sistema, então o usuário precisa converter antes.
- Qualidade: cortar e exportar em JPEG ou WebP recomprime a imagem. Se o objetivo é fidelidade pixel a pixel, PNG é o caminho. A ferramenta não promete “qualidade original” em formato com perda.
- Escala: não é um processador em lote. É uma imagem por vez, com limite de 16 MP. Para vídeo, dezenas de imagens ou arquivos enormes, o navegador não é o lugar.
- Compatibilidade: não tenho uma matriz de testes cobrindo todos os dispositivos. Os limites são conservadores justamente por isso.
Essa parte é importante: processar no cliente não elimina complexidade. Troca o custo de upload e servidor por custo de memória, compatibilidade e experiência do usuário. Em um utilitário simples, a troca vale a pena. Em um pipeline pesado, provavelmente não.
Se eu começasse de novo, separaria o plano de grade do código de desenho desde o primeiro commit. A parte que mais mudou não foi o Canvas; foi a regra de negócio de quantas partes, qual formato e qual limite.
Privacidade é arquitetura, não slogan
A imagem nunca é enviada para um servidor. O site é estático, construído com Astro, e não tem backend. A hospedagem é de arquivos estáticos — no meu caso, Cloudflare Pages. O editor é uma ilha interativa em React dentro das páginas geradas no build. Não existe endpoint para receber o arquivo, nem conta, nem biblioteca de imagens.
Mas “sem upload de imagem” não é a mesma coisa que “sem nenhum dado”. O site tem analytics. Os eventos que registro são de uso: modo do editor, formato de saída e quantidade de partes. Não incluo a imagem, o nome do arquivo ou o conteúdo. A distinção parece pequena, mas é o tipo de coisa que vale deixar clara em vez de vender uma privacidade absoluta que não existe.
Se você for construir algo parecido, revise também o monitoramento de erros. Um relatório de erro pode capturar contexto demais sem você perceber. Privacidade de verdade é consequência da arquitetura: não ter para onde enviar a imagem é mais forte do que prometer que você não vai enviar.
Vale a pena?
Para um divisor de imagens, sim. O tempo de resposta é menor, não há custo de servidor por arquivo e a imagem não sai do dispositivo. Para um serviço de processamento em lote, com arquivos grandes e requisitos rígidos de qualidade, a resposta provavelmente é não.
O projeto está em dividirimagem. Se você já tentou processar imagens no navegador, quais limites encontrou? Tenho curiosidade principalmente sobre Safari no iOS e Android de entrada: memória, Canvas e workers ainda são terrenos com bastante variação.