Teknisk artikkel

Herding av Delphi TIFF-dekoder: BigTIFF og tiled TIFF

PDFlibPas dekoder TIFF med en håndskrevet Object Pascal-parser i stedet for en libtiff-binding, og versjon 3.534.1 strammet til akkurat der parseren nekter inndata. BigTIFF-magi 43 avvises nå ved navn, TileOffsets og TileByteCounts avslås under tag-tolkningen, og hver buffer dimensjoneres med Int64-aritmetikk under et dekodningstak på 256 MiB

Feilen dette lukker viser seg aldri i et labmiljø. Den viser seg som en skannegateway som har kjørt stille i tre år, helt til en kunde sender et geodataarkiv eller et whole-slide medisinsk bilde gjennom den. Filen har en gyldig TIFF-header. Den lar seg tolke. Det som kommer ut er en side med stripete støy, eller en allokering på flere gigabyte som tar tjenesten ned, og ingen sted på veien erklærte inndataene som ugyldige. Det er feilbildet det er verdt å bygge imot: ikke et krasj, men et feilaktig svar levert med selvtillit

Hvorfor beviser ikke II eller MM at du har en klassisk TIFF?

Fordi markøren for byterekkefølge er felles for begge dialektene. Classic TIFF og BigTIFF åpner begge med II eller MM, og feltet som faktisk skiller dem er 16-bit-magien like etterpå: 42 for classic TIFF slik TIFF 6.0-spesifikasjonen definerer det, 43 for BigTIFF med sine 64-bit-offsets. En laster skrevet som FValidTIFF := PopWord = 42 tar ikke feil om classic TIFF, men den slår to svært ulike avvisninger sammen til én stille boolsk verdi, så en BigTIFF blir umulig å skille fra en avkuttet JPEG noen har omdøpt. PDFlibPas skiller nå tilfellene og registrerer hver enkelt i TPDFTIFF.LastError: en header kortere enn fire byte, en ugyldig byterekkefølge-markør, magi 43 og enhver annen magiverdi gir hver sin tekst. Biblioteket dekoder fremdeles ikke BigTIFF, og å si det rent ut er poenget. Kalleren får forskjellen mellom «dette er ikke en TIFF» og «dette er en TIFF hvis 64-bit offset-oppsett den innebygde dekoderen ikke implementerer», som er forskjellen mellom en støttesak du kan svare på i ett svar og en som blir en uke med gjetting

TIFF-lasteren i PDFlibPas leser byterekkefølge-markøren og 16-bit-magien hver for seg, så en kort header, en ugyldig markør, BigTIFF-magi 43 og enhver annen magiverdi gir hver sin LastError-tekst i stedet for én stille boolsk verdi
Classic TIFF og BigTIFF åpner med samme byterekkefølge-markør, så PDFlibPas skiller fire avvisningstilfeller og navngir hver enkelt i 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 er en annen geometri, ikke bare en offsets-array

PDFlibPas avviser tiled TIFF under tag-tolkningen, før noen pikseldata berøres. Snarveien som inviterer til feilen er lett å se: tag 324 (TileOffsets) og tag 325 (TileByteCounts) er arrayer av fil-offsets og byteantall, strukturelt identiske med strip-arrayene, så å la de eksisterende strip-feltene peke på dem koster to linjer og kompilerer rent. Det er også galt. Tiles danner et todimensjonalt rutenett med utfylte kantblokker, eget radsteg inni hver tile og ingen RowsPerStrip-semantikk i det hele tatt, slik tiled-bildeseksjonen i TIFF 6.0 forklarer. Å føre tile-pakker til en strip-dekoder feiler derfor ikke høyt. SimpleExtract og CompDecode går gjennom dataene med feil radsteg og gir ut et bilde med riktige dimensjoner og feil piksler. Den eldre koden forsterket dette ved å føre StripsAreTiles, ColumnsPerTile og RowsPerTile i TTIFFPage: tile-geometri registrert av en dekoder uten noen tile-monterer bak. I 3.534.1 reiser behandlerne for tag 324 og 325 tile-feilen og forlater IFD-en umiddelbart, så avvisningen bærer ordet «tiled» i stedet for å dukke opp uker senere som en gjengivelsesklage

