Technisch artikel

GIF-, BMP- en JPEG-headergeometrie lezen in HotXLS

HotXLS leest de echte pixelgeometrie van een afbeelding uit de header van het image bestand zelf, voordat er enige drawing-geometrie wordt geschreven. GetImageSize in lxImage.pas dispatcht op de bestandsextensie naar GetJpegSize, GetGifSize, GetBmpSize, GetPngSize of GetWmfEmfSize, zet pixels om naar points met een factor van 0.75, en retourneert -1 wanneer hij het echt niet kan zeggen

Dat laatste zinnetje is het deel waar het langst aan is gesleuteld. Een size reader die niet kan falen, is een size reader die liegt, en een leugen over beeldgeometrie komt niet aan de oppervlakte bij het invoegen. Die komt weken later boven, wanneer iemand het workbook opent en het bedrijfslogo een platgedrukte rechthoek is

Waarom heeft een afbeelding haar echte geometrie nodig voordat ze de drawing in gaat?

Omdat het spreadsheetformaat de extent opslaat, niet de afbeelding. Wanneer AddPicture of Images.AddFromFile een afbeelding plaatst, registreert het drawing-deel een expliciete breedte en hoogte voor de shape — in DrawingML zijn dat de cx- en cy-attributen van het a:ext-element, in English Metric Units, volgens ECMA-376 Part 1 §20.4. Niets in die record zegt "gebruik de natuurlijke grootte van de afbeelding", dus die natuurlijke grootte moet ergens vandaan komen, en de enige plek waar ze bestaat is de image-bytes. Zit je er een factor drie naast, dan wordt de afbeelding een factor drie uitgerekt, in elke applicatie die het workbook opent, voor altijd. De eenheidsconversie van die pixelafmetingen naar EMU is een aparte kwestie, behandeld in het artikel over EMU-eenheden en schalen; dit stuk gaat over de stap ervoor, waar de pixelgetallen überhaupt worden verkregen. De fallback wanneer een reader faalt is 100 bij 100, en dat getal is bewust, overduidelijk fout. Het is geen gok op een aannemelijke grootte, het is een marker die betekent "niemand kon dit bestand lezen", en het is stukken beter dan een aannemelijk ogend fout getal, omdat een gebruiker het meteen meldt

Hoe GetImageSize in HotXLS dispatcht op de bestandsextensie naar de readers GetJpegSize, GetGifSize, GetBmpSize, GetPngSize en GetWmfEmfSize en drie uitkomsten rapporteert: 1 wanneer echte geometrie uit de header is gelezen, -1 wanneer er niets te lezen viel en de 100 bij 100-fallback geldt, en 7 wanneer een metafile zijn extent uit de BSE-record haalt
GetImageSize onderscheidt drie uitkomsten: 1 voor geometrie die uit de header is gelezen, -1 voor onleesbare bestanden waarbij de overduidelijk foute 100 bij 100-fallback door gebruikers wordt gemeld, en 7 voor metafiles waarvan de extent in de BSE-record zit

GIF: de descriptor die nooit werd geparsed

GetGifSize is nieuw, en voordat het bestond kwam elke GIF die in een worksheet werd ingevoegd er precies uit als 100 bij 100 — er was helemaal geen GIF-tak, dus de extensie viel door naar de default-arm van GetImageSize en retourneerde -1. Dit is de goedkoopste header van het stel om te parsen, wat de omissie enigszins gênant en heel makkelijk te repareren maakt. De GIF89a-specificatie plaatst de Logical Screen Descriptor direct na de zes-byte handtekening, en §18 definieert de eerste twee velden als de logische schermbreedte en -hoogte, elk een unsigned 16-bit waarde in little-endian. In bestandstermen zijn dat bytes 6 en 7 voor breedte, bytes 8 en 9 voor hoogte, dus één read van tien bytes dekt de handtekeningcheck en beide afmetingen

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
    // Handtekening "GIF8", dan de Logical Screen Descriptor met de breedte en
    // hoogte als little-endian woorden op offsets 6 en 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;

Let op de vorm: de faalwaarden worden eerst toegewezen, en alleen het succespad overschrijft ze. Elke reader in lxImage.pas volgt die volgorde, omdat het "ik kon dit niet lezen" de default maakt in plaats van iets dat bij elke vroege exit moet worden onthouden. Alleen GIF8 checken in plaats van de volledige GIF87a- of GIF89a-string is bewust — de descriptorlayout is in beide revisies identiek, en er is geen voordeel bij het afwijzen van een bestand waarvan de geometrie leesbaar is

