Technisch artikel

Delphi TIFF-decoder verhard: BigTIFF en getegelde TIFF

PDFlibPas decodeert TIFF met een handgeschreven Object Pascal-parser in plaats van een libtiff-binding, en versie 3.534.1 heeft precies aangedraaid waar die parser input weigert. BigTIFF magic 43 wordt nu bij naam geweigerd, TileOffsets en TileByteCounts worden al bij het parsen van tags afgewezen, en elke buffer wordt via Int64-rekensommaties gemeten onder een decodeerplafond van 256 MiB

Het defect dat hiermee dichtgaat ziet u nooit in een lab. Het verschijnt als een scangateway die drie jaar stilletjes heeft gedraaid, totdat een klant er een geospatiaal archief of een whole-slide medische afbeelding doorheen stuurt. Het bestand heeft een legitieme TIFF-header. Het parst gewoon. Wat eruit komt is een pagina met streperige ruis, of een toewijzing van meerdere gigabyte die de dienst platlegt, en nergens onderweg heeft ooit iemand de input ongeldig verklaard. Dat is de faalvorm waar u tegen moet bouwen: geen crash, maar een fout antwoord dat vol overtuiging wordt geleverd

Waarom bewijst II of MM niet dat u een klassieke TIFF hebt?

Omdat de bytevolgordemarker door beide dialecten wordt gedeeld. Klassieke TIFF en BigTIFF openen beide met II of MM, en het veld dat ze werkelijk onderscheidt is de 16-bits magic die er meteen op volgt: 42 voor klassieke TIFF zoals de specificatie TIFF 6.0 die definieert, 43 voor BigTIFF met zijn 64-bits offsets. Een loader geschreven als FValidTIFF := PopWord = 42 doet niets verkeerd voor klassieke TIFF, maar hij drukt twee totaal verschillende weigeringen samen tot één stille boolean, waardoor een BigTIFF niet meer te onderscheiden is van een afgekapte JPEG die iemand heeft hernoemd. PDFlibPas splitst de gevallen nu op en legt elk ervan vast in TPDFTIFF.LastError: een header korter dan vier bytes, een ongeldige bytevolgordemarker, magic 43 en elke andere magic-waarde leveren allemaal aparte tekst op. De library decodeert BigTIFF nog steeds niet, en dat ronduit zeggen is juist het punt. De aanroeper krijgt het verschil tussen "dit is geen TIFF" en "dit is een TIFF waarvan de ingebouwde decoder de 64-bits offsetindeling niet implementeert", en dat is het verschil tussen een supportticket dat in één reactie is afgehandeld en een ticket dat uitgroeit tot een week gokken

De TIFF-loader van PDFlibPas leest de bytevolgordemarker en de 16-bits magic apart, zodat een korte header, een ongeldige marker, BigTIFF magic 43 en elke andere magic-waarde elk een eigen LastError-tekst opleveren in plaats van één stille boolean
Klassieke TIFF en BigTIFF openen met dezelfde bytevolgordemarker, dus PDFlibPas splitst vier weigeringsgevallen en benoemt elk ervan in LastError
var
  Tiff: TPDFTIFF;
  Page: Integer;
begin
  Tiff := TPDFTIFF.Create;
  try
    Tiff.LoadFromFile('inbox\scan-0417.tif');
    if not Tiff.ValidTIFF then
      raise Exception.Create('TIFF rejected: ' + Tiff.LastError);
    if Tiff.PageCount < 1 then
      raise Exception.Create('TIFF carries no decodable page');
    for Page := 1 to Tiff.PageCount do
      Writeln(Format('page %d: %dx%d, %d spp',
        [Page,
         Tiff.PageInfo[Page].Width,
         Tiff.PageInfo[Page].Height,
         Tiff.PageInfo[Page].SamplesPerPixel]));
  finally
    Tiff.Free;
  end;
end;

Tiles zijn een andere geometrie, niet nog een offsets-array

PDFlibPas weigert getegelde TIFF al tijdens het parsen van tags, nog voordat er ook maar één pixeldata wordt aangeraakt. De snelkoppeling die de bug uitnodigt is makkelijk te zien: tag 324 (TileOffsets) en tag 325 (TileByteCounts) zijn arrays van bestandsoffsets en byteaantallen, structureel identiek aan de striparrays, dus de bestaande stripvelden ernaartoe wijzen kost twee regels en compileert schoon. Het is ook gewoon fout. Tiles vormen een tweedimensionaal raster met opgevulde randblokken, hun eigen rijstride binnen elke tile, en in het geheel geen RowsPerStrip-semantiek, zoals de tiled-image-sectie van TIFF 6.0 uit de doeken doet. Tilepayloads aan een stripdecoder voeren faalt daarom niet luid. SimpleExtract en CompDecode lopen door de data met de verkeerde stride en spuwen een beeld uit met de juiste afmetingen en de verkeerde pixels. De oudere code verergerde dit door StripsAreTiles, ColumnsPerTile en RowsPerTile in TTIFFPage bij te houden: tilegeometrie geregistreerd door een decoder zonder tile-assembler erachter. In 3.534.1 geven de handlers voor tags 324 en 325 de tilefout en verlaten ze de IFD onmiddellijk, zodat de weigering het woord "tiled" draagt in plaats van weken later als renderklacht boven water te komen