PDFlibPas sammenligner strip-oppsett, der helsidebrede bånd deler ett radsteg, med tile-oppsett, et todimensjonalt rutenett av utfylte kantblokker, og avviser tag 324 og 325 under tag-tolkningen før noen pikseldata berøres
Tile-arrayer ser strukturelt identiske ut med strip-arrayer, og det er derfor de gir riktige dimensjoner og feil piksler når de føres til en strip-dekoder

Én dimensjonsgrense er ikke et minnebudsjett

Å klemme bredde og høyde til 65 535 hver er nødvendig og langt fra tilstrekkelig, for størrelsen som driver allokeringen er et produkt. RowsPerStrip * Width * SamplesPerPixel kan få 32-bit-aritmetikken til å flyte over lenge før en av sidene når sin egen grense, og selv uten overløp kan den navngi en allokering ingen tjeneste burde forsøke. PDFlibPas beregner radbyte i Int64 og håndhever tre tak samtidig: 65 535 per dimensjon, 32 fargekomponenter og 256 MiB dekodede byte

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

// inne i 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);

Tre detaljer der betyr mer enn konstantene selv. Høydetesten er skrevet som en divisjon i stedet for en multiplikasjon, så det overdimensjonerte produktet dannes aldri i det hele tatt. En RowsPerStrip under 1 eller over bildehøyden normaliseres først til høyden, noe som er den enkeltstrip-lesingen TIFF 6.0 allerede antyder og som stopper et fiendtlig tag fra å blåse opp strip-bufferen. Og rutinen er delt: ValidatePageForDecode kjøres på slutten av tag-tolkningen og igjen ved inngangen til både SimpleExtract og CompDecode, så kode som når en dekoder direkte kan ikke gå utenom budsjettet. Det er samme regel PDFlibPas følger ved tolkning av utruelige PDF-objektgrafer, for en grense håndhevet ved én av tre dører er ikke en grense

PDFlibPas dimensjonerer hver TIFF-buffer med Int64-aritmetikk, tester bildehøyden med en divisjon så det overdimensjonerte produktet aldri dannes, og kjører samme ValidatePageForDecode-rutine ved tag-tolkning og ved begge dekoderinngangene
Tre tak, Int64-radbyte-aritmetikk og én delt valideringsrutine som nås fra alle tre dørene, for en grense håndhevet ved én av tre dører er ikke en grense

Hva må en kaller sjekke før den leser PageInfo?

Sjekk ValidTIFF først, deretter PageCount, og først deretter indeksér PageInfo. En avvist fil kan etterlate PageCount på null, og GetPageInfo svarer en indeks utenfor området med en uinitialisert TTIFFPage-post, så en feilbane som leser oppløsning eller prøveantall på vei mot å rapportere feilen ender med å lese støy. Versjon 3.534.1 ordnet begge kallerne inne i biblioteket: bildeimportbanen leser XRes og YRes bare inne i den gyldige greinen, og TPDFlib.GetImagePageCount krever ValidTIFF i stedet for å stole på et ikke-null sideantall på egen hånd. Lenger ned i kjeden er Options-argumentet til AddImageFromFile sidenummeret (1-basert) for en TIFF med flere sider, så GetImagePageCount må være til å stole på før løkken starter, ikke etterpå. Null sider er nå et reelt svar som betyr «ingenting her kan dekodes», ikke en ulykke fra en tidlig retur, noe som betyr mest når du sorterer og fletter tosidige skannebunter og ett ark som feiltolkes i stillhet havner på feil plass

var
  Pdf: TPDFlib;
  Pages, I, ImageID: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    Pages := Pdf.GetImagePageCount('inbox\scan-0417.tif');
    if Pages < 1 then
      Exit;  // ugyldig header, BigTIFF, tiled-oppsett eller over budsjett
    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;

