Artigo Técnico

Lendo a geometria dos cabeçalhos GIF, BMP e JPEG na HotXLS

A HotXLS lê a geometria real em pixels de uma imagem do próprio cabeçalho do arquivo antes de qualquer geometria de drawing ser escrita. GetImageSize em lxImage.pas despacha pela extensão do arquivo para GetJpegSize, GetGifSize, GetBmpSize, GetPngSize ou GetWmfEmfSize, converte pixels para pontos com um fator de 0.75, e retorna -1 quando genuinamente não consegue dizer

Essa última cláusula é a parte que levou mais tempo para acertar. Um leitor de tamanho que não pode falhar é um leitor de tamanho que mente, e uma mentira sobre geometria de imagem não aparece na hora da inserção. Ela aparece semanas depois, quando alguém abre a pasta de trabalho e o logo da empresa é um retângulo esmagado

Por que uma imagem precisa da geometria real antes de entrar no drawing?

Porque o formato de planilha guarda a extensão, não a imagem. Quando AddPicture ou Images.AddFromFile coloca uma imagem, a part de drawing registra uma largura e altura explícitas para a shape — em DrawingML são os atributos cx e cy do elemento a:ext, em English Metric Units, pela ECMA-376 Parte 1 §20.4. Nada nesse registro diz "use o tamanho natural da imagem", então o tamanho natural tem que vir de algum lugar, e o único lugar onde ele existe é nos bytes da imagem. Erre por um fator de três e a imagem fica esticada por um fator de três, em todo aplicativo que abre a pasta de trabalho, para sempre. A conversão de unidades dessas dimensões em pixels para EMU é uma preocupação separada, coberta no artigo sobre unidades EMU e escala; este texto é sobre o passo anterior, onde os números de pixels são obtidos. O fallback quando um leitor falha é 100 por 100, e esse número é deliberadamente, obviamente errado. Não é um palpite de tamanho plausível, é um marcador que significa "ninguém conseguiu ler esse arquivo", e é muito melhor que um número errado de cara plausível porque um usuário reporta na hora

Como GetImageSize na HotXLS despacha pela extensão do arquivo para os leitores GetJpegSize, GetGifSize, GetBmpSize, GetPngSize e GetWmfEmfSize e reporta três resultados: 1 quando a geometria verdadeira foi lida do cabeçalho, -1 quando nada pôde ser lido e o fallback 100 por 100 se aplica, e 7 quando um metafile tira a extensão do registro BSE
GetImageSize distingue três resultados: 1 para geometria lida do cabeçalho, -1 para arquivos ilegíveis onde o fallback 100 por 100 obviamente errado é reportado por usuários, e 7 para metafiles cuja extensão vive no registro BSE

GIF: o descriptor que nunca era analisado

GetGifSize é novo, e antes de ele existir toda GIF inserida numa planilha saía exatamente em 100 por 100 — não havia ramo de GIF nenhum, então a extensão caía no braço default de GetImageSize e retornava -1. É o cabeçalho mais barato do conjunto de analisar, o que torna a omissão levemente constrangedora e muito fácil de corrigir. A especificação GIF89a coloca o Logical Screen Descriptor imediatamente depois da assinatura de seis bytes, e a §18 define os dois primeiros campos dele como largura e altura da tela lógica, cada um um valor unsigned de 16 bits em little-endian. Em termos de arquivo, são os bytes 6 e 7 para largura, bytes 8 e 9 para altura, então uma leitura única de dez bytes cobre a checagem da assinatura e as duas dimensões

function GetGifSize(const AFileName: WideString; var AWidth, AHeight: Double): Integer;
var
  f: TWFileStream;
  Header: array [0.. 9] of Byte;
  wWidth, wHeight: Word;
begin
  Result:= - 1;
  AWidth:= 100;
  AHeight:= 100;
  f:= TWFileStream.Create(AFileName, fmOpenRead);
  try
    // Assinatura "GIF8", depois o Logical Screen Descriptor com largura e
    // altura como words little-endian nos offsets 6 e 8
    if (f.Read(Header[0], SizeOf(Header))= SizeOf(Header))and
      (Header[0]= Ord('G'))and (Header[1]= Ord('I'))and
      (Header[2]= Ord('F'))and (Header[3]= Ord('8')) then
    begin
      wWidth:= Header[6]or (Header[7] shl 8);
      wHeight:= Header[8]or (Header[9] shl 8);
      if (wWidth> 0)and (wHeight> 0) then
      begin
        AWidth:= wWidth* 0.75;
        AHeight:= wHeight* 0.75;
        Result:= 1;
      end;
    end;
  finally
    f.Free;
  end;
end;

Note a forma: os valores de falha são atribuídos primeiro, e o caminho de sucesso é a única coisa que os sobrescreve. Todo leitor em lxImage.pas segue essa ordem, porque ela torna "não consegui ler isso" o default em vez de algo que tem que ser lembrado em cada saída antecipada. Checar só GIF8 em vez da string completa GIF87a ou GIF89a é intencional — o layout do descriptor é idêntico nas duas revisões, e não há benefício em rejeitar um arquivo cuja geometria é legível

