Technický článek

Zpevnění dekodéru TIFF v Delphi: BigTIFF a dlaždice

PDFlibPas dekóduje TIFF parserem psaným ručně v Object Pascalu namísto vazby na libtiff a verze 3.534.1 zpřísnila přesně ta místa, kde tento parser vstup odmítá. BigTIFF s magickým číslem 43 se nyní odmítá jménem, TileOffsets a TileByteCounts se zavrhují už při parsování značek a každý buffer se rozměřuje aritmetikou Int64 pod stropem 256 MiB pro dekódování

Porucha, kterou tahle změna zavírá, se v laboratoři nikdy neukáže. Objeví se jako skenovací brána, která tři roky tiše běží, dokud zákazník nepustí přes ni geografický archiv nebo celosnímkový zdravotnický snímek. Soubor má legitimní hlavičku TIFF. Zparsuje se. Ven vyletí stránka pruhovaného šumu, nebo vícegigabajtová alokace, která shodí službu, a nic na té cestě nikdy neoznačilo vstup za neplatný. To je tvar poruchy, proti kterému se vyplatí inženýrsky bránit: ne pád, ale sebejistě doručená špatná odpověď

Proč II nebo MM neprokazuje, že máte klasické TIFF?

Protože značku byte order sdílejí oba dialekty. Klasické TIFF i BigTIFF začínají II nebo MM a polem, které je skutečně odlišuje, je šestnáctibitová magie bezprostředně za ním: 42 pro klasické TIFF definované ve specifikaci TIFF 6.0, 43 pro BigTIFF se 64bitovými offsety. Loader napsaný jako FValidTIFF := PopWord = 42 se u klasického TIFF nemýlí, ale sbalí dvě velmi odlišná zamítnutí do jednoho tichého booleanu, takže BigTIFF je nerozeznatelný od zkráceného JPEGu, který někdo jen přejmenoval. PDFlibPas nyní případy rozděluje a každou zvlášť zapisuje do TPDFTIFF.LastError: hlavička kratší než čtyři bajty, neplatná značka byte order, magie 43 i každá jiná magická hodnota dávají odlišné texty. Knihovna BigTIFF stále nedešifruje a říct to napřímo je přesně ten smysl. Volající dostane rozdíl mezi „toto není TIFF“ a „toto je TIFF, jehož 64bitové rozložení offsetů vestavěný dekodér neimplementuje“, což je rozdíl mezi tickety, na který odpovíte jednou odpovědí, a tickety, který se změní na týden hádání

Loader TIFF v PDFlibPas čte značku byte order a šestnáctibitovou magii zvlášť, takže krátká hlavička, neplatná značka, BigTIFF s magií 43 i každá jiná magická hodnota dávají odlišný text LastError místo jednoho tichého booleanu
Klasické TIFF i BigTIFF začínají stejnou značkou byte order, takže PDFlibPas rozděluje čtyři případy odmítnutí a každý pojmenuje v 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;

Dlaždice jsou jiná geometrie, ne další pole offsetů

PDFlibPas zamítá dlaždicovaný TIFF už při parsování značek, dřív než se dotkne jakéhokoli pixelu. Zkratka, která chybu zve, je snadno vidět: značky 324 (TileOffsets) a 325 (TileByteCounts) jsou pole offsetů a délek v bajtech, strukturálně shodné s poli pruhů, takže nasměrovat stávající pole pruhů na ně stojí dva řádky a přeloží se to čistě. Je to ale špatně. Dlaždice tvoří dvojrozměrnou mřížku s vyplněnými okrajovými bloky, mají vlastní krok řádku uvnitř každé dlaždice a žádnou sémantiku RowsPerStrip, jak rozebírá sekce o dlaždicovaných snímcích ve specifikaci TIFF 6.0. Krmení dlaždicových dat dekodéru pruhů proto nehavaruje hlasitě. SimpleExtract a CompDecode projdou data špatným krokem a vyplivnou snímek se správnými rozměry a špatnými pixely. Starší kód to ještě prohluboval tím, že držel StripsAreTiles, ColumnsPerTile a RowsPerTile v TTIFFPage: geometrie dlaždic zapsaná dekodérem, který žádný skladač dlaždic nemá. Ve 3.534.1 obslužné rutiny značek 324 a 325 vyhodí chybu dlaždic a IFD okamžitě opustí, takže odmítnutí nese slovo „tiled“ a nevyplave o týdny později jako stížnost na vykreslování

