Artículo técnico

Leyendo la geometría de header de GIF, BMP y JPEG en HotXLS

HotXLS lee la geometría real en píxeles de una imagen desde el propio header del archivo antes de escribir cualquier geometría de dibujo. GetImageSize en lxImage.pas despacha por la extensión del archivo a GetJpegSize, GetGifSize, GetBmpSize, GetPngSize o GetWmfEmfSize, convierte los píxeles a puntos con un factor de 0.75, y devuelve -1 cuando de verdad no puede saberlo

Esa última cláusula es la parte que más tardó en quedar bien. Un lector de tamaños que no puede fallar es un lector de tamaños que miente, y una mentira sobre la geometría de una imagen no sale a la superficie en el momento de insertar. Sale semanas después, cuando alguien abre el workbook y el logo de la empresa es un rectángulo aplastado

¿Por qué una imagen necesita su geometría real antes de entrar al dibujo?

Porque el formato de hoja de cálculo guarda la extensión, no la imagen. Cuando AddPicture o Images.AddFromFile colocan una imagen, la parte de dibujo registra un ancho y un alto explícitos para la forma — en DrawingML eso son los atributos cx y cy del elemento a:ext, en English Metric Units, según ECMA-376 Parte 1 §20.4. Nada en ese registro dice «usa el tamaño natural de la imagen», así que el tamaño natural tiene que venir de algún lado, y el único lugar donde existe son los bytes de la imagen. Equivócate por un factor de tres y la imagen queda estirada por un factor de tres, en cada aplicación que abra el workbook, para siempre. La conversión de unidades desde esas dimensiones en píxeles hasta EMU es un tema aparte, cubierto en el artículo sobre unidades EMU y escalado; esta pieza es sobre el paso anterior, donde se obtienen los números de píxeles. El fallback cuando un lector falla es 100 por 100, y ese número está deliberada y obviamente mal. No es una conjetura de un tamaño plausible, es un marcador que significa «nadie pudo leer este archivo», y es mucho mejor que un número equivocado de aspecto plausible porque un usuario lo reporta de inmediato

Cómo GetImageSize en HotXLS despacha por la extensión del archivo a los lectores GetJpegSize, GetGifSize, GetBmpSize, GetPngSize y GetWmfEmfSize e informa tres resultados: 1 cuando la geometría real se leyó del header, -1 cuando no se pudo leer nada y aplica el fallback 100 por 100, y 7 cuando un metafile toma su extensión del registro BSE
GetImageSize distingue tres resultados: 1 para la geometría leída del header, -1 para archivos ilegibles donde los usuarios reportan el fallback 100 por 100 obviamente equivocado, y 7 para metafiles cuya extensión vive en el registro BSE

GIF: el descriptor que nunca se parseó

GetGifSize es nuevo, y antes de que existiera cada GIF insertado en una hoja salía exactamente a 100 por 100 — no había rama GIF en absoluto, así que la extensión caía hasta el brazo por defecto de GetImageSize y devolvía -1. Es el header más barato del conjunto para parsear, lo que vuelve la omisión ligeramente vergonzosa y muy fácil de arreglar. La especificación GIF89a pone el Logical Screen Descriptor inmediatamente después de la firma de seis bytes, y §18 define sus primeros dos campos como el ancho y el alto de la pantalla lógica, cada uno un valor de 16 bits sin signo guardado en little-endian. En términos de archivo eso es los bytes 6 y 7 para el ancho, los bytes 8 y 9 para el alto, así que una sola lectura de diez bytes cubre la comprobación de firma y las dos dimensiones

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
    // Firma "GIF8", luego el Logical Screen Descriptor con el ancho y el
    // alto como words little-endian en los offsets 6 y 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;

Fíjate en la forma: los valores de fallo se asignan primero, y el camino de éxito es lo único que los sobrescribe. Cada lector en lxImage.pas sigue ese orden, porque hace de «no pude leer esto» el valor por defecto en lugar de algo que hay que recordar en cada salida temprana. Revisar solo GIF8 en vez de la cadena completa GIF87a o GIF89a es intencional — el layout del descriptor es idéntico en ambas revisiones, y no hay beneficio en rechazar un archivo cuya geometría se puede leer

