Teknisk artikel

Delphi TIFF-afkodning: BigTIFF og fliseopdelt TIFF afvises

PDFlibPas afkoder TIFF med en håndskrevet Object Pascal-parser i stedet for en libtiff-binding, og version 3.534.1 skærpede præcis dér, hvor parseren afviser input. BigTIFF magic 43 afvises nu ved navn, TileOffsets og TileByteCounts afvises under tagparsing, og alle buffere dimensioneres med Int64-aritmetik under et dekodningsloft på 256 MiB

Fejlen, der lukkes her, viser sig aldrig i et laboratorium. Den viser sig som en scanningsgateway, der har kørt stille i tre år, indtil en kunde sender et geodætisk arkiv eller et whole-slide medicinsk billede igennem. Filen har en gyldig TIFF-header. Den kan parses. Resultatet er en side med stribet støj eller en allokering på flere gigabyte, der bringer tjenesten ned, og undervejs erklærer intet inputtet som ugyldigt. Det er den fejlbillede, det er værd at bygge imod: ikke et crash, men et forkert svar leveret med overbevisning

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

Fordi byte-ordensmarkøren deles af begge dialekter. Klassisk TIFF og BigTIFF åbner begge med II eller MM, og feltet, der reelt adskiller dem, er 16-bit magic lige bagefter: 42 for klassisk TIFF som defineret i TIFF 6.0-specifikationen, 43 for BigTIFF med dets 64-bit offsets. En loader skrevet som FValidTIFF := PopWord = 42 tager ikke fejl om klassisk TIFF, men den smelter to meget forskellige afvisninger sammen til én stille boolean, så en BigTIFF bliver uadskillelig fra en afkortet JPEG, som nogen har omdøbt. PDFlibPas adskiller nu tilfældene og registrerer hver enkelt i TPDFTIFF.LastError: en header kortere end fire bytes, en ugyldig byte-ordensmarkør, magic 43 og enhver anden magic-værdi giver alle forskellig tekst. Biblioteket afkoder stadig ikke BigTIFF, og at sige det rent ud er hele pointen. Kalderen får forskellen mellem "dette er ikke en TIFF" og "dette er en TIFF, hvis 64-bit offset-layout den indbyggede afkoder ikke implementerer", og det er forskellen på en supportsag, du kan besvare i ét svar, og en, der bliver til en uges gætterier

PDFlibPas TIFF-loader læser byte-ordensmarkøren og 16-bit magic hver for sig, så en kort header, en ugyldig markør, BigTIFF magic 43 og enhver anden magic-værdi hver giver forskellig LastError-tekst i stedet for én stille boolean
Klassisk TIFF og BigTIFF åbner med samme byte-ordensmarkør, så PDFlibPas adskiller fire afvisningstilfælde og navngiver 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;

Fliser er en anden geometri, ikke endnu et offsets-array

PDFlibPas afviser fliseopdelt TIFF under tagparsing, før nogen pixeldata røres. Den genvej, der lokker fejlen til, er nem at se: tag 324 (TileOffsets) og tag 325 (TileByteCounts) er arrays af file-offsets og byteantal, strukturelt identiske med strip-arrays, så at lade de eksisterende strip-felter pege på dem koster to linjer og kompilerer rent. Det er også forkert. Fliser danner et todimensionelt gitter med udfyldte kantblokke, deres egen rækkestride inde i hver flise og slet ingen RowsPerStrip-semantik, som afsnittet om fliseopdelte billeder i TIFF 6.0 forklarer. At fodre en strip-afkoder med flisedata fejler derfor ikke højt. SimpleExtract og CompDecode vandrer gennem dataene med forkert stride og producerer et billede med de rigtige dimensioner og de forkerte pixels. Den ældre kode forstærkede det ved at føre StripsAreTiles, ColumnsPerTile og RowsPerTile i TTIFFPage: flisegeometri registreret af en afkoder uden nogen fliseassembler bagved. I 3.534.1 rejser håndtererne for tag 324 og 325 flisefejlen og opgiver IFD'en straks, så afvisningen bærer ordet "fliseopdelt" i stedet for uger senere at dukke op som en renderingsklage