Bygge dekoderen selv eller linke libtiff?

PDFlibPas beholder den innebygde dekoderen, og den avgjørende faktoren er plattformrekkevidde, ikke hvem som har skrevet koden. Rundt 1 873 linjer Object Pascal kompilerer overalt kompilatoren går: Win32, Win64, macOS, iOS, Android og FPC på Linux. libtiff 4.7.1 er rundt 30 000 linjer C spredt over 34 tif_*.c-oversettelsesenheter, og de ferdigbygde objektfilene som finnes i dag dekker bare Windows. Å ta den i bruk bytter full TIFF-dekning mot en liste over støttede plattformer som krymper til enhver maskin som kan kjøre C-verktøykjeden, pluss en linkeromgang ingen har gått gjennom ennå

Hva det koster er verdt å si uten pynt. Den innebygde dekoderen håndterer det skannedokumentarbeid faktisk produserer: CCITT Group 3 endimensjonal og todimensjonal, Group 4, LZW, Deflate, PackBits og JPEG-in-TIFF, på tvers av WhiteIsZero, BlackIsZero, RGB, palett- og CMYK-fotometri med Predictor 1 og 2. Disse pakene samsvarer med PDF-filterne i ISO 32000-1 §7.4.4 og §7.4.6, og det er derfor TIFF-grensesnittet veier så tungt i en skannepipeline. Det den ikke håndterer, er BigTIFF, tiles, flyttalls-Predictor 3, PixarLog og SGILog, gammeldags JPEG-komprimering 6 og sub-IFD-pyramider. Siden 3.534.1 er hver av dem en navngitt avvisning i stedet for et feil bilde, og biblioteket fører en skriftlig utløserliste for å gjenoppta libtiff-beslutningen:

  • en kunde melder inn en BigTIFF-fil og trenger innebygd støtte i stedet for et konverteringssteg
  • en kunde melder inn tiled TIFF fra medisinske, GIS- eller industrielle kilder og trenger den dekodet på stedet
  • en kunde melder inn TIFF med flyttalls-Predictor 3
  • et publisert sikkerhetshull rammer de innebygde CCITT- eller LZW-dekodebanene
  • plattformargumentet gjelder ikke lenger, enten fordi støtten for macOS, iOS og Android faller bort, eller fordi en gjenbrukbar libtiff-integrasjon allerede dekker macOS og Linux

Selve migreringen er avgrenset snarere enn hypotetisk: en USE_LIBTIFF-betingelse ville holde det offentlige grensesnittet til TPDFTIFF intakt, føre LoadFromStream gjennom TIFFClientOpen med stream-callbacks, og la Pascal-parseren stå som ikke-Windows-fallback. Inntil en av disse utløserne faktisk inntreffer, gir det å vedlikeholde to dekodere og en doblet testmatrise ingenting en kunde kan kjenne. Å utsette en kostnad med fluktveien allerede nedskrevet er noe annet enn å ignorere den

Hvor dette etterlater en skannedokument-pipeline

Behandle TPDFTIFF som en port i stedet for en konverterer. Last filen, les ValidTIFF, og logg LastError ordrett når den er usann, for den strengen er nå den korteste veien fra en feltmelding til en diagnose. Filer som ikke passerer porten kan fortsatt reddes ved å konvertere dem oppstrøms, som er det praktiske svaret for BigTIFF- og tiled-kilder i dag. For inndata helt utenfor TIFF tar PDFlibPas en egen vei gjennom AVIF-, HEIF- og JPEG XL-bildeinndata, så spørsmålet om hvilken dekoder som eier hvilket format forblir eksplisitt i stedet for å vokse frem av seg selv

Alt dette ligger bak det vanlige bilde-API-et, så en dokumentpipeline får det tettere skillet uten å endre én linje kallende kode utover å sjekke sideantallet den uansett burde ha sjekket. Vurderer du en innebygd TIFF-til-PDF-vei for Delphi eller C++Builder, er hele komponenten og bildehåndteringen dokumentert på siden for PDF Library for Delphi