Technical Article

Decode Rotated QR Codes in PDF Pages with HotPDF

HotPDF decodes rotated QR symbols in a loaded PDF page by normalising the sampled module matrix through all eight D4 orientations inside the decoder itself. The outer rotation retry that works for linear symbologies cannot work for QR, and understanding why saves you a day of chasing a decoder that looks broken but is not

The scenario is ordinary enough. Scanned delivery notes arrive as PDFs, each page carries a QR label, and the scanner operator fed a stack of sheets in whatever direction the tray accepted. Some labels are upright, some are a quarter turn off, a few are upside down. You call the barcode decoder, half the pages resolve, and the other half come back empty with no error at all

Why does rotating the scan mask never fix a rotated QR?

Because a QR finder pattern layout is deliberately asymmetric, and a whole-image rotation preserves that asymmetry instead of removing it. QR Code places three finder squares at the top-left, top-right and bottom-left corners, and leaves the bottom-right corner empty (ISO/IEC 18004:2015 §6.3.3). That missing corner is the orientation cue. Rotate the page bitmap by ninety degrees and the gap simply moves to a different corner. There is no non-trivial rotation of the plane that maps a three-corner layout back onto itself, so a decoder that only accepts the canonical arrangement will reject every attempt in turn

This matters because the obvious fix is the wrong one. The natural instinct is to hang the retry on the outside: render the page, hand the mask to the decoder, and if that fails, rotate the mask and try again for 90, 180 and 270 degrees. For Code 39 that policy is exactly right, because a linear symbology has a start and stop pattern the scanner can find once the bars run horizontally. For QR it is four guaranteed failures followed by a report of nothing found

The D4 group, applied to the module matrix

The correct place for the normalisation is after sampling, on the boolean module grid rather than on the pixel mask. Once the decoder has resolved the symbol into an n by n matrix of dark and light modules, it can enumerate the dihedral group of the square: four rotations times two reflections, eight candidate orientations in total. For each candidate it checks the finder triangle, and the first candidate whose three finders land in the top-left, top-right and bottom-left positions is the true orientation. From there the existing pipeline runs unchanged, because the format information bits, the zigzag data placement and Reed-Solomon correction all assume a canonical matrix and now get one

Four renderings of the same HotPDF QR module matrix under the rotations of the D4 group at 0, 90, 180 and 270 degrees, showing the three finder patterns migrating corners whilst the empty corner moves with them, so only the canonical orientation presents finders at top-left, top-right and bottom-left to the decoder
Rotating the pixel mask cannot remove the QR finder asymmetry, so HotPDF enumerates the D4 orientations on the sampled module matrix and keeps the first candidate whose finders land top-left, top-right and bottom-left

Two properties make this cheap. The matrix is small compared to the rendered bitmap, so eight transposes cost far less than eight page renders. And the matrix is a clean boolean array built by the sampler, so no transform along the way can introduce values that were never sampled

Version detection is a divisibility search, not a division

The module count cannot be derived by dividing the sampled width by an assumed module size, and getting this wrong is a subtle source of decode failures on high-resolution renders. A QR symbol of version v is 4v + 17 modules across, so version 1 is 21 modules and version 40 is 177. A mask that measures 126 pixels wide is equally consistent with version 1 at six pixels per module and with several higher versions at smaller module sizes. Linear division picks one of them and is usually wrong

What works is a divisibility search over the candidate versions. Walk from version 40 down to version 1, keep the candidates whose module count divides the sampled width evenly and leaves at least three pixels per module, and take the smallest surviving version. The three-pixel floor is what stops the search from accepting an absurdly dense reading of a coarse symbol, and the smallest-version rule resolves the remaining ambiguity in favour of the reading a scanner would actually produce

The HotPDF version detection walk for a QR symbol on a 126 pixel sampled mask, testing each candidate module count 4v plus 17 from version 40 down to version 1 for even divisibility and a three pixel module floor before the smallest surviving version wins
A QR module count comes from a divisibility search over candidate versions, not from dividing mask width by an assumed module size, and the smallest surviving version resolves the ambiguity
var
  Pdf: THotPDF;
  Options: THPDFBarcodeDecodeOptions;
  Codes: THPDFDecodedBarcodes;
  Info: THPDFBarcodeDecodeInfo;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('delivery-notes.pdf');
    Options := THPDFBarcodeDecodeOptions.Default;
    Options.DPI := 300;
    Options.RotationPolicy := bdrpFallback;
    Options.MinimumConfidence := 0.5;
    Options.MaxResults := 16;
    if Pdf.DecodeLoadedPageBarcodes(0, Options, Codes, Info) then
      for I := 0 to High(Codes) do
        if Codes[I].Symbology = bsyQRCode then
          Writeln(Codes[I].Text, '  at ',
            Format('%.0f', [Codes[I].OrientationDegrees]), ' degrees');
  finally
    Pdf.Free;
  end;
end;

THPDFBarcodeDecodeOptions.Default hands back a populated record rather than a zeroed one, which matters because a DPI of zero or a result cap of zero is a valid-looking way to get nothing back. RotationPolicy controls the outer retry only: bdrpNone renders once, bdrpFallback retries the other orientations after a failed first pass, and bdrpAll renders every orientation unconditionally. Because QR normalisation happens inside the decoder, QR pages resolve on the first attempt under any of the three policies. The policy is there for the linear symbologies that genuinely need it

