Article technique

Lire la géométrie d'en-tête GIF, BMP et JPEG dans HotXLS

HotXLS lit la vraie géométrie en pixels d'une image depuis l'en-tête du fichier image lui-même avant qu'aucune géométrie de dessin soit écrite. GetImageSize dans lxImage.pas distribue sur l'extension de fichier vers GetJpegSize, GetGifSize, GetBmpSize, GetPngSize ou GetWmfEmfSize, convertit les pixels en points avec un facteur de 0.75, et retourne -1 quand il ne peut vraiment pas le dire

Cette dernière clause est la partie qui a mis le plus longtemps à être juste. Un lecteur de taille qui ne peut pas échouer est un lecteur de taille qui ment, et un mensonge sur la géométrie d'une image ne remonte pas au moment de l'insertion. Il remonte des semaines plus tard, quand quelqu'un ouvre le classeur et que le logo de la société est un rectangle écrasé

Pourquoi une image a-t-elle besoin de sa vraie géométrie avant d'aller dans le dessin ?

Parce que le format de feuille de calcul stocke l'étendue, pas l'image. Quand AddPicture ou Images.AddFromFile place une image, la partie dessin enregistre une largeur et une hauteur explicites pour la forme — en DrawingML, ce sont les attributs cx et cy de l'élément a:ext, en unités métriques anglaises, selon ECMA-376 Partie 1 §20.4. Rien dans cet enregistrement ne dit « utilise la taille naturelle de l'image », donc la taille naturelle doit venir de quelque part, et le seul endroit où elle existe, c'est les octets de l'image. Faites une erreur d'un facteur trois et l'image est étirée d'un facteur trois, dans chaque application qui ouvre le classeur, pour toujours. La conversion d'unités de ces dimensions en pixels jusqu'aux EMU est un souci séparé, couvert dans l'article sur les unités EMU et la mise à l'échelle ; ce morceau parle de l'étape d'avant, celle où les nombres de pixels sont obtenus tout court. Le repli quand un lecteur échoue est 100 par 100, et ce nombre est délibérément, évidemment faux. Ce n'est pas une estimation d'une taille plausible, c'est un marqueur qui veut dire « personne n'a pu lire ce fichier », et c'est bien mieux qu'un nombre faux à l'air plausible parce qu'un utilisateur le signale immédiatement

Comment GetImageSize dans HotXLS distribue sur l'extension de fichier vers les lecteurs GetJpegSize, GetGifSize, GetBmpSize, GetPngSize et GetWmfEmfSize et rapporte trois issues : 1 quand la vraie géométrie a été lue dans l'en-tête, -1 quand rien n'a pu être lu et que le repli 100 par 100 s'applique, et 7 quand un métafichier prend son étendue dans l'enregistrement BSE
GetImageSize distingue trois issues : 1 pour une géométrie lue dans l'en-tête, -1 pour les fichiers illisibles où le repli 100 par 100 manifestement faux est signalé par les utilisateurs, et 7 pour les métafichiers dont l'étendue vit dans l'enregistrement BSE

GIF : le descripteur qui n'a jamais été analysé

GetGifSize est nouveau, et avant qu'il n'existe, chaque GIF inséré dans une feuille de calcul ressortait à exactement 100 par 100 — il n'y avait aucune branche GIF du tout, donc l'extension retombait sur le bras par défaut de GetImageSize et retournait -1. C'est l'en-tête le moins cher de l'ensemble à analyser, ce qui rend l'omission légèrement embarrassante et très facile à corriger. La spécification GIF89a place le descripteur d'écran logique immédiatement après la signature de six octets, et §18 définit ses deux premiers champs comme la largeur et la hauteur de l'écran logique, chacune une valeur non signée de 16 bits stockée en little-endian. En termes de fichier, ce sont les octets 6 et 7 pour la largeur, les octets 8 et 9 pour la hauteur, donc une seule lecture de dix octets couvre la vérification de signature et les deux dimensions

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
    // Signature « GIF8 », puis le descripteur d'écran logique avec la largeur et
    // la hauteur en mots little-endian aux offsets 6 et 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;

Notez sa forme : les valeurs d'échec sont affectées d'abord, et le chemin de succès est la seule chose qui les écrase. Chaque lecteur dans lxImage.pas suit cet ordre, parce que cela fait de « je n'ai pas pu lire ceci » le défaut plutôt que quelque chose à se rappeler à chaque sortie anticipée. Ne vérifier que GIF8 plutôt que la chaîne complète GIF87a ou GIF89a est intentionnel — la disposition du descripteur est identique dans les deux révisions, et il n'y a aucun bénéfice à rejeter un fichier dont la géométrie est lisible

