Technical Article

TIFF 12-Bit, Lossless and Arithmetic JPEG to PDF in Delphi

PDFlibPas, the PDF Library for Delphi, imports TIFF images whose JPEG compression goes beyond 8-bit baseline: 12-bit extended sequential (SOF1), Huffman lossless (SOF3), arithmetic-coded (SOF9 and SOF10) and old-style TIFF 6.0 JPEG with table offsets. Because PDF DCTDecode cannot carry those processes, PDFlibPas decodes them in native Pascal and embeds the samples losslessly at 8 or 16 bits per component

The full set requires v3.724.0 or later, and it is aimed squarely at TIFFs from scanners, medical and scientific capture software, and archive systems frozen in the 1990s. Below is why these variants cannot be copied into the PDF as-is, how PDFlibPas lands them, and the Pascal traps found along the way

Why can't a 12-bit JPEG go straight into a PDF?

A 12-bit JPEG cannot be passed through because PDF has no filter that is required to decode it. ISO 32000-1 §7.4.8 defines DCTDecode around the JPEG baseline format, with the progressive extension added in PDF 1.3. Nothing in that clause covers 12-bit sample precision, the lossless process of ITU-T T.81 Annex H, or the arithmetic entropy coder of Annex D. A reader that meets an SOF1 12-bit frame or an SOF9 marker inside a DCTDecode stream is entitled to draw nothing, and most viewers do exactly that

For the 8-bit Huffman baseline and progressive strips that make up most JPEG-in-TIFF files, PDFlibPas still validates every scan and passes the compressed bytes through as DCTDecode, which keeps the PDF small. Everything else is decoded. PDFlibPas reads the frame marker and sample precision of the first JPEG block, and when it finds SOF3, SOF9, SOF10 or a precision other than 8, the page goes through the sample decoder instead of the pass-through path. Detecting arithmetic coding by its frame marker matters more than it sounds: a check that looks only at sample precision lets a classic TIFF slip an 8-bit SOF9 strip into the DCTDecode path and produce a PDF that readers cannot display

Decision flow for TIFF JPEG blocks in PDFlibPas for Delphi: the frame marker routes 8-bit scans to DCTDecode and other processes to native decoding
the frame marker, not the sample precision alone, decides whether a TIFF JPEG strip passes through as DCTDecode or gets decoded natively

What PDFlibPas writes into the PDF instead

PDFlibPas turns decoded JPEG blocks into an ordinary image XObject with BitsPerComponent 8 or 16. The samples are stored uncompressed by default; call CompressImages(1) before adding the image and PDFlibPas writes them with FlateDecode. ISO 32000-1 §8.9.5.1 permits 16 bits per component from PDF 1.5, so PDFlibPas raises the document version to at least 1.5 whenever an image with 16-bit samples is added. That includes multi-strip and tiled images, whose root object is a Form XObject with no BitsPerComponent of its own to inspect

The mapping is fixed per JPEG process:

  • 12-bit Huffman JPEG (sequential SOF1, progressive SOF2) is dequantised, inverse transformed and widened to 16-bit samples
  • Arithmetic-coded JPEG (sequential SOF9, progressive SOF10) becomes 8-bit samples at 8-bit precision and 16-bit samples at 12-bit precision
  • Huffman lossless JPEG (SOF3) becomes 8-bit or 16-bit samples, matching its declared precision exactly

The widening from 12 to 16 bits uses bit replication, (V shl 4) or (V shr 8), so 4095 maps to 65535 and white stays white instead of topping out at 65520. PDFlibPas never drops these images to 8 bits behind your back. When the library rasterises such a page for its own display surface it uses the high byte of each sample, but the embedded PDF image keeps all sixteen

Mapping of decoded JPEG processes to sample widths in PDFlibPas for Delphi: 12-bit Huffman widens to 16 bits, arithmetic and lossless map per declared precision
each JPEG process has a fixed sample width in the PDF, and 12-bit data is widened by bit replication so white stays white

The price is file size: even with CompressImages(1), Flate-compressed 16-bit samples are larger than the DCT data they came from, and without it they are larger still. For diagnostic and archival workflows that trade is usually right. If size matters more for a given job, the guide to reducing PDF file size in Delphi shows where the bytes actually go

Importing these TIFFs with TPDFlib

The calling code does not change at all. TPDFlib.AddImageFromFile(Const FileName: WideString; Options: Integer): Integer takes the 1-based TIFF page number in Options and returns an image ID, or 0 when the page cannot be imported. Unsupported JPEG variants are rejected before the image is inserted, so a 0 means nothing half-decoded reached the document

uses
  PDFlibrary;

function TiffToPdf(const TiffName, PdfName: WideString): Boolean;
var
  Lib: TPDFlib;
  Page, PageCount, ImageID: Integer;
  DPI, W, H: Double;