PDFlibPas srovnává rozložení do pruhů, kde celošířské pásy sdílejí jeden krok řádku, s rozložením do dlaždic, tedy dvourozměrnou mřížkou s vyplněnými okrajovými bloky, a zamítá značky 324 a 325 už při parsování značek, dřív než se dotkne pixelů
Pole dlaždic vypadají strukturálně shodně s poli pruhů, a právě proto dekodér pruhů z nich vytvoří správné rozměry a špatné pixely

Jedno omezení rozměru není rozpočet paměti

Omezit šířku a výšku na 65 535 je nutné a zdaleka to nestačí, protože veličina řídící alokaci je součin. RowsPerStrip * Width * SamplesPerPixel může přetéct 32bitovou aritmetiku dřív, než některá strana dosáhne vlastního limitu, a i bez přetečení může popsat alokaci, kterou by si žádná služba neměla dovolit. PDFlibPas počítá bajty řádku v Int64 a vynucuje tři stropy společně: 65 535 na rozměr, 32 barevných složek a 256 MiB dekódovaných bajtů

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

// uvnitř 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);

Tři detaily tam hrají větší roli než konstanty samy. Test výšky je zapsán jako dělení, ne jako násobení, takže převislý součin vůbec nevznikne. RowsPerStrip pod 1 nebo nad výšku snímku se nejdřív normalizuje na výšku, což je čtení do jednoho pruhu, které TIFF 6.0 přímo implikuje, a zároveň to brání nepřátelské značce nafouknout buffer pruhu. A rutina je sdílená: ValidatePageForDecode běží na konci parsování značek a znovu na vstupu SimpleExtract i CompDecode, takže kód, který do dekodéru vejde přímo, nemůže kolem rozpočtu obejít. Totožné pravidlo PDFlibPas dodržuje při parsování nedůvěryhodných grafů PDF objektů, protože limit vynucený u jedněch ze tří dveří není limit

PDFlibPas rozměřuje každý TIFF buffer přes aritmetiku Int64, testuje výšku snímku dělením, takže převislý součin nikdy nevznikne, a spouští tutéž rutinu ValidatePageForDecode při parsování značek i na vstupu obou dekodérů
Tři stropy, Int64 aritmetika bajtů řádku a jedna sdílená validační rutina dostupná ze všech tří dveří, protože limit vynucený u jedněch ze tří dveří není limit

Co musí volající zkontrolovat před čtením PageInfo?

Nejdřív ValidTIFF, pak PageCount a teprve potom indexujte PageInfo. Zamítnutý soubor může nechat PageCount na nule a GetPageInfo odpoví na index mimo rozsah neinicializovaným záznamem TTIFFPage, takže chybová cesta, která cestou k nahlášení poruchy čte rozlišení nebo počty vzorků, nakonec čte šum. Ve 3.534.1 se opravili oba volající uvnitř knihovny: cesta importu snímku čte XRes a YRes jen uvnitř platné větve a TPDFlib.GetImagePageCount vyžaduje ValidTIFF místo toho, aby věřila nenulovému počtu stránek samému o sobě. Níže po proudu je argument Options funkce AddImageFromFile číslem stránky od jedničky pro vícestránkový TIFF, takže GetImagePageCount musí být důvěryhodná dřív, než se smyčka rozjede, a ne až po ní. Nula stránek je teď skutečná odpověď ve smyslu „tady se nic nedá dekódovat“, ne artefakt předčasného returnu, což se nejvíc projeví, když slučujete a prokládáte dávky duplexních skenů a jedna potichu špatně dekódovaná strana by skončila na špatném místě

var
  Pdf: TPDFlib;
  Pages, I, ImageID: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    Pages := Pdf.GetImagePageCount('inbox\scan-0417.tif');
    if Pages < 1 then
      Exit;  // špatná hlavička, BigTIFF, dlaždicové rozložení nebo přes rozpočet
    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;