Bytekaart van de GIF-header die HotXLS leest: bytes 0 tot 5 dragen de GIF8-handtekening en versie, bytes 6 en 7 de little-endian logische schermbreedte en bytes 8 en 9 de hoogte, zodat één read van tien bytes in GetGifSize de handtekeningcheck en beide afmetingen dekt vóór de 0.75-point-conversie
De GIF Logical Screen Descriptor stopt alles wat GetGifSize nodig heeft in tien bytes: de zes-byte handtekening gevolgd door little-endian breedte op offsets 6 en 7 en hoogte op 8 en 9, waarbij GIF87a en GIF89a een identieke layout delen

Wat verandert een BMP-headergrootte van 12 eigenlijk?

GetBmpSize leest de vier bytes op offset 14 als de DIB-headergrootte en brancht op de waarde, omdat een BMP met een headergrootte van 12 zijn afmetingen in een totaal andere veldbreedte opslaat dan elke andere BMP. [MS-WMF] §2.2.2 legt de familie uit: BITMAPCOREHEADER is 12 bytes en draagt 16-bit breedte en hoogte, terwijl BITMAPINFOHEADER, BITMAPV4HEADER en BITMAPV5HEADER allemaal beginnen met 32-bit signed biWidth en biHeight. Vier bytes lezen waar het bestand er twee heeft, faalt niet luidruchtig; het trekt het plane aantal en de bitdiepte mee als de hoge helft van een getal en rapporteert een bitmap van enkele honderdduizenden pixels breed. De tweede valstrik in dezelfde header is het teken van biHeight: een negatieve waarde betekent geen negatieve hoogte, het betekent dat de DIB-rijen top-down worden opgeslagen in plaats van bottom-up, een pixel-order-uitspraak die niets met geometrie te maken heeft

  f.Seek(14, soFromBeginning);
  if f.Read(HeaderSize, 4)= 4 then
  begin
    if HeaderSize= 12 then
    begin
      // BITMAPCOREHEADER slaat 16-bit afmetingen op
      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;
  // ...
  // Een negatieve biHeight markeert alleen een top-down DIB; de magnitude is de grootte
  if (Result= 1)and (lWidth> 0)and (lHeight<> 0) then
  begin
    AWidth:= lWidth* 0.75;
    AHeight:= Abs(lHeight)* 0.75;
  end
  else
    Result:= - 1;

De laatste guard verdient een nauwkeurige lezing. Hoogte wordt getest tegen <> 0 in plaats van > 0, omdat negatief legaal is en nul niet, en Abs wordt alleen op het conversiepunt toegepast. Breedte heeft geen vergelijkbaar geval — een negatieve biWidth is niet door het formaat gedefinieerd, dus wordt het als corrupt bestand behandeld en gedegradeerd tot -1. Een ruwe negatieve waarde doorgeven zou in plaats daarvan een negatieve extent in de drawing-record opleveren, die wordt geklemd, genegeerd of als een gespiegelde shape gerenderd, afhankelijk van welke applicatie het workbook opent

JPEG: welke markers zijn echt frame headers?

GetJpegSize accepteert de markerset [$C0..$C3, $C5..$C7, $C9..$CB, $CD..$CF], en de drie gaten in dat bereik zijn het hele punt. ITU-T T.81 §B.2.2 definieert de frame header als SOFn, en de frame header is waar het aantal lijnen en samples per lijn wonen, in elk frame type, niet alleen de gangbare. De markers die binnen hetzelfde numerieke bereik vallen maar geen frame headers zijn: $C4 (DHT, definieert Huffman-tabellen), $C8 (JPG, gereserveerd) en $CC (DAC, definieert arithmetic coding conditioning). Een reader die alleen baseline, extended sequential en progressive herkent — $C0, $C1 en $C2 — werkt op bijna elke JPEG die een camera of een screenshot tool produceert, en faalt dan stilletjes op de exemplaren die er voor documentworkflows toe doen: lossless JPEG ($C3), de arithmetic-coded varianten ($C9 tot en met $CB) en de hiërarchische frame types ($CD tot en met $CF) scannen allemaal langs de frame header, lopen van het einde van het bestand af en belanden in de fallback. Medische beeldvorming en gescande-documenten-pipelines produceren precies zulke bestanden

// Elke SOFn-marker draagt de frame-afmetingen; $C4 (DHT), $C8 (JPG)
// en $CC (DAC) delen het bereik maar zijn geen 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