begin
  Result := False;
  Lib := TPDFlib.Create;
  try
    Lib.SetMeasurementUnits(0);   // default user space, 1/72 inch
    Lib.SetOrigin(1);             // top-left origin for DrawImage
    Lib.CompressImages(1);        // Flate for decoded samples
    PageCount := Lib.GetImagePageCount(TiffName);
    if PageCount = 0 then
      Exit;
    for Page := 1 to PageCount do
    begin
      ImageID := Lib.AddImageFromFile(TiffName, Page);  // Options = TIFF page
      if ImageID = 0 then
        Exit;                     // unsupported or damaged image data
      Lib.SelectImage(ImageID);
      case Lib.ImageResolutionUnits of
        2: DPI := Lib.ImageHorizontalResolution;          // dots per inch
        3: DPI := Lib.ImageHorizontalResolution * 2.54;   // dots per cm
      else
        DPI := 0;
      end;
      if DPI <= 0 then
        DPI := 300;
      W := Lib.ImageWidth * 72 / DPI;
      H := Lib.ImageHeight * 72 / DPI;
      if Page > 1 then
        Lib.NewPage;
      Lib.SetPageDimensions(W, H);
      Lib.DrawImage(0, 0, W, H);
    end;
    Result := Lib.SaveToFile(PdfName) = 1;
  finally
    Lib.Free;
  end;
end;

No per-format branching is needed, and AddImageFromStream and AddImageFromString follow the same rules for TIFF data held in memory

Old-style JPEG: tables at offsets, strips without headers

Since v3.713.0, PDFlibPas imports old-style JPEG TIFFs (Compression = 6) whose tables come from the JPEGQTables, JPEGDCTables and JPEGACTables offset tags or from a tables-only JPEGInterchangeFormat stream. TIFF 6.0 Section 22 defined that layout, and TIFF Technical Note 2 later replaced it with Compression = 7 and a shared JPEGTables tag, but scanners kept writing the old form for years

The container side, BigTIFF and tiled layouts, is covered in the write-up on hardening the TIFF decoder; here the focus is the JPEG payload. Each table-offset tag points at a raw 64-byte quantisation table, or at a Huffman table of 16 counts followed by its values, one entry per component. PDFlibPas turns those into DQT and DHT segments in a synthesised JPEGTables stream. Each strip of bare entropy-coded data then gets a header of its own: SOI, a frame marker carrying that strip's row count, DRI when JPEGRestartInterval is set, SOS, the entropy data and EOI. The strips cannot be glued into one scan, because old-style JPEG restarts DC prediction at every strip boundary

Two details are easy to get wrong. The three components of a YCbCr image often point their table offsets at the same bytes, so PDFlibPas deduplicates tables by content before it assigns table numbers. If more than two Huffman tables of one class remain, the synthesised frame uses SOF1 instead of SOF0, since a baseline frame may reference only two DC and two AC tables. When JPEGInterchangeFormat instead points at a complete JPEG stream that contains scans, PDFlibPas treats that stream as the whole image and imports it as a single new-style strip. Lossless, tiled and separate-plane old-style JPEG are refused with an explicit error rather than guessed at, so convert those to new-style JPEG upstream

Why does a CMYK JPEG inside a TIFF come out inverted?

A CMYK JPEG inside a TIFF comes out inverted when the importer applies the Adobe APP14 convention that belongs to standalone JPEG files. Photoshop writes inverted CMYK into standalone JPEGs, so PDF producers traditionally add /Decode [1 0 1 0 1 0 1 0] to compensate. TIFF PhotometricInterpretation 5 defines samples as ink amounts where 0 means no ink, and mainstream TIFF readers take the decoded JPEG samples at face value without looking at APP14

PDFlibPas embeds JPEG-compressed CMYK TIFF strips without the inverted Decode array, whether or not an APP14 marker is present. The APP14 transform flag still decides whether YCCK is converted to CMYK, and DCTDecode handles that step by itself. Standalone four-component JPEG files keep the inverted Adobe convention, so identical JPEG bytes are treated differently depending on the container, as the two formats demand

Why does lossless JPEG decoding break on a Pascal shift?

Lossless JPEG decoding breaks in Pascal because predictors 5 and 6 need an arithmetic right shift and Pascal's shr is a logical one. SOF3 predicts each sample from its left (Ra), upper (Rb) and upper-left (Rc) neighbours and codes only the difference. Of the seven predictors in T.81 Table H.1, predictor 5 is Ra + ((Rb - Rc) >> 1) and predictor 6 is Rb + ((Ra - Rc) >> 1), and the bracketed difference goes negative whenever the upper-left neighbour is the brighter one. Feed a negative Integer to shr and the sign bit becomes a magnitude bit, so the image dissolves into noise

Shift behaviour for lossless JPEG predictors in Delphi and PDFlibPas: logical shr corrupts negative values, div 2 truncates towards zero, HalfDown floors like the reference
predictors 5 and 6 need floor semantics, and a small helper matches the reference decoder on Delphi and Free Pascal