Mapa de bytes del header GIF que lee HotXLS: los bytes 0 a 5 llevan la firma y versión GIF8, los bytes 6 y 7 el ancho de pantalla lógica little-endian y los bytes 8 y 9 el alto, así que una lectura de diez bytes en GetGifSize cubre la comprobación de firma y ambas dimensiones antes de la conversión a puntos con 0.75
El GIF Logical Screen Descriptor empaqueta todo lo que GetGifSize necesita en diez bytes: la firma de seis bytes seguida del ancho little-endian en los offsets 6 y 7 y el alto en 8 y 9, con GIF87a y GIF89a compartiendo un layout idéntico

¿Qué cambia de verdad un tamaño de header BMP de 12?

GetBmpSize lee los cuatro bytes en el offset 14 como el tamaño del header DIB y ramifica según el valor, porque un BMP con un header de tamaño 12 guarda sus dimensiones en un ancho de campo completamente distinto de cualquier otro BMP. [MS-WMF] §2.2.2 describe la familia: BITMAPCOREHEADER tiene 12 bytes y lleva ancho y alto de 16 bits, mientras que BITMAPINFOHEADER, BITMAPV4HEADER y BITMAPV5HEADER todos empiezan con biWidth y biHeight con signo de 32 bits. Leer cuatro bytes donde el archivo tiene dos no falla ruidosamente; arrastra el conteo de planos y la profundidad de bits como la mitad alta de un número e informa un bitmap de varios cientos de miles de píxeles de ancho. La segunda trampa en el mismo header es el signo de biHeight: un valor negativo no significa un alto negativo, significa que las filas del DIB están guardadas de arriba hacia abajo en lugar de abajo hacia arriba, que es una afirmación sobre el orden de píxeles sin nada que ver con la geometría

  f.Seek(14, soFromBeginning);
  if f.Read(HeaderSize, 4)= 4 then
  begin
    if HeaderSize= 12 then
    begin
      // BITMAPCOREHEADER guarda dimensiones 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;
  // ...
  // Un biHeight negativo solo marca un DIB top-down; la magnitud es el tamaño
  if (Result= 1)and (lWidth> 0)and (lHeight<> 0) then
  begin
    AWidth:= lWidth* 0.75;
    AHeight:= Abs(lHeight)* 0.75;
  end
  else
    Result:= - 1;

El guard final vale la pena leerlo con atención. El alto se prueba contra <> 0 en lugar de > 0, porque el negativo es legal y el cero no, y Abs se aplica solo en el punto de conversión. El ancho no tiene caso equivalente — un biWidth negativo no está definido por el formato, así que se trata como archivo corrupto y se degrada a -1. Pasar un valor negativo crudo hacia arriba produciría en cambio una extensión negativa en el registro de dibujo, que se recorta, se ignora o se renderiza como una forma espejada según qué aplicación abra el workbook

JPEG: qué markers son en realidad frame headers?

GetJpegSize acepta el conjunto de markers [$C0..$C3, $C5..$C7, $C9..$CB, $CD..$CF], y los tres huecos de ese rango son todo el punto. ITU-T T.81 §B.2.2 define el frame header como SOFn, y el frame header es donde viven el número de líneas y de muestras por línea, en cada tipo de frame y no solo en los comunes. Los markers que caen dentro del mismo tramo numérico pero no son frame headers son $C4 (DHT, define las tablas de Huffman), $C8 (JPG, reservado) y $CC (DAC, define el acondicionamiento de codificación aritmética). Un lector que reconoce solo baseline, extended sequential y progressive — $C0, $C1 y $C2 — funciona con casi cualquier JPEG que produzca una cámara o una herramienta de capturas, y después falla en silencio con los que importan para los flujos de documentos: el JPEG sin pérdida ($C3), las variantes con codificación aritmética ($C9 a $CB) y los tipos de frame jerárquicos ($CD a $CF) todos escanean más allá del frame header, se salen del archivo y aterrizan en el fallback. Los pipelines de imágenes médicas y documentos escaneados producen exactamente esos archivos

// Cualquier marker SOFn lleva las dimensiones del frame; $C4 (DHT), $C8 (JPG)
// y $CC (DAC) comparten el rango pero no son 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

