O HotXLS lê a geometria real em pixels de uma imagem a partir do próprio header do ficheiro antes de qualquer geometria de drawing ser escrita. O GetImageSize em lxImage.pas despacha pela extensão do ficheiro para GetJpegSize, GetGifSize, GetBmpSize, GetPngSize ou GetWmfEmfSize, converte pixels em pontos com um fator de 0.75, e retorna -1 quando genuinamente não consegue saber
Essa última cláusula é a parte que demorou mais tempo a acertar. Um leitor de tamanho que não pode falhar é um leitor de tamanho que mente, e uma mentira sobre a geometria da imagem não vem ao de cima na altura da inserção. Vem ao de cima semanas depois, quando alguém abre o workbook e o logo da empresa é um retângulo esmagado
Porque é que uma imagem precisa da sua geometria real antes de ir para o drawing?
Porque o formato da folha de cálculo guarda a extensão, não a imagem. Quando o AddPicture ou o Images.AddFromFile coloca uma imagem, a parte de drawing regista uma largura e altura explícitas para a forma — no DrawingML são os atributos cx e cy do elemento a:ext, em English Metric Units, segundo a parte 1 §20.4 da ECMA-376. Nada nesse registo diz "usa o tamanho natural da imagem", pelo que o tamanho natural tem de vir de algum lado, e o único sítio onde existe são os bytes da imagem. Erre por um fator de três e a imagem fica esticada por um fator de três, em todas as aplicações que abrem o workbook, para sempre. A conversão de unidades dessas dimensões em pixels para EMU é uma preocupação separada, coberta em o artigo sobre unidades EMU e scaling; esta peça é sobre o passo anterior, onde os números de pixels são obtidos de todo. 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 este ficheiro", e é muito melhor do que um número errado de aspeto plausível porque um utilizador reporta-o imediatamente
GIF: o descritor a que nunca se fazia parse
O GetGifSize é novo, e antes de existir cada GIF inserido numa worksheet saía exatamente a 100 por 100 — não havia ramo GIF de todo, pelo que a extensão caía no braço default do GetImageSize e retornava -1. É o header mais barato do conjunto para fazer parse, o que torna a omissão ligeiramente embaraçosa e muito fácil de corrigir. A especificação GIF89a coloca o Logical Screen Descriptor imediatamente a seguir à assinatura de seis bytes, e a secção §18 define os seus dois primeiros campos como a largura e altura do ecrã lógico, cada um um valor unsigned de 16 bits guardado em little-endian. Em termos de ficheiro, são os bytes 6 e 7 para a largura, os bytes 8 e 9 para a altura, pelo que uma única leitura de dez bytes cobre a verificação da assinatura e ambas as 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 a largura e
// a 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;
Repare na forma: os valores de falha são atribuídos primeiro, e o caminho de sucesso é a única coisa que os sobrescreve. Todos os leitores em lxImage.pas seguem essa ordem, porque torna "não consegui ler isto" o default em vez de algo que tem de ser lembrado em cada saída antecipada. Verificar apenas 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 ficheiro cuja geometria é legível
O que muda realmente um tamanho de header BMP de 12?
O GetBmpSize lê os quatro bytes no offset 14 como o tamanho do header DIB e ramifica pelo valor, porque um BMP com tamanho de header 12 guarda as suas dimensões numa largura de campo completamente diferente de qualquer outro BMP. A secção §2.2.2 de [MS-WMF] apresenta a família: o BITMAPCOREHEADER tem 12 bytes e carrega largura e altura de 16 bits, enquanto o BITMAPINFOHEADER, o BITMAPV4HEADER e o BITMAPV5HEADER começam todos com biWidth e biHeight com sinal de 32 bits. Ler quatro bytes onde o ficheiro tem dois não falha ruidosamente; puxa a contagem de planos e a profundidade de bits como a metade alta de um número e reporta um bitmap com várias centenas de milhares de pixels de largura. A segunda armadilha no mesmo header é o sinal do biHeight: um valor negativo não significa altura negativa, significa que as linhas DIB estão guardadas de cima para baixo em vez de baixo para cima, que é uma afirmação sobre a ordem dos pixels sem nada a ver 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 apenas 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;
O guard final vale a pena ler com atenção. A altura é testada contra <> 0 em vez de > 0, porque negativo é legal e zero não é, e o Abs é aplicado apenas no ponto da conversão. A largura não tem caso equivalente — um biWidth negativo não é definido pelo formato, pelo que é tratado como ficheiro corrompido e rebaixado a -1. Passar um valor negativo cru para cima produziria em vez disso uma extensão negativa no registo de drawing, que é clampada, ignorada ou renderizada como uma forma invertida dependendo de qual aplicação abre o workbook
JPEG: quais markers são realmente frame headers?
O GetJpegSize aceita o conjunto de markers [$C0..$C3, $C5..$C7, $C9..$CB, $CD..$CF], e os três intervalos nesse range são o ponto todo. 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 as samples por linha, em todos os tipos de frame e não apenas nos comuns. Os markers que caem dentro do mesmo span numérico mas não são frame headers são $C4 (DHT, definir tabelas Huffman), $C8 (JPG, reservado) e $CC (DAC, definir condicionamento de arithmetic coding). Um leitor que reconhece apenas baseline, sequential estendido e progressive — $C0, $C1 e $C2 — funciona com quase todos os JPEG que uma câmara ou uma ferramenta de screenshots produz, e depois falha silenciosamente naqueles que importam para workflows de documentos: o JPEG lossless ($C3), as variantes com arithmetic coding ($C9 a $CB) e os tipos de frame hierárquicos ($CD a $CF) passam todos a correr para lá do frame header, saem do fim do ficheiro e aterram no fallback. Imaging médico e pipelines de documentos digitalizados produzem exatamente esses ficheiros
// Qualquer marker SOFn carrega as dimensões do frame; $C4 (DHT), $C8 (JPG)
// e $CC (DAC) partilham 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 saltados são a word de comprimento do segmento mais o byte de precisão das samples, pelo que a leitura aterra na contagem de linhas; o JPEG guarda a altura antes da largura, que é o inverso da maioria dos formatos e uma transposição fácil de fazer. E os valores passam pelo swapByte porque o JPEG é big-endian de ponta a ponta, a mesma razão pela qual o GetPngSize passa os seus valores IHDR de 32 bits pelo lswapByte. A caminhada de segmentos tem o seu próprio guard: quando um marker não é parameterless, o leitor pega no comprimento de dois bytes, faz byte-swap, e recusa-se a fazer seek se o valor for abaixo de 2 — um comprimento mais curto do que o próprio campo traduzir-se-ia num seek para trás, e um seek para trás num loop de scan é um loop infinito num ficheiro que outra pessoa controla
Retornar menos um é melhor do que retornar um número plausível
O fio comum em todos os cinco leitores é que o GetImageSize distingue três resultados em vez de dois. 1 significa que a geometria foi lida do ficheiro. -1 significa que não foi, e o chamador deve usar o seu default. 7 é retornado pelo GetWmfEmfSize e significa outra coisa completamente: diz ao AddPicture para tirar o tamanho do registo BSE do metafile em vez disso, porque um WMF ou EMF carrega a sua extensão no próprio registo de imagem do workbook em vez de num offset fixo no header do ficheiro. O tratamento de metafiles em geral é coberto em o artigo sobre o decoder EMF e WMF limitado. O GetJpegSize mostra porque é que essa distinção precisa de uma flag explícita em vez de uma inferência: o loop de scan pode terminar de três formas — encontrou um frame header, chegou ao fim do ficheiro, ou desistiu num comprimento de segmento corrompido — e só a primeira atribui wWidth e wHeight. Sem o Boolean found, os outros dois paths converteriam aquilo que essas locals tivessem dentro e entregariam o resultado para cima como sucesso. Locals não inicializadas são frequentemente zero, e zero é a pior resposta possível aqui: sobrevive a todas as verificações de validade a jusante e produz uma imagem invisível que ninguém consegue selecionar ou apagar
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 registo 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 estão os limites honestos
Estes leitores fazem deliberadamente parse de um header e param. Não decodificam pixels, pelo que reportam as dimensões guardadas e nada mais, e há casos em que as dimensões guardadas não são a história toda. Um GIF animado cujos frames são mais pequenos do que o ecrã lógico continua a reportar o ecrã lógico, que está correto para o layout e pode não bater certo com o que um visualizador mostra. Um JPEG com uma tag de orientação EXIF que especifica uma rotação de 90 graus reporta as dimensões do frame sem rotação, pelo que uma foto em retrato tirada num telemóvel pode voltar como paisagem. Um DIB com um rácio de aspeto de pixels não quadrado reporta contagens de pixels cruas, e o fator 0.75 assume 96 DPI em todo o lado. Nenhum destes é um bug na leitura do header e nenhum é corrigível sem decodificar a imagem, que não é para isso que esta camada serve — são limites documentados, pelo que se o seu pipeline lida com fotos de telemóvel, leia você mesmo a tag de orientação e troque os valores antes de chamar o AddPicture. Tudo o resto sobre colocação de imagens, ancoragem e o modelo de objetos de drawing é coberto em o artigo sobre gráficos, imagens e drawings
O HotXLS lê e escreve XLS, XLSX, ODS e CSV nativamente em Delphi e C++Builder sem precisar de instalação do Excel, e a inserção de imagens passa por este path de header em todos eles. O suporte de formatos e os detalhes de licenciamento estão na página do produto HotXLS Delphi spreadsheet component