PDFlibPas sammenligner strip-layout, hvor fuldbreddede bånd deler én rækkestride, med fliselayout, et todimensionelt gitter af udfyldte kantblokke, og afviser tag 324 og 325 under tagparsing, før nogen pixeldata røres
Flise-arrays ser strukturelt identiske ud med strip-arrays, og det er derfor, fodring af en strip-afkoder med dem giver de rigtige dimensioner og de forkerte pixels

Én dimensionsgrænse er ikke et hukommelsesbudget

At klemme bredde og højde til 65.535 hver er nødvendigt, men langt fra tilstrækkeligt, fordi mængden, der driver allokeringen, er et produkt. RowsPerStrip * Width * SamplesPerPixel kan løbe over i 32-bit aritmetik længe før nogen af siderne når sin egen grænse, og selv uden overflow kan det navngive en allokering, ingen tjeneste burde forsøge. PDFlibPas beregner rækkebytes i Int64 og håndhæver tre lofter samtidig: 65.535 per dimension, 32 farvekomponenter og 256 MiB dekodede bytes

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

// inde 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 dér betyder mere end konstanterne selv. Højdetesten er skrevet som en division frem for en multiplikation, så det overdimensionerede produkt dannes aldrig. En RowsPerStrip under 1 eller over billedhøjden normaliseres først til højden, hvilket er den single-strip-læsning, TIFF 6.0 allerede antyder, og som stopper et fjendtligt tag fra at puste strip-bufferen op. Og rutinen er delt: ValidatePageForDecode køres i slutningen af tagparsingen og igen ved indgangen til både SimpleExtract og CompDecode, så kode, der når direkte frem til en afkoder, ikke kan gå uden om budgettet. Det er samme regel, som PDFlibPas følger ved parsing af ikke-betroede PDF-objektgrafer, fordi en grænse, der håndhæves ved én af tre døre, ikke er en grænse

PDFlibPas dimensionerer hver TIFF-buffer med Int64-aritmetik, tester billedhøjden med en division, så det overdimensionerede produkt aldrig dannes, og kører samme ValidatePageForDecode-rutine ved tagparsing og ved begge afkoderindgange
Tre lofter, Int64-rækkebyte-aritmetik og én delt valideringsrutine, der nås fra alle tre døre, for en grænse, der håndhæves ved én af tre døre, er ikke en grænse

Hvad skal en kalder kontrollere, før PageInfo læses?

Kontrollér først ValidTIFF, derefter PageCount, og indexér først derefter i PageInfo. En afvist fil kan lade PageCount stå på nul, og GetPageInfo besvarer et indeks uden for intervallet med en uinitialiseret TTIFFPage-post, så en fejlsti, der læser opløsning eller sampletal på vej til at rapportere fejlen, ender med at læse støj. Version 3.534.1 fikset begge kaldere inde i biblioteket: billedimportstien læser XRes og YRes kun inde i den gyldige gren, og TPDFlib.GetImagePageCount kræver ValidTIFF i stedet for at stole på et ikke-nul sidetal alene. Nedstrøms er Options-argumentet til AddImageFromFile sidenummeret med 1 som udgangspunkt for en multipage TIFF, så GetImagePageCount skal være til at stole på, før løkken starter og ikke efter. Nul sider er nu et rigtigt svar, der betyder "intet her kan dekodes", ikke en tilfældighed ved en tidlig returnering, hvilket betyder mest, når du sammenføjer og fletter duplex-scanningsbatches, og et enkelt tavst fejldekodet ark ville lande forkert

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, fliseopdelt layout eller 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;

Byg afkoderen selv eller link libtiff?