PDFlibPas zet de stripindeling, waarbij volledig brede banden één rijstride delen, af tegen de tileindeling, een tweedimensionaal raster van opgevulde randblokken, en weigert tags 324 en 325 tijdens het parsen van tags voordat er pixeldata wordt aangeraakt
Tilearrays zien er structureel identiek uit tot striparrays, en daarom levert het voeren ervan aan een stripdecoder de juiste afmetingen met de verkeerde pixels op

Eén afmetingsklem is geen geheugenbudget

Breedte en hoogte elk klemmen op 65.535 is nodig en nog lang niet voldoende, want de grootheid die de toewijzing stuurt is een product. RowsPerStrip * Width * SamplesPerPixel kan in 32-bits rekenwerk veel eerder overlopen dan dat een van de kanten zijn eigen limiet bereikt, en zelfs zonder overloop kan het een toewijzing benoemen die geen enkele dienst zou moeten wagen. PDFlibPas rekent rijbytes in Int64 en handhaaft drie plafonds tegelijk: 65.535 per afmeting, 32 kleurcomponenten en 256 MiB aan gedecodeerde bytes

const
  PDFLIB_TIFF_MAX_IMAGE_DIM = 65535;
  PDFLIB_TIFF_MAX_COLOR_COMPONENTS = 32;
  PDFLIB_TIFF_MAX_DECODED_IMAGE_BYTES = 256 * 1024 * 1024;

// binnen TPDFTIFF.ValidatePageForDecode
BitsPerPixel := Int64(P.BitsPerSample) * P.SamplesPerPixel;
RowBytes := (Int64(P.Width) * BitsPerPixel + 7) div 8;
if (RowBytes < 1) or
   (RowBytes > PDFLIB_TIFF_MAX_DECODED_IMAGE_BYTES) or
   (Int64(P.Height) > PDFLIB_TIFF_MAX_DECODED_IMAGE_BYTES div RowBytes) then
  Exit(False);

DecodedBytes := RowBytes * P.RowsPerStrip;
if (DecodedBytes < 1) or
   (DecodedBytes > PDFLIB_TIFF_MAX_DECODED_IMAGE_BYTES) or
   (DecodedBytes > MaxInt) then
  Exit(False);

Drie details daar tellen zwaarder dan de constanten zelf. De hoogtetest is geschreven als een deling in plaats van een vermenigvuldiging, zodat het te grote product nooit vorm krijgt. Een RowsPerStrip onder 1 of boven de beeldhoogte wordt eerst genormaliseerd naar de hoogte, wat de single-strip-lezing die TIFF 6.0 al impliceert is en wat voorkomt dat een vijandige tag de stripbuffer laat opzwellen. En de routine wordt gedeeld: ValidatePageForDecode draait aan het eind van het tagparsen en opnieuw bij de instap van zowel SimpleExtract als CompDecode, dus code die rechtstreeks een decoder bereikt kan niet om het budget heen. Dat is dezelfde regel die PDFlibPas volgt bij het parsen van onbetrouwbare PDF-objectgrafieken, want een limiet die aan één van drie deuren wordt gehandhaafd is geen limiet

PDFlibPas meet elke TIFF-buffer via Int64-rekensommaties, toetst de beeldhoogte met een deling zodat het te grote product nooit vorm krijgt, en draait dezelfde routine ValidatePageForDecode bij het tagparsen en bij beide decoderinstappen
Drie plafonds, Int64-rijbytenrekening en één gedeelde validatieroutine die vanaf alle drie de deuren wordt bereikt, want een limiet die aan één van drie deuren wordt gehandhaafd is geen limiet

Wat moet een aanroeper controleren vóór het lezen van PageInfo?

Controleer eerst ValidTIFF, daarna PageCount, en pas daarna indexeert u PageInfo. Een geweigerd bestand kan PageCount op nul laten, en GetPageInfo antwoordt op een index buiten bereik met een ongeïnitialiseerd TTIFFPage-record, dus een foutpad dat op weg naar de foutrapportage toch resolution of sampleaantallen leest, eindigt met het lezen van ruis. Versie 3.534.1 heeft beide aanroepers binnen de library gefixed: het importpad voor afbeeldingen leest XRes en YRes alleen binnen de geldige tak, en TPDFlib.GetImagePageCount eist ValidTIFF in plaats van op eigen houtje een niet-nul paginatelling te vertrouwen. Stroomafwaarts is het Options-argument van AddImageFromFile het paginanummer vanaf 1 voor een multipage TIFF, dus GetImagePageCount moet betrouwbaar zijn voordat de lus begint, niet erna. Nul pagina's is nu een echt antwoord dat betekent "hier is niets decodeerbaar", geen toeval van een vroege return, en dat telt het zwaarst wanneer u duplex scanbatches collationeert en interleavet en één stilletjes verkeerd gedecodeerd vel op de verkeerde plek zou belanden

