Technical Article

Delphi TIFF Decoder Hardening: BigTIFF and Tiled TIFF

PDFlibPas decodes TIFF with a hand-written Object Pascal parser rather than a libtiff binding, and version 3.534.1 tightened exactly where that parser refuses input. BigTIFF magic 43 is now rejected by name, TileOffsets and TileByteCounts are refused during tag parsing, and every buffer is sized through Int64 arithmetic under a 256 MiB decode ceiling

The defect this closes never shows up in a lab. It shows up as a scanning gateway that has run quietly for three years, until a customer routes a geospatial archive or a whole-slide medical image through it. The file has a legitimate TIFF header. It parses. What comes out is a page of striped noise, or a multi-gigabyte allocation that takes the service down, and nothing along the way ever declared the input invalid. That is the failure shape worth engineering against: not a crash, but a wrong answer delivered confidently

Why does II or MM not prove you have a classic TIFF?

Because the byte-order marker is shared by both dialects. Classic TIFF and BigTIFF each open with II or MM, and the field that actually distinguishes them is the 16-bit magic immediately after it: 42 for classic TIFF as defined in the TIFF 6.0 specification, 43 for BigTIFF with its 64-bit offsets. A loader written as FValidTIFF := PopWord = 42 is not wrong about classic TIFF, but it collapses two very different rejections into one silent boolean, so a BigTIFF becomes indistinguishable from a truncated JPEG somebody renamed. PDFlibPas now separates the cases and records each one in TPDFTIFF.LastError: a header shorter than four bytes, an invalid byte-order marker, magic 43, and any other magic value all produce distinct text. The library still does not decode BigTIFF, and saying so plainly is the point. The caller gets the difference between "this is not a TIFF" and "this is a TIFF whose 64-bit offset layout the built-in decoder does not implement", which is the difference between a support ticket you can answer in one reply and one that turns into a week of guessing

The PDFlibPas TIFF loader reads the byte-order marker and the 16-bit magic separately, so a short header, an invalid marker, BigTIFF magic 43 and any other magic value each produce distinct LastError text instead of one silent boolean
Classic TIFF and BigTIFF open with the same byte-order marker, so PDFlibPas separates four rejection cases and names each one 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 are a different geometry, not another offsets array

PDFlibPas rejects tiled TIFF while parsing tags, before any pixel data is touched. The shortcut that invites the bug is easy to see: tag 324 (TileOffsets) and tag 325 (TileByteCounts) are arrays of file offsets and byte counts, structurally identical to the strip arrays, so pointing the existing strip fields at them costs two lines and compiles cleanly. It is also wrong. Tiles form a two-dimensional grid with padded edge blocks, their own row stride inside each tile, and no RowsPerStrip semantics whatsoever, as the tiled-image section of TIFF 6.0 spells out. Feeding tile payloads to a strip decoder therefore does not fail loudly. SimpleExtract and CompDecode walk the data with the wrong stride and emit an image with the right dimensions and the wrong pixels. The older code compounded this by keeping StripsAreTiles, ColumnsPerTile and RowsPerTile in TTIFFPage: tile geometry recorded by a decoder with no tile assembler behind it. In 3.534.1 the handlers for tags 324 and 325 raise the tile error and abandon the IFD immediately, so the refusal carries the word "tiled" instead of surfacing weeks later as a rendering complaint

PDFlibPas compares strip layout, where full-width bands share one row stride, with tile layout, a two-dimensional grid of padded edge blocks, and refuses tags 324 and 325 during tag parsing before any pixel data is touched
Tile arrays look structurally identical to strip arrays, which is why feeding them to a strip decoder yields the right dimensions and the wrong pixels

One dimension clamp is not a memory budget

Clamping width and height to 65,535 each is necessary and nowhere near sufficient, because the quantity that drives allocation is a product. RowsPerStrip * Width * SamplesPerPixel can overflow 32-bit arithmetic long before either side reaches its own limit, and even without overflow it can name an allocation no service should attempt. PDFlibPas computes row bytes in Int64 and enforces three ceilings together: 65,535 per dimension, 32 colour components, and 256 MiB of decoded bytes

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

// inside 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);

Three details there matter more than the constants themselves. The height test is written as a division rather than a multiplication, so the oversized product is never formed at all. A RowsPerStrip below 1 or above the image height is normalised to the height first, which is the single-strip reading TIFF 6.0 already implies and which stops a hostile tag from inflating the strip buffer. And the routine is shared: ValidatePageForDecode runs at the end of tag parsing and again at the entry of both SimpleExtract and CompDecode, so code that reaches a decoder directly cannot walk around the budget. That is the same rule PDFlibPas follows when parsing untrusted PDF object graphs, because a limit enforced at one door out of three is not a limit