Carte d'octets de l'en-tête GIF lu par HotXLS : les octets 0 à 5 portent la signature et la version GIF8, les octets 6 et 7 la largeur d'écran logique little-endian et les octets 8 et 9 la hauteur, donc une seule lecture de dix octets dans GetGifSize couvre la vérification de signature et les deux dimensions avant la conversion en points par 0.75
Le descripteur d'écran logique GIF emballe tout ce dont GetGifSize a besoin en dix octets : la signature de six octets suivie de la largeur little-endian aux offsets 6 et 7 et de la hauteur aux 8 et 9, avec GIF87a et GIF89a partageant une disposition identique

Que change réellement une taille d'en-tête BMP de 12 ?

GetBmpSize lit les quatre octets à l'offset 14 comme la taille de l'en-tête DIB et se branche sur la valeur, parce qu'un BMP avec une taille d'en-tête de 12 stocke ses dimensions dans une largeur de champ complètement différente de tous les autres BMP. [MS-WMF] §2.2.2 dispose la famille : BITMAPCOREHEADER fait 12 octets et porte une largeur et une hauteur de 16 bits, tandis que BITMAPINFOHEADER, BITMAPV4HEADER et BITMAPV5HEADER commencent tous par des biWidth et biHeight signés de 32 bits. Lire quatre octets là où le fichier en a deux n'échoue pas bruyamment ; cela attire le compte de plans et la profondeur de bits comme moitié haute d'un nombre et rapporte un bitmap de plusieurs centaines de milliers de pixels de large. Le second piège dans le même en-tête est le signe de biHeight : une valeur négative ne veut pas dire une hauteur négative, cela veut dire que les rangées DIB sont stockées du haut vers le bas plutôt que du bas vers le haut, une déclaration d'ordre de pixels qui n'a rien à voir avec la géométrie

  f.Seek(14, soFromBeginning);
  if f.Read(HeaderSize, 4)= 4 then
  begin
    if HeaderSize= 12 then
    begin
      // BITMAPCOREHEADER stocke des dimensions 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 négatif marque seulement un DIB du haut vers le bas ; la magnitude est la taille
  if (Result= 1)and (lWidth> 0)and (lHeight<> 0) then
  begin
    AWidth:= lWidth* 0.75;
    AHeight:= Abs(lHeight)* 0.75;
  end
  else
    Result:= - 1;

La garde finale vaut la peine d'être lue de près. La hauteur est testée contre <> 0 plutôt que > 0, parce que le négatif est légal et que zéro ne l'est pas, et Abs n'est appliqué qu'au point de conversion. La largeur n'a pas de cas équivalent — un biWidth négatif n'est pas défini par le format, donc il est traité comme un fichier corrompu et dégradé en -1. Passer une valeur brute négative vers le haut produirait à la place une étendue négative dans l'enregistrement de dessin, qui est clampée, ignorée ou rendue comme une forme retournée selon l'application qui ouvre le classeur

JPEG : quels marqueurs sont vraiment des en-têtes de trame ?

GetJpegSize accepte l'ensemble de marqueurs [$C0..$C3, $C5..$C7, $C9..$CB, $CD..$CF], et les trois trous dans cette plage sont tout le propos. ITU-T T.81 §B.2.2 définit l'en-tête de trame comme SOFn, et l'en-tête de trame est l'endroit où vivent le nombre de lignes et d'échantillons par ligne, dans chaque type de trame plutôt que seulement les courants. Les marqueurs qui tombent dans la même plage numérique mais ne sont pas des en-têtes de trame sont $C4 (DHT, définir les tables de Huffman), $C8 (JPG, réservé) et $CC (DAC, définir le conditionnement du codage arithmétique). Un lecteur qui ne reconnaît que la baseline, la séquentielle étendue et la progressive — $C0, $C1 et $C2 — marche sur presque tous les JPEG qu'un appareil photo ou un outil de capture produit, puis échoue tranquillement sur ceux qui comptent pour les flux documentaires : le JPEG sans perte ($C3), les variantes à codage arithmétique ($C9 à $CB) et les types de trames hiérarchiques ($CD à $CF) scannent tous au-delà de l'en-tête de trame, dépassent la fin du fichier et atterrissent dans le repli. L'imagerie médicale et les pipelines de documents scannés produisent exactement ces fichiers

// Tout marqueur SOFn porte les dimensions de trame ; $C4 (DHT), $C8 (JPG)
// et $CC (DAC) partagent la plage mais ne sont pas des en-têtes de trame
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