Swapping the shift for div 2 does not fix it either. div truncates towards zero while an arithmetic shift rounds towards negative infinity, and the two disagree on every odd negative value. Free Pascal has SarLongint in its System unit; Delphi has no equivalent, so PDFlibPas uses a helper that compiles identically on both:

// Arithmetic shift right by one: floor(Value / 2)
// shr is a logical shift, and div 2 truncates towards zero
function HalfDown(Value: Integer): Integer;
begin
  if Value >= 0 then
    Result := Value shr 1
  else
    Result := -((1 - Value) shr 1);
end;

// Value = -3:  -3 shr 1 = 2147483646,  -3 div 2 = -1,  HalfDown(-3) = -2

For the type-width side of the same class of problem, see Longint and pointer pitfalls on Delphi Linux64

The rest of the lossless decoder has boundary rules of its own. The first row, and the first row after each restart marker, predicts from the left neighbour only and starts at 2^(P−Pt−1) for precision P and point transform Pt; every later row starts from the sample above. Reconstruction wraps modulo 2^16, and the point transform scales only the output, never the prediction chain. Huffman DC category 16, a difference of 32768 with no extra bits, is legal only in lossless mode, so the table parser has to accept it before it knows which process the frame will declare

PDFlibPas supports all seven predictors, point transforms and restart intervals that cover whole rows. The JPEG precision must equal BitsPerSample and be 8 or 16, and every component must be sampled 1x1. YCbCr lossless data and 12-bit lossless JPEG are refused with an explicit error

12-bit DCT and arithmetic coding in native Pascal

The 12-bit and arithmetic decoders in PDFlibPas share one inverse DCT and differ only in entropy decoding. In 12-bit Huffman JPEG, DC categories reach 15 and AC categories 14, quantisation tables may carry 16-bit entries, and DC prediction resets at every scan and every restart. Coefficients stay in zigzag order until dequantisation, which must use the quantisation value at the same zigzag index, and only then move to natural order for the separable two-pass IDCT. Decoded samples land within ±1 of an independent floating-point reference decoder

Arithmetic coding replaces Huffman with the QM coder of T.81 Annex D: a 114-entry probability state table, a 64-bin DC statistics area and a 256-bin AC area, steered by conditioning parameters from the DAC segment (defaults L = 0, U = 1, Kx = 5). Statistics, DC context and prediction reset at every restart interval and at the start of every scan. That is why PDFlibPas keeps DAC segments when it normalises shared JPEGTables for arithmetic images; drop them and every strip decodes with the wrong statistics model

v3.723.0 added subsampled components and YCbCr-to-RGB conversion with the JFIF equations to these decoded paths, and v3.724.0 added progressive scans for both 12-bit Huffman (SOF2) and arithmetic (SOF10) data. Subsampled chroma is replicated to full resolution rather than smoothed, which is what a reference decoder produces with chroma smoothing turned off. Hierarchical JPEG and arithmetic-coded lossless JPEG (SOF11) are refused with an explicit error

Testing binary JPEG fixtures in Delphi

Hand-built JPEG fixtures in Delphi unit tests can fail in a way that looks exactly like a decoder bug. A string constant built from #$xx characters that runs past 255 characters is typed as UnicodeString, and assigning it to an AnsiString converts it through the active code page. Bytes $80 to $9F, which are routine in entropy-coded data, get rewritten on the way in. Building marker headers at runtime has the same trap: in Delphi, AnsiChar + AnsiChar yields a UnicodeString, so append single bytes to an AnsiString or write them with SetLength and indexed assignment. For fixtures, store hex text and decode it at runtime:

uses
  SysUtils, PDFlibrary;

function HexToBytes(const Hex: AnsiString): AnsiString;
var
  I: Integer;
begin
  SetLength(Result, Length(Hex) div 2);
  for I := 1 to Length(Result) do
    Result[I] := AnsiChar(StrToInt('$' + string(Copy(Hex, I * 2 - 1, 2))));
end;

// The fixture stays plain ASCII, so no code page can touch it
ImageID := Lib.AddImageFromString(HexToBytes(TiffHex), 1);

When generating test images, note that jpegtran -arithmetic writes sequential SOF9; SOF10 needs -progressive as well. And a non-zero image ID only proves the import was accepted. Render the page and compare pixels, because a pass-through mistake produces a well-formed PDF that no viewer can draw

Bringing legacy scan archives into PDF

These JPEG variants tend to surface all at once during an archive migration. PDFlibPas reads 12-bit, lossless, arithmetic and old-style JPEG TIFFs through the same AddImageFromFile call as everything else, keeps full precision in the PDF, and refuses the rare processes outside that set with an explicit error. For licensing, the full feature list and trial downloads, see the PDFlibPas PDF Library for Delphi product page