Twee details in die zeven regels. De drie overgeslagen bytes zijn het segmentlengte-woord plus de sample-precision-byte, dus de read komt uit op het lijnenaantal; JPEG slaat hoogte vóór breedte op, het omgekeerde van de meeste formaten en een makkelijke transpositie om te maken. En de waarden gaan door swapByte omdat JPEG overal big-endian is, om dezelfde reden waarom GetPngSize zijn 32-bit IHDR-waarden door lswapByte haalt. De segmentwandeling heeft zijn eigen guard: wanneer een marker niet parameterloos is, neemt de reader de twee-byte lengte, byte-swapt die, en weigert te seeken als de waarde onder de 2 ligt — een lengte korter dan zijn eigen veld zou een achterwaartse seek betekenen, en een achterwaartse seek in een scanloop is een oneindige loop op een bestand waar iemand anders de controle over heeft

Welke JPEG-markers HotXLS in GetJpegSize als frame headers accepteert: het SOFn-bereik C0 tot en met CF met gaten bij C4 voor DHT, C8 voor JPG en CC voor DAC, plus de frame header-layout waarin de twee-byte lengte en precision-byte worden overgeslagen, hoogte vóór breedte wordt gelezen, en beide waarden voor big-endian volgorde door swapByte gaan
Het SOFn-markerbereik met zijn drie gaten: DHT, JPG en DAC delen het numerieke bereik maar bevatten geen frame-afmetingen, en het accepteren van het volledige bereik laat lossless en hiërarchische JPEG's hun geometrie rapporteren

Min één retourneren verslaat een aannemelijk getal retourneren

De rode draad door alle vijf readers is dat GetImageSize drie uitkomsten onderscheidt in plaats van twee. 1 betekent dat de geometrie uit het bestand is gelezen. -1 betekent dat niet, en de caller moet zijn default gebruiken. 7 wordt door GetWmfEmfSize geretourneerd en betekent iets heel anders: het vertelt AddPicture de grootte uit de metafile BSE-record te halen, omdat een WMF of EMF zijn extent in de eigen picture-record van het workbook draagt in plaats van op een vaste offset in de file header. Metafile-afhandeling in het algemeen wordt behandeld in het artikel over de bounded EMF- en WMF-decoder. GetJpegSize laat zien waarom dat onderscheid een expliciete flag nodig heeft in plaats van een afleiding: de scanloop kan op drie manieren eindigen — hij vond een frame header, hij stuitte op het einde van het bestand, of hij haakte af op een corrupte segmentlengte — en alleen de eerste kent wWidth en wHeight toe. Zonder de found-Boolean zouden de andere twee paden omzetten wat die lokale variabelen toevallig bevatten en het resultaat als succes doorgeven. Niet-geïnitialiseerde lokale variabelen zijn vaak nul, en nul is hier het slechtst mogelijke antwoord: het overleeft elke validiteitscheck downstream en produceert een onzichtbare afbeelding die niemand kan selecteren of verwijderen

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: grootte uit de BSE-record
  else
    // W en H zijn 100 x 100; bepaal beleid in plaats van een placeholder te accepteren
    LogWarning('unreadable image geometry: ' + FileName);
  end;
end;

Waar de eerlijke grenzen liggen

Deze readers parsen bewust een header en stoppen dan. Ze decoderen geen pixels, dus ze rapporteren de opgeslagen afmetingen en niets meer, en er zijn gevallen waarin de opgeslagen afmetingen niet het hele verhaal zijn. Een geanimeerde GIF waarvan de frames kleiner zijn dan het logische scherm rapporteert nog steeds het logische scherm, wat correct is voor de layout maar misschien niet overeenkomt met wat een viewer toont. Een JPEG met een EXIF-orientatietag die een rotatie van 90 graden specificeert, rapporteert de niet-geroteerde frame-afmetingen, dus een portretfoto die op een telefoon is genomen kan als landscape terugkomen. Een DIB met een niet-vierkante pixel-aspectratio rapporteert ruwe pixelaantallen, en de 0.75-factor neemt overal 96 DPI aan. Geen van deze zijn bugs in het header-lezen en geen is te repareren zonder de afbeelding te decoderen, waarvoor deze laag niet bedoeld is — het zijn gedocumenteerde grenzen, dus als je pipeline telefoonfoto's verwerkt, lees dan zelf de orientatietag en wissel de waarden om voordat je AddPicture aanroept. Al het andere over het plaatsen van afbeeldingen, anchoring en het drawing object model wordt behandeld in het artikel over charts, images en drawings

HotXLS leest en schrijft XLS, XLSX, ODS en CSV natively in Delphi en C++Builder zonder dat er een Excel-installatie nodig is, en het invoegen van afbeeldingen loopt in elk daarvan door dit header-pad. Formaatondersteuning en licentiedetails staan op de productpagina van de HotXLS Delphi spreadsheet component