var
  Pdf: TPDFlib;
  Pages, I, ImageID: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    Pages := Pdf.GetImagePageCount('inbox\scan-0417.tif');
    if Pages < 1 then
      Exit;  // slechte header, BigTIFF, tiled-indeling of over budget
    Pdf.NewDocument;
    for I := 1 to Pages do
    begin
      Pdf.NewPage;
      ImageID := Pdf.AddImageFromFile('inbox\scan-0417.tif', I);
      if ImageID > 0 then
      begin
        Pdf.SelectImage(ImageID);
        Pdf.DrawImage(0, 0, 595, 842);
      end;
    end;
    Pdf.SaveToFile('scan-0417.pdf');
  finally
    Pdf.Free;
  end;
end;

Zelf de decoder bouwen of libtiff linken?

PDFlibPas houdt de ingebouwde decoder aan, en de doorslaggevende factor is platformbereik, niet makerschap. Ruim 1.873 regels Object Pascal compileren overal waar de compiler komt: Win32, Win64, macOS, iOS, Android, en FPC op Linux. libtiff 4.7.1 is zo'n 30.000 regels C verspreid over 34 tif_*.c vertaaleenheden, en de prebuilt objectbestanden die er vandaag zijn dekken uitsluitend Windows. Het adopteren ervan zou volledige TIFF-dekking inruilen voor een lijst ondersteunde platforms die krimpt tot welke machine de C-toolchain kan draaien, plus een linkerpas die nog niemand heeft doorlopen

Wat dat kost verdient het om zonder draaitechniek te benoemen. De ingebouwde decoder verwerkt wat scanwerk in de praktijk voortbrengt: CCITT Group 3 een- en tweedimensionaal, Group 4, LZW, Deflate, PackBits en JPEG-in-TIFF, over WhiteIsZero-, BlackIsZero-, RGB-, palet- en CMYK-fotometrieën met Predictor 1 en 2. Die payloads sluiten aan bij de PDF-filters uit ISO 32000-1 §7.4.4 en §7.4.6, en daarom weegt de TIFF-voorzijde zo zwaar in een scanpijplijn. Wat hij niet aankan is BigTIFF, tiles, floating-point Predictor 3, PixarLog en SGILog, ouderwetse JPEG-compressie 6, en sub-IFD-piramides. Sinds 3.534.1 is elk daarvan een benoemde weigering in plaats van een verkeerd beeld, en de library bewaart een schriftelijke triggerlijst om de libtiff-beslissing te heropenen:

  • een klant meldt een BigTIFF-bestand en heeft native ondersteuning nodig in plaats van een conversiestap
  • een klant meldt getegelde TIFF uit medische, GIS- of industriële bronnen en wil hem ter plekke gedecodeerd
  • een klant meldt floating-point Predictor 3 TIFF
  • een gepubliceerde kwetsbaarheid landt op de ingebouwde CCITT- of LZW-decodeerpaden
  • het crossplatformargument houdt op te gelden, doordat ondersteuning voor macOS, iOS en Android wordt geschrapt, of doordat een herbruikbare libtiff-integratie macOS en Linux al dekt

De migratie zelf is begrensd in plaats van hypothetisch: een USE_LIBTIFF-conditionele zou het publieke oppervlak van TPDFTIFF intact laten, LoadFromStream via TIFFClientOpen met stream-callbacks leiden, en de Pascal-parser als niet-Windows-fallback laten staan. Tot een van die triggers echt afgaat, zijn twee decoders onderhouden en een verdubbelde testmatrix niets kopen wat een klant kan voelen. Een kostenpost uitstellen met de vluchtroute alvast op papier is iets anders dan haar negeren

Waar dit een scanpijplijn laat staan

Behandel TPDFTIFF als een poort in plaats van een converter. Laad het bestand, lees ValidTIFF, en log LastError letterlijk zodra die false is, want die string is nu de kortste route van een veldrapport naar een diagnose. Bestanden die aan de poort falen blijven herstelbaar door ze stroomopwaarts te converteren, en dat is vandaag het praktische antwoord voor BigTIFF- en tiled-bronnen. Voor input die helemaal buiten TIFF valt, neemt PDFlibPas een aparte route via zijn AVIF-, HEIF- en JPEG XL-beeldinput, zodat de vraag welke decoder welk formaat bezit expliciet blijft in plaats van geleidelijk te ontstaan

Al dit zit achter de gewone image-API, dus een documentpijplijn wint de strakkere begrenzing zonder ook maar één regel aanroepcode te veranderen, op de paginatelling na die er al had moeten staan. Wie een native TIFF-naar-PDF-route voor Delphi of C++Builder afweegt, vindt de volledige component en zijn beeldverwerking gedocumenteerd op de pagina PDF Library for Delphi