Dos detalles en esas siete líneas. Los tres bytes saltados son la palabra de largo del segmento más el byte de precisión de muestra, así que la lectura cae justo en el conteo de líneas; JPEG guarda el alto antes que el ancho, que es lo contrario de la mayoría de los formatos y una transposición fácil de cometer. Y los valores pasan por swapByte porque JPEG es big-endian de punta a punta, la misma razón por la que GetPngSize pasa sus valores de 32 bits de IHDR por lswapByte. El recorrido de segmentos tiene su propio guard: cuando un marker no carece de parámetros, el lector toma el largo de dos bytes, le hace byte swap, y se niega a hacer seek si el valor está por debajo de 2 — un largo más corto que su propio campo se traduciría en un seek hacia atrás, y un seek hacia atrás en un bucle de escaneo es un loop infinito en un archivo que controla otra persona

Qué markers JPEG acepta HotXLS como frame headers en GetJpegSize: el rango SOFn de C0 a CF con huecos en C4 para DHT, C8 para JPG y CC para DAC, más el layout del frame header donde se saltan el largo de dos bytes y el byte de precisión, el alto se lee antes que el ancho, y ambos valores pasan por swapByte por el orden big-endian
El rango de markers SOFn con sus tres huecos: DHT, JPG y DAC comparten el tramo numérico pero no tienen dimensiones de frame, y aceptar el rango completo permite que los JPEG sin pérdida y jerárquicos reporten su geometría

Devolver menos uno gana a devolver un número plausible

El hilo común de los cinco lectores es que GetImageSize distingue tres resultados en lugar de dos. 1 significa que la geometría se leyó del archivo. -1 significa que no, y quien llama debe usar su valor por defecto. 7 lo devuelve GetWmfEmfSize y significa otra cosa por completo: le dice a AddPicture que tome el tamaño del registro BSE del metafile en su lugar, porque un WMF o EMF lleva su extensión en el propio registro de imagen del workbook, no en un offset fijo del header del archivo. El manejo de metafiles en general está cubierto en el artículo sobre el decoder acotado de EMF y WMF. GetJpegSize muestra por qué esa distinción necesita un flag explícito en lugar de una inferencia: el bucle de escaneo puede terminar de tres maneras — encontró un frame header, llegó al final del archivo, o abortó por un largo de segmento corrupto — y solo la primera asigna wWidth y wHeight. Sin el Boolean found, los otros dos caminos convertirían lo que sea que esas locales contuvieran y pasarían el resultado hacia arriba como éxito. Las locales sin inicializar suelen ser cero, y cero es la peor respuesta posible aquí: sobrevive a cada comprobación de validez río abajo y produce una imagen invisible que nadie puede seleccionar ni borrar

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: tamaño del registro BSE
  else
    // W y H son 100 x 100; decidir política en vez de aceptar un placeholder
    LogWarning('unreadable image geometry: ' + FileName);
  end;
end;

Dónde están los límites honestos

Estos lectores parsean un header deliberadamente y se detienen. No decodifican píxeles, así que reportan las dimensiones guardadas y nada más, y hay casos donde las dimensiones guardadas no son toda la historia. Un GIF animado cuyos frames son más chicos que la pantalla lógica igual reporta la pantalla lógica, lo que es correcto para el layout y puede no coincidir con lo que muestra un visor. Un JPEG con un tag EXIF de orientación que especifica una rotación de 90 grados reporta las dimensiones del frame sin rotar, así que una foto vertical sacada con el teléfono puede volver como horizontal. Un DIB con una relación de aspecto de píxel no cuadrada reporta los conteos de píxeles crudos, y el factor 0.75 asume 96 DPI en todo. Ninguno de esos es un bug en la lectura del header y ninguno se puede arreglar sin decodificar la imagen, que no es para lo que sirve esta capa — son límites documentados, así que si tu pipeline maneja fotos de teléfono, lee el tag de orientación tú mismo e intercambia los valores antes de llamar a AddPicture. Todo lo demás sobre colocación de imágenes, anclaje y el modelo de objetos de dibujo está cubierto en el artículo de gráficos, imágenes y dibujos

HotXLS lee y escribe XLS, XLSX, ODS y CSV de forma nativa en Delphi y C++Builder sin requerir instalación de Excel, y la inserción de imágenes pasa por este camino de header en todos ellos. Los detalles de soporte de formatos y licencias están en la página del producto HotXLS Delphi spreadsheet component