PDFlibPas sizes every TIFF buffer through Int64 arithmetic, tests the image height with a division so the oversized product is never formed, and runs the same ValidatePageForDecode routine at tag parsing and at both decoder entries
Three ceilings, Int64 row-byte arithmetic and one shared validation routine reached from all three doors, because a limit enforced at one door out of three is not a limit

What must a caller check before reading PageInfo?

Check ValidTIFF first, then PageCount, and only then index PageInfo. A rejected file can leave PageCount at zero, and GetPageInfo answers an out-of-range index with an uninitialised TTIFFPage record, so an error path that reads resolution or sample counts on its way to reporting the failure ends up reading noise. Version 3.534.1 fixed both callers inside the library: the image import path reads XRes and YRes only inside the valid branch, and TPDFlib.GetImagePageCount requires ValidTIFF instead of trusting a non-zero page count on its own. Downstream, the Options argument of AddImageFromFile is the 1-based page number for a multipage TIFF, so GetImagePageCount has to be trustworthy before the loop starts rather than after it. Zero pages is now a real answer meaning "nothing here is decodable", not an accident of an early return, which matters most when you are collating and interleaving duplex scan batches and one silently mis-decoded sheet would land in the wrong position

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

Build the decoder or link libtiff?

PDFlibPas keeps the built-in decoder, and the deciding factor is platform reach rather than authorship. Roughly 1,873 lines of Object Pascal compile anywhere the compiler goes: Win32, Win64, macOS, iOS, Android, and FPC on Linux. libtiff 4.7.1 is around 30,000 lines of C spread over 34 tif_*.c translation units, and the prebuilt object files that exist today cover Windows only. Adopting it would trade complete TIFF coverage for a supported-platform list that shrinks to whatever machine can run the C toolchain, plus a linker pass nobody has walked through yet

What that costs is worth stating without spin. The built-in decoder handles what scanned-document work actually produces: CCITT Group 3 one-dimensional and two-dimensional, Group 4, LZW, Deflate, PackBits and JPEG-in-TIFF, across WhiteIsZero, BlackIsZero, RGB, palette and CMYK photometrics with Predictor 1 and 2. Those payloads line up with the PDF filters in ISO 32000-1 §7.4.4 and §7.4.6, which is why the TIFF front-end carries so much weight in a scanning pipeline. What it does not handle is BigTIFF, tiles, floating-point Predictor 3, PixarLog and SGILog, old-style JPEG compression 6, and sub-IFD pyramids. Since 3.534.1 every one of those is a named refusal rather than a wrong image, and the library keeps a written trigger list for reopening the libtiff decision:

  • a customer reports a BigTIFF file and needs native support rather than a conversion step
  • a customer reports tiled TIFF from medical, GIS or industrial sources and needs it decoded in place
  • a customer reports floating-point Predictor 3 TIFF
  • a published vulnerability lands on the built-in CCITT or LZW decode paths
  • the cross-platform argument stops applying, either because macOS, iOS and Android support is dropped, or because a reusable libtiff integration already covers macOS and Linux

The migration itself is scoped rather than hypothetical: a USE_LIBTIFF conditional would keep the TPDFTIFF public surface intact, route LoadFromStream through TIFFClientOpen with stream callbacks, and leave the Pascal parser as the non-Windows fallback. Until one of those triggers actually fires, maintaining two decoders and a doubled test matrix buys nothing a customer can feel. Deferring a cost with the escape route already written down is a different thing from ignoring it

Where this leaves a scanned-document pipeline

Treat TPDFTIFF as a gate rather than a converter. Load the file, read ValidTIFF, and log LastError verbatim whenever it is false, because that string is now the shortest route from a field report to a diagnosis. Files that fail the gate remain recoverable by converting them upstream, which is the practical answer for BigTIFF and tiled sources today. For input outside TIFF entirely, PDFlibPas takes a separate route through its AVIF, HEIF and JPEG XL image input path, so the question of which decoder owns which format stays explicit instead of emergent

All of this sits behind the ordinary image API, so a document pipeline gains the tighter boundary without changing a line of calling code beyond checking the page count it should already have been checking. If you are weighing a native TIFF-to-PDF path for Delphi or C++Builder, the full component and its image handling are documented on the PDF Library for Delphi page