Mapa de bytes do cabeçalho GIF lido pela HotXLS: os bytes 0 a 5 carregam a assinatura e versão GIF8, os bytes 6 e 7 a largura da tela lógica em little-endian e os bytes 8 e 9 a altura, então uma leitura única de dez bytes em GetGifSize cobre a checagem da assinatura e as duas dimensões antes da conversão de 0.75 para pontos
O Logical Screen Descriptor da GIF empacota tudo que GetGifSize precisa em dez bytes: a assinatura de seis bytes seguida de largura little-endian nos offsets 6 e 7 e altura em 8 e 9, com GIF87a e GIF89a compartilhando um layout idêntico

O que um tamanho de cabeçalho BMP de 12 muda de verdade?

GetBmpSize lê os quatro bytes no offset 14 como o tamanho do cabeçalho DIB e ramifica no valor, porque um BMP com tamanho de cabeçalho 12 guarda as dimensões numa largura de campo completamente diferente de todo outro BMP. O [MS-WMF] §2.2.2 detalha a família: BITMAPCOREHEADER tem 12 bytes e carrega largura e altura de 16 bits, enquanto BITMAPINFOHEADER, BITMAPV4HEADER e BITMAPV5HEADER todos começam com biWidth e biHeight signed de 32 bits. Ler quatro bytes onde o arquivo tem dois não falha alto; puxa a contagem de planos e a profundidade de bits como metade alta de um número e reporta um bitmap de várias centenas de milhares de pixels de largura. A segunda armadilha no mesmo cabeçalho é o sinal de biHeight: um valor negativo não significa altura negativa, significa que as linhas do DIB estão armazenadas de cima para baixo em vez de baixo para cima, que é uma afirmação de ordem de pixels sem relação nenhuma com geometria

  f.Seek(14, soFromBeginning);
  if f.Read(HeaderSize, 4)= 4 then
  begin
    if HeaderSize= 12 then
    begin
      // BITMAPCOREHEADER guarda dimensões de 16 bits
      if (f.Read(wWidth, 2)= 2)and (f.Read(wHeight, 2)= 2) then
      begin
        lWidth:= wWidth;
        lHeight:= wHeight;
        Result:= 1;
      end;
    end
    else if (f.Read(lWidth, 4)= 4)and (f.Read(lHeight, 4)= 4) then
      Result:= 1;
  end;
  // ...
  // Um biHeight negativo só marca um DIB top-down; a magnitude é o tamanho
  if (Result= 1)and (lWidth> 0)and (lHeight<> 0) then
  begin
    AWidth:= lWidth* 0.75;
    AHeight:= Abs(lHeight)* 0.75;
  end
  else
    Result:= - 1;

A guarda final vale ler com atenção. A altura é testada contra <> 0 em vez de > 0, porque negativo é legal e zero não é, e Abs é aplicado só no ponto da conversão. A largura não tem caso equivalente — um biWidth negativo não é definido pelo formato, então é tratado como arquivo corrompido e rebaixado para -1. Passar um valor negativo cru para cima, em vez disso, produziria uma extensão negativa no registro de drawing, que é clampada, ignorada ou renderizada como shape espelhada dependendo de qual aplicativo abre a pasta de trabalho

JPEG: quais marcadores são realmente headers de frame?

GetJpegSize aceita o conjunto de marcadores [$C0..$C3, $C5..$C7, $C9..$CB, $CD..$CF], e as três lacunas nesse range são o ponto inteiro. A ITU-T T.81 §B.2.2 define o frame header como SOFn, e o frame header é onde vivem o número de linhas e o número de amostras por linha, em todo tipo de frame em vez de só nos comuns. Os marcadores que caem dentro do mesmo intervalo numérico mas não são frame headers são $C4 (DHT, define tabelas de Huffman), $C8 (JPG, reservado) e $CC (DAC, define o condicionamento da codificação aritmética). Um leitor que reconhece só baseline, extended sequential e progressive — $C0, $C1 e $C2 — funciona em quase todo JPEG que uma câmera ou uma ferramenta de screenshot produz, e então falha silenciosamente nos que importam para fluxos de documentos: JPEG lossless ($C3), as variantes codificadas aritmeticamente ($C9 a $CB) e os tipos de frame hierárquicos ($CD a $CF) todos varrem para além do frame header, saem do fim do arquivo, e caem no fallback. Imagens médicas e pipelines de documentos escaneados produzem exatamente esses arquivos

