1

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:

  1. Ler o arquivo com createImageBitmap, usando imageOrientation: "from-image" para respeitar a rotação EXIF. Sem isso, foto de celular aparece deitada.
  2. Gerar uma prévia pequena, com no máximo 1200 px no maior lado. O bitmap original fica inteiro para o corte.
  3. Planejar a grade com coordenadas inteiras antes de desenhar qualquer coisa.
  4. Para cada parte, criar um OffscreenCanvas do tamanho exato da célula, desenhar o recorte e chamar convertToBlob.
  5. Entregar os blobs para a thread principal, que cria os downloads, o ZIP ou o PDF.
  6. 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 ImageBitmap assim 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.

Carregando publicação patrocinada...