Vlastní dekodér, nebo linkovat libtiff?

PDFlibPas drží vestavěný dekodér a rozhodujícím faktorem je dosah napříč platformami, ne autorství. Zhruba 1 873 řádků Object Pascalu se přeloží všude, kam se dostane kompilátor: Win32, Win64, macOS, iOS, Android a FPC na Linuxu. libtiff 4.7.1 je kolem 30 000 řádků C roztahaných do 34 překladových celků tif_*.c a předkompilované objektové soubory, které dnes existují, pokrývají jen Windows. Přijetím byste vyměnili kompletní pokrytí TIFF za seznam podporovaných platforem, který se smrskne na stroje, kde běží C toolchain, plus průchod linkerem, který ještě nikdo neprošel

Co to stojí, stojí říct bez laciného marketingu. Vestavěný dekodér zvládá, co práce se skenovanými dokumenty reálně produkuje: CCITT Group 3 jednorozměrný i dvourozměrný, Group 4, LZW, Deflate, PackBits a JPEG v TIFF, přes fotometrie WhiteIsZero, BlackIsZero, RGB, paletu a CMYK s Predictorem 1 a 2. Tyto payloady se kryjí s PDF filtry v ISO 32000-1 §7.4.4 a §7.4.6, a proto nese TIFF front-end ve skenovací pipeline takovou váhu. Nezvládá BigTIFF, dlaždice, predictor 3 pro plovoucí desetinnou čárku, PixarLog a SGILog, starou JPEG kompresi 6 a pyramidy pod-IFD. Od 3.534.1 je každá z těch věcí pojmenované odmítnutí místo špatného snímku a knihovna si vede písemný seznam spouštěčů pro znovuotevření rozhodnutí o libtiff:

  • zákazník nahlásí BigTIFF soubor a potřebuje nativní podporu místo kroku s konverzí
  • zákazník nahlásí dlaždicovaný TIFF z medicínských, GIS nebo průmyslových zdrojů a potřebuje ho dekódovat na místě
  • zákazník nahlásí TIFF s predictorem 3 pro plovoucí desetinnou čárku
  • zveřejněná zranitelnost dopadne na vestavěné cesty dekódování CCITT nebo LZW
  • přestane platit argument napříč platformami, ať už protože podpora macOS, iOS a Android skončí, nebo protože existující znovupoužitelná integrace libtiff pokryje macOS a Linux

Samotná migrace je zarámovaná, ne hypotetická: podmíněný USE_LIBTIFF by zachoval veřejný povrch TPDFTIFF nedotčený, směroval LoadFromStream přes TIFFClientOpen se streamovými callbacky a nechal Pascal parser jako fallback pro ne-Windows. Dokud jeden z těch spouštěčů skutečně nezazvoní, údržba dvou dekodérů a dvojnásobné testovací matrice nekupuje nic, co by zákazník pocítil. Odkládat náklad s tím, že úniková cesta už je sepsaná, je něco jiného než náklad ignorovat

Co to znamená pro pipeline skenovaných dokumentů

Berte TPDFTIFF jako bránu, ne jako konvertor. Načtěte soubor, přečtěte ValidTIFF a kdykoli je nepravdivé, logujte LastError doslova, protože tenhle řetězec je dnes nejkratší cesta z hlášení od zákazníka k diagnóze. Soubory, které bránou neprojdou, zůstávají zachránitelné konverzí po proudu, a to je dnes praktická odpověď na BigTIFF i dlaždicované zdroje. Pro vstup mimo TIFF úplně bere PDFlibPas zvlášť cestu přes vstup snímků AVIF, HEIF a JPEG XL, takže otázka, který dekodér vlastní který formát, zůstává výslovná místo toho, aby vznikala sama

Všechno tohle sedí za obyčejným snímkovým API, takže pipeline dokumentů dostane těsnější hranici bez změny jediného řádku volacího kódu, kromě kontroly počtu stránek, kterou stejně měla dělat. Pokud zvažujete nativní cestu TIFF do PDF pro Delphi nebo C++Builder, celá komponenta a její zpracování snímků jsou zdokumentované na stránce PDF Library for Delphi