// Todo marcador SOFn carrega as dimensões do frame; $C4 (DHT), $C8 (JPG)
// e $CC (DAC) compartilham o range mas não são frame headers
if Seg in [$C0.. $C3, $C5.. $C7, $C9.. $CB, $CD.. $CF] then
begin
  ReadLen:= f.Read(Dummy[0], 3);
  ReadLen:= f.Read(wHeight, 2);
  wHeight:= swapByte(wHeight);
  ReadLen:= f.Read(wWidth, 2);
  wWidth:= swapByte(wWidth);
  found:= True;
  Break;
end

Dois detalhes nessas sete linhas. Os três bytes pulados são a word de comprimento do segmento mais o byte de precisão de amostra, então a leitura cai na contagem de linhas; o JPEG guarda altura antes de largura, que é o inverso da maioria dos formatos e uma transposição fácil de cometer. E os valores passam por swapByte porque o JPEG é big-endian por inteiro, a mesma razão pela qual GetPngSize passa seus valores IHDR de 32 bits por lswapByte. A caminhada de segmentos tem a própria guarda: quando um marcador não é sem parâmetros, o leitor pega o comprimento de dois bytes, faz byte-swap, e se recusa a fazer seek se o valor for menor que 2 — um comprimento mais curto que o próprio campo se traduziria num seek para trás, e um seek para trás num loop de varredura é um loop infinito num arquivo que outra pessoa controla

Quais marcadores JPEG a HotXLS aceita como frame headers em GetJpegSize: o range SOFn de C0 a CF com lacunas em C4 para DHT, C8 para JPG e CC para DAC, mais o layout do frame header onde o comprimento de dois bytes e o byte de precisão são pulados, a altura é lida antes da largura, e os dois valores passam por swapByte para ordem big-endian
O range de marcadores SOFn com as três lacunas: DHT, JPG e DAC compartilham o intervalo numérico mas não guardam dimensões de frame, e aceitar o range completo deixa JPEGs lossless e hierárquicos reportarem a geometria

Retornar menos um vence retornar um número plausível

O fio comum entre os cinco leitores é que GetImageSize distingue três resultados em vez de dois. 1 significa que a geometria foi lida do arquivo. -1 significa que não foi, e o chamador deve usar o default dele. 7 é retornado por GetWmfEmfSize e significa outra coisa inteira: diz ao AddPicture para tomar o tamanho do registro BSE do metafile, porque um WMF ou EMF carrega a extensão no próprio registro de imagem da pasta de trabalho em vez de num offset fixo no cabeçalho do arquivo. Tratamento de metafile em geral é coberto no artigo sobre o decoder EMF e WMF limitado. GetJpegSize mostra por que essa distinção precisa de uma flag explícita em vez de inferência: o loop de varredura pode terminar de três jeitos — achou um frame header, bateu no fim do arquivo, ou abortou por um comprimento de segmento corrompido — e só o primeiro atribui wWidth e wHeight. Sem o Boolean found, os outros dois caminhos converteriam o que esses locals por acaso contivessem e entregariam o resultado para cima como sucesso. Locals não inicializados são frequentemente zero, e zero é a pior resposta possível aqui: sobrevive a toda checagem de validade a jusante e produz uma imagem invisível que ninguém consegue selecionar nem deletar

var
  W, H: Double;
begin
  case GetImageSize(FileName, W, H) of
    1: Sheet.AddPicture(FileName, Row, Col, W, H);
    7: Sheet.AddPicture(FileName, Row, Col);  // metafile: tamanho do registro BSE
  else
    // W e H são 100 x 100; decida a política em vez de aceitar um placeholder
    LogWarning('unreadable image geometry: ' + FileName);
  end;
end;

Onde ficam os limites honestos

Esses leitores deliberadamente analisam um cabeçalho e param. Eles não decodificam pixels, então reportam as dimensões armazenadas e nada mais, e há casos em que as dimensões armazenadas não são a história inteira. Uma GIF animada cujos frames são menores que a tela lógica ainda reporta a tela lógica, que é correto para layout e pode não bater com o que um viewer mostra. Um JPEG carregando uma tag de orientação EXIF que especifica rotação de 90 graus reporta as dimensões de frame não rotacionadas, então uma foto em retrato tirada num celular pode voltar como paisagem. Um DIB com razão de aspecto de pixel não quadrada reporta contagens cruas de pixels, e o fator 0.75 assume 96 DPI o tempo todo. Nenhum desses é bug na leitura de cabeçalho e nenhum é corrigível sem decodificar a imagem, que não é o propósito desta camada — são limites documentados, então se o seu pipeline lida com fotos de celular, leia a tag de orientação você mesmo e troque os valores antes de chamar AddPicture. Todo o resto sobre posicionamento de imagem, ancoragem e o modelo de objetos de drawing é coberto no artigo sobre gráficos, imagens e drawings

A HotXLS lê e escreve XLS, XLSX, ODS e CSV nativamente em Delphi e C++Builder sem instalação do Excel, e a inserção de imagem passa por esse caminho de cabeçalho em todos eles. Detalhes de suporte a formatos e licenciamento estão na página do produto HotXLS Delphi spreadsheet component