Deux détails dans ces sept lignes. Les trois octets sautés sont le mot de longueur de segment plus l'octet de précision d'échantillonnage, donc la lecture atterrit sur le compte de lignes ; JPEG stocke la hauteur avant la largeur, l'inverse de la plupart des formats et une transposition facile à commettre. Et les valeurs passent par swapByte parce que JPEG est big-endian de bout en bout, la même raison pour laquelle GetPngSize fait passer ses valeurs IHDR de 32 bits par lswapByte. La marche des segments a sa propre garde : quand un marqueur n'est pas sans paramètre, le lecteur prend la longueur de deux octets, l'échange d'octets, et refuse de seek si la valeur est en dessous de 2 — une longueur plus courte que son propre champ se traduirait par un seek en arrière, et un seek en arrière dans une boucle de scan est une boucle infinie sur un fichier contrôlé par quelqu'un d'autre

Quels marqueurs JPEG HotXLS accepte comme en-têtes de trame dans GetJpegSize : la plage SOFn de C0 à CF avec des trous à C4 pour DHT, C8 pour JPG et CC pour DAC, plus la disposition de l'en-tête de trame où la longueur de deux octets et l'octet de précision sont sautés, la hauteur est lue avant la largeur, et les deux valeurs passent par swapByte pour l'ordre big-endian
La plage de marqueurs SOFn avec ses trois trous : DHT, JPG et DAC partagent la plage numérique mais ne portent pas de dimensions de trame, et accepter la plage complète permet aux JPEG sans perte et hiérarchiques de rapporter leur géométrie

Retourner moins un bat retourner un nombre plausible

Le fil commun à travers les cinq lecteurs, c'est que GetImageSize distingue trois issues plutôt que deux. 1 veut dire que la géométrie a été lue depuis le fichier. -1 veut dire qu'elle ne l'a pas été, et l'appelant doit utiliser son défaut. 7 est retourné par GetWmfEmfSize et veut dire autre chose entièrement : il dit à AddPicture de prendre la taille depuis l'enregistrement BSE du métafichier à la place, parce qu'un WMF ou un EMF porte son étendue dans l'enregistrement d'image propre au classeur plutôt qu'à un offset fixe dans l'en-tête du fichier. La gestion des métafichiers en général est couverte dans l'article sur le décodeur EMF et WMF borné. GetJpegSize montre pourquoi cette distinction a besoin d'un drapeau explicite plutôt que d'une inférence : la boucle de scan peut se terminer de trois façons — elle a trouvé un en-tête de trame, elle a atteint la fin du fichier, ou elle a abandonné sur une longueur de segment corrompue — et seule la première affecte wWidth et wHeight. Sans le booléen found, les deux autres chemins convertiraient ce que ces locales contenaient par hasard et tendraient le résultat vers le haut comme un succès. Les locales non initialisées sont fréquemment zéro, et zéro est la pire réponse possible ici : elle survit à chaque vérification de validité en aval et produit une image invisible que personne ne peut sélectionner ni supprimer

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);  // métafichier : taille depuis l'enregistrement BSE
  else
    // W et H valent 100 x 100 ; décidez une politique plutôt que d'accepter un placeholder
    LogWarning('unreadable image geometry: ' + FileName);
  end;
end;

Où sont les limites honnêtes

Ces lecteurs analysent délibérément un en-tête puis s'arrêtent. Ils ne décodent pas les pixels, donc ils rapportent les dimensions stockées et rien de plus, et il y a des cas où les dimensions stockées ne sont pas toute l'histoire. Un GIF animé dont les trames sont plus petites que l'écran logique rapporte toujours l'écran logique, ce qui est correct pour la mise en page et peut ne pas correspondre à ce qu'un visualiseur montre. Un JPEG portant une balise d'orientation EXIF qui spécifie une rotation de 90 degrés rapporte les dimensions de trame non pivotées, donc une photo portrait prise sur un téléphone peut revenir en paysage. Un DIB avec un ratio d'aspect de pixels non carré rapporte des comptes de pixels bruts, et le facteur 0.75 suppose 96 DPI partout. Aucun de ces cas n'est un bug de la lecture d'en-tête et aucun n'est corrigeable sans décoder l'image, ce qui n'est pas le rôle de cette couche — ce sont des frontières documentées, donc si votre pipeline gère des photos de téléphone, lisez la balise d'orientation vous-même et échangez les valeurs avant d'appeler AddPicture. Tout le reste de la pose des images, de l'ancrage et du modèle d'objets de dessin est couvert dans l'article sur les graphiques, les images et les dessins

HotXLS lit et écrit XLS, XLSX, ODS et CSV nativement en Delphi et C++Builder sans aucune installation d'Excel requise, et l'insertion d'images passe par ce chemin d'en-tête dans chacun d'eux. Le support des formats et les détails de licence sont sur la page produit du HotXLS Delphi Component