How do you prove a bitmap transform is not inventing pixels?

Count the ink on both sides and require the totals to match. A rotation is a permutation of pixels, nothing more, so the number of non-zero cells in the output must equal the number in the input. When a mask rotation in the outer retry path reported 4800 set cells going in and 7439 coming out, that single comparison was enough to convict the transform without reading a line of its geometry

The cause was mundane and worth carrying away as a rule. A dynamic array sized with SetLength is not guaranteed to arrive zeroed when it is a function result travelling a path the runtime does not clear, and cells the rotation never writes then carry whatever bytes were there before. Some of those stale bytes are non-zero, and non-zero means ink. The fix is one line, FillChar(Result[0], N, 0) before the permutation loop runs, and the discipline it implies is broader: any function returning a mask or bitmap buffer should clear its output explicitly instead of relying on allocation semantics

What made the defect survive three releases is more interesting than the defect. Once QR moved its orientation handling into the decoder, QR stopped exercising the outer mask rotation entirely, and the only remaining consumer of that code path was Code 39. Shared infrastructure hides bugs like this all the time: coverage from one feature makes a path look tested whilst the feature that actually depends on it has none of its own. Every path a new feature stops using needs a test that still uses it

Reading the results back in page coordinates

Every geometric value the decoder produces is expressed in the coordinate frame of the attempt bitmap, and the caller needs it in PDF user space. That conversion runs in two stages: undo the quarter turn the retry applied, then undo the render transform that mapped user space onto the bitmap. What arrives in THPDFDecodedBarcode is an axis-aligned bounding box in user space, with Left, Bottom, Right and Top following the PDF convention that Y grows upwards, plus an anticlockwise OrientationDegrees

The HotPDF barcode pipeline from rendered page bitmap through sampling into a boolean module matrix, D4 normalisation, divisibility version detection and Reed-Solomon decoding, then the two-stage coordinate conversion that undoes the retry quarter turn and the render transform before THPDFDecodedBarcode publishes Left, Bottom, Right, Top and OrientationDegrees in user space
QR normalisation inside the decoder lets pages resolve on the first attempt, whilst the two-stage coordinate conversion turns attempt-bitmap results into axis-aligned user space boxes

Get the direction of that second conversion wrong and the symptom is nasty: text decodes perfectly, but the box you draw for a review overlay lands on the mirror image of the right position. Anyone building a review interface on top of the decoder should assert against a known fixture, with a symbol placed deliberately near one page corner so a flipped Y axis is visible at a glance. The same reasoning applies to any coordinate crossing the rendering boundary, which is why rendering a PDF page to a bitmap in Delphi is worth understanding before you build on top of the decoder

What the built-in decoder will and will not do

The built-in decoder is a bounded, dependency-free implementation, and it is honest about its limits rather than degrading quietly. It recognises Code 39 and QR, validates the BCH-protected format bits and the mask pattern before it publishes any data, and it does not attempt error recovery on damaged symbols. If your input is a photograph of a curved label under uneven light, that is a different problem class and wants a specialised engine

// Swap in your own engine: implement IHPDFBarcodeDecoder and pass it
// to the decoder-aware overload. HotPDF still owns page rendering,
// budgets, coordinate mapping and de-duplication
if not Pdf.DecodeLoadedPageBarcodes(PageIndex, MyDecoder, Options,
     Codes, Info) then
  case Info.Status of
    bdsBudgetExceeded:
      Log('raise MaxPixels or lower DPI: ' + string(Info.Diagnostic));
    bdsRenderError:
      Log('page did not render: ' + string(Info.Diagnostic));
    bdsDecoderError:
      Log(string(Info.DecoderName) + ' failed: ' + string(Info.Diagnostic));
  end;

THPDFBarcodeDecodeInfo is where a production pipeline earns its keep. RotationAttemptCount and DecoderCallCount tell you whether the outer retry ran at all, ReceivedResultCount against AcceptedResultCount separates a decoder that found nothing from a confidence threshold that rejected everything it found, and RenderedPixels with PeakWorkingBytes is what you graph when a batch job starts thrashing. An empty result set plus bdsSucceeded means the page really has no readable symbol, which is a different operational fact from bdsBudgetExceeded

The budget fields deserve a deliberate decision rather than a default. MaxPixels and MaxWorkingBytes exist because DPI multiplies quadratically: moving from 300 to 600 DPI on an A4 page quadruples both the render cost and the peak allocation, and an untrusted input declaring an enormous page box can turn a scan job into an out-of-memory incident. Set the caps to what your worst legitimate document needs, then let bdsBudgetExceeded route the outliers to a slower, isolated path

If your documents mix machine-readable labels with printed text you plan to index, the barcode decoder pairs naturally with the recognition engine covered in template-matching OCR inside HotPDF, and the generation side of the same story is in drawing barcodes into a PDF with HotPDF. Both run on the same rendering and budget infrastructure, so a pipeline that already sets sane limits for one gets the other almost free

Rotation tolerance is one of those features that is invisible when it works and infuriating when it does not, and the engineering lesson generalises past QR: normalise as close to the semantic representation as you can get, not at the pixel layer where the data still carries every accident of how it was captured. HotPDF ships this as part of the HotPDF Delphi PDF component, alongside the rendering, OCR and page-analysis pieces the same intake pipelines usually need