PDFlibPas beholder den indbyggede afkoder, og den afgørende faktor er platformsrækkevidde frem for ophav. Cirka 1.873 linjer Object Pascal kompilerer alle steder, compileren kan nå: Win32, Win64, macOS, iOS, Android og FPC på Linux. libtiff 4.7.1 er omkring 30.000 linjer C fordelt over 34 tif_*.c-oversættelsesenheder, og de forudbyggede objektfiler, der findes i dag, dækker kun Windows. At tage den til efterretning ville bytte fuld TIFF-dækning til en liste over understøttede platforme, der skrumper til de maskiner, der kan køre C-værktøjskæden, plus en linkeromgang, ingen endnu har gennemgået

Hvad det koster, er det værd at sige uden pynt. Den indbyggede afkoder håndterer det, scannet dokumentarbejde faktisk producerer: CCITT Group 3 endimensional og todimensional, Group 4, LZW, Deflate, PackBits og JPEG-in-TIFF, på tværs af WhiteIsZero, BlackIsZero, RGB, palette og CMYK-fotometri med Predictor 1 og 2. Denne slags data matcher PDF-filtrene i ISO 32000-1 §7.4.4 og §7.4.6, og derfor vejer TIFF-frontenden så tungt i en scanningspipeline. Den håndterer ikke BigTIFF, fliser, floating-point Predictor 3, PixarLog og SGILog, gammeldags JPEG-komprimering 6 og sub-IFD-pyramider. Siden 3.534.1 er hver af dem en navngiven afvisning frem for et forkert billede, og biblioteket fastholder en skriftlig triggerliste for at genåbne libtiff-beslutningen:

  • en kunde melder en BigTIFF-fil og har brug for native understøttelse frem for et konverteringstrin
  • en kunde melder fliseopdelt TIFF fra medicinske, GIS- eller industrielle kilder og har brug for, at den dekodes på stedet
  • en kunde melder floating-point Predictor 3 TIFF
  • en offentliggjort sårbarhed rammer de indbyggede CCITT- eller LZW-afkodningsstier
  • cross-platform-argumentet holder op med at gælde, enten fordi understøttelsen af macOS, iOS og Android udgår, eller fordi en genanvendelig libtiff-integration allerede dækker macOS og Linux

Migrationen selv er afgrænset snarere end hypotetisk: en USE_LIBTIFF-betingelse ville holde TPDFTIFFs offentlige overflade intakt, sende LoadFromStream gennem TIFFClientOpen med stream-callbacks og lade Pascal-parseren være ikke-Windows-fallback. Indtil en af de triggers reelt indtræffer, køber vedligeholdelse af to afkodere og en fordoblet testmatrix intet, en kunde kan mærke. At udskyde en omkostning med flugtvejen allerede nedskrevet er noget andet end at ignorere den

Hvor det efterlader en pipeline for scannede dokumenter

Behandl TPDFTIFF som en port frem for en konverter. Indlæs filen, læs ValidTIFF, og log LastError ordret, hver gang den er falsk, for den streng er nu den korteste vej fra en feltrapport til en diagnose. Filer, der falder gennem porten, kan stadig reddes ved at konvertere dem upstream, hvilket er det praktiske svar for BigTIFF- og fliseopdelte kilder i dag. For input helt uden for TIFF tager PDFlibPas en separat rute gennem sin AVIF-, HEIF- og JPEG XL-billedinput-sti, så spørgsmålet om, hvilken afkoder der ejer hvilket format, forbliver eksplicit i stedet for at opstå tilfældigt

Alt dette ligger bag den almindelige billed-API, så en dokumentpipeline får den strammere grænse uden at ændre én linje kaldende kode, udover at kontrollere sidetallet, den burde have kontrolleret alligevel. Hvis du overvejer en native TIFF til PDF-sti til Delphi eller C++Builder, er den fulde komponent og dens billedhåndtering dokumenteret på siden om PDF Library til Delphi