Technical Article

Validating PDF/raster Scanned Documents in Delphi

A scan batch can be nothing but one JPEG per page, with no fonts, no annotations and no bookmarks, and still fail as PDF/raster. ISO 23504-1 asks more of the file than raster-only pages: it has to identify itself with a %PDF-raster-1.0 comment in the trailer area, carry XMP metadata and a trailer /ID, and keep its catalog and Info dictionary to short permitted lists. Checking a capture batch therefore takes two different reads, one over the raw bytes and one over what each page actually draws

Two reads of the same file

PDF/R validation and SaveAsPdfR ship with the Small Business and Enterprise editions of PDFium Component, which splits the validation along that line. ValidatePdfRCompliance in the FPdfPdfr unit reads a stream and checks the byte-level rules: header version, the raster marker, object streams, catalog and Info entries, XMP, trailer /ID, MediaBox origins, font resources, forbidden filter names and whether the number of image objects matches the number of pages. ValidatePdfRPageFacts checks one page at a time from a TPdfRPageFacts record: how many text, path, form and shading objects the page draws, where the single strip image sits, its pixel size, bit depth, color space and filter chain

Both reads report through one enumeration, TPdfRValidationIssue, and the combined entry points merge them into a single TPdfRValidationResult. Its Conformance field becomes prc1 only when the raster marker is found, and IsCompliant returns True only when the level is prc1 and the issue set is empty. A file that is perfect in every other respect but lacks the marker reports prcNone, which surprises people the first time they run a clean scan through it

The question is where the page facts come from. TPdf.ValidatePdfR collects them from the loaded document, but it first serializes that document to a temporary store and runs the byte checks on the re-saved copy, so the marker and catalog verdicts describe a fresh save, which may differ from the file on disk. ValidatePdfRasterSource in FPdfBusinessWorkflows takes one snapshot of the input stream and runs both reads against it. For a file that is about to go into an archive, that is the call to use: the verdict then refers to the bytes being archived

uses
  System.Classes, System.SysUtils, System.TypInfo,
  FPdfPdfr, FPdfReport, FPdfBusinessWorkflows;

procedure CheckDeliveredScan(const FileName: string; Log: TStrings);
var
  Src: TFileStream;
  Options: TPdfRasterSourceValidationOptions;
  Report: TPdfRasterSourceValidationReport;
  Issue: TPdfRValidationIssue;
  I: Integer;
begin
  Options := TPdfRasterSourceValidationOptions.Default;
  Options.MaxInputBytes := 512 * 1024 * 1024;     // default budget is 64 MiB
  Options.Validation.MinimumResolutionDpi := 200; // our capture contract
  Src := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    if not ValidatePdfRasterSource(Src, Options, Report) then
    begin
      Log.Add(Format('Check stopped (%s): %s',
        [GetEnumName(TypeInfo(TPdfRasterSourceStatus), Ord(Report.Status)),
         Report.ErrorMessage]));
      Exit;
    end;
  finally
    Src.Free;
  end;

  if Report.IsCompliant then
    Log.Add('PDF/R-1 conformant')
  else
  begin
    // Validation.Issues holds the byte-level issues plus every page issue
    for Issue := Low(TPdfRValidationIssue) to High(TPdfRValidationIssue) do
      if Issue in Report.Validation.Issues then
        Log.Add('File: ' + GetEnumName(TypeInfo(TPdfRValidationIssue), Ord(Issue)));
    // PageIssues says which pages to rescan
    for I := 0 to Report.ProcessedPageCount - 1 do
      for Issue := Low(TPdfRValidationIssue) to High(TPdfRValidationIssue) do
        if Issue in Report.PageIssues[I] then
          Log.Add(Format('Page %d: %s', [Report.Pages[I].PageNumber,
            GetEnumName(TypeInfo(TPdfRValidationIssue), Ord(Issue))]));
  end;

  // Keep the machine-readable verdict next to the archived file
  Log.Add(Report.ToJson(TPdfReportLimits.Default));
end;

Two defaults in TPdfRasterSourceValidationOptions matter for scan batches. MaxInputBytes is 64 MiB and MaxPages is 1000; a long batch of color scans can exceed the byte budget, and the check then ends with status prssBudgetExceeded and Complete False. MemoryLimitBytes is 8 MiB, beyond which the snapshot spills to TemporaryDirectory and the report sets SnapshotSpilled. Report.IsCompliant also requires Complete, so a check that stopped halfway never reads as a pass

Map of PDF/R validation in PDFium Component for Delphi: ValidatePdfRCompliance reads a stream into TPdfRValidationResult, whose issue set covers page rules like pvriFontForbidden and pvriBadMediaBox, the ISO 23504-1 image filter white list, and container rules such as pvriObjStmPresent
One call returns the conformance level plus a set of concrete issues, and the checks split into page rules, a four-filter image white list and container rules that fire before any page is read

Why does an OCR text layer fail PDF/R?

Because a raster page draws the scanned image and nothing else. An invisible OCR layer is a normal addition in a PDF/A workflow, and it puts two things into the file that PDF/R excludes: a font resource, reported by the byte read as pvriFontForbidden, and text objects on the page, reported by the page read as pvriNonRasterPageContent. The same page issue fires for vector paths, shadings and form XObjects, so a stamped page number or a drawn border rule fails in the same way as hidden text

The page-to-image relationship is strict too. Each page has to carry exactly one strip image; pvriPageImageMismatch fires on a page with no image and on a page whose scan was written as two images side by side. On the byte side the count is taken over every object whose /Subtype is /Image, so any extra image object, a soft mask attached to the strip for example, pushes the total past the page count

Geometry gets two checks. pvriBadMediaBox reports a MediaBox that does not start at [0 0 ...]; the byte read inspects inline numeric arrays. The page read then compares the strip's drawn bounds with the MediaBox and reports pvriStripGeometryInvalid when any edge is off by more than GeometryTolerancePoints, 0.5 point by default. A scanner driver that leaves a white margin by scaling the image to 98% of the page fails here, even though the page looks fine on screen. Setting RequireFullPageStrip to False switches the coverage test off for internal files where a margin is acceptable

Which image encodings pass

The validator accepts four strip filters: /CCITTFaxDecode, /DCTDecode, /JPXDecode and /FlateDecode. The byte read flags the other standard filters by name as pvriForbiddenImageFilter: /LZWDecode, /RunLengthDecode, /ASCII85Decode, /ASCIIHexDecode, /JBIG2Decode and /Crypt

JBIG2 is the rejection capture teams hit first. It is permitted in PDF/A and compresses bilevel pages very well, so a capture profile built for PDF/A may already emit it. Its lossy pattern-matching mode is also known to replace a scanned glyph with a similar-looking one, a documented problem with digits on scanned forms, and a raster archive exists precisely so that what was on paper is what is in the file. Re-encoding those pages as CCITT Group 4 keeps them bilevel and lossless

The page read goes one step further and looks at the filter chain together with the image's bit depth and color space, reporting mismatches as pvriImageCompressionParametersInvalid:

  • A chain of more than one filter is rejected, so an ASCII85-wrapped Flate image fails twice, once on the byte side and once here
  • /CCITTFaxDecode must sit on a 1-bit DeviceGray strip
  • /DCTDecode must sit on an 8-bit or 24-bit strip in DeviceGray or DeviceRGB
  • /FlateDecode is accepted on 1-bit strips; a 24-bit Flate page fails this check
  • /JPXDecode is accepted at 1, 8 or 24 bits per pixel

Independently of the filter, a strip whose color space is neither DeviceGray nor DeviceRGB gets pvriImageColorInvalid. An ICC-based or CMYK scan therefore has to be converted before it is wrapped, and the cheapest place to do that is the scanner profile, before any PDF is written

PDF/R image filter white list for Delphi validation: CCITTFaxDecode, DCTDecode, JPXDecode and FlateDecode are allowed on strip images, while LZW, RunLength, ASCII85, ASCIIHex, JBIG2 and Crypt filters raise pvriForbiddenImageFilter
The validator accepts four strip encodings and reports every other standard filter, JBIG2 and the ASCII filters included, as pvriForbiddenImageFilter

How the strip resolution is worked out

When the image carries its own resolution, the page read uses it. When it does not, the effective resolution is derived from pixels and page size: pixel width times 72 divided by MediaBox width in points, and the same for height. Suppose a scanner writes a 2550 by 3300 pixel strip onto a US Letter page of 612 by 792 points. That works out to exactly 300 dpi in both directions. The same page at 600 by 776 pixels comes to about 70.6 dpi horizontally, which is below the default minimum of 72, so the page gets pvriImageResolutionInvalid

The default range in TPdfRValidationOptions is 72 to 2400 dpi. Those bounds only catch obviously broken pages. A capture contract usually promises a specific minimum, often 200 or 300 dpi, and that value belongs in MinimumResolutionDpi: a 150 dpi page passes the default range, and it is exactly the page that contract was written to exclude. Setting either bound to 0 turns that side of the check off

ValidatePdfRPageFacts is public, so the same rules can run before any PDF exists, straight from what the scanner reports about each image:

uses
  FPdfPdfr;

// Builds page facts from scanner output; this helper is yours, not part of the library
function ScannerStripFacts(PixelW, PixelH: Integer;
  PageWidthPt, PageHeightPt: Single; const Filter: string;
  BitsPerPixel: LongWord; ColorSpace: Integer): TPdfRPageFacts;
begin
  Result := Default(TPdfRPageFacts);
  Result.PageNumber := 1;
  Result.MediaWidth := PageWidthPt;
  Result.MediaHeight := PageHeightPt;
  Result.ImageCount := 1;
  Result.ImageRight := PageWidthPt;   // strip covers the whole page
  Result.ImageTop := PageHeightPt;
  Result.PixelWidth := PixelW;        // HorizontalDpi left at 0: derived
  Result.PixelHeight := PixelH;
  Result.BitsPerPixel := BitsPerPixel;
  Result.ColorSpace := ColorSpace;    // 1 = DeviceGray, 2 = DeviceRGB
  SetLength(Result.Filters, 1);
  Result.Filters[0] := Filter;
end;

var
  Opts: TPdfRValidationOptions;
  Issues: TPdfRValidationIssues;
begin
  Opts := TPdfRValidationOptions.Default;
  Opts.MinimumResolutionDpi := 300;

  // 300 dpi color JPEG on Letter: empty set
  Issues := ValidatePdfRPageFacts(
    ScannerStripFacts(2550, 3300, 612, 792, 'DCTDecode', 24, 2), Opts);

  // Same page compressed with Flate at 24 bits:
  // [pvriImageCompressionParametersInvalid]
  Issues := ValidatePdfRPageFacts(
    ScannerStripFacts(2550, 3300, 612, 792, 'FlateDecode', 24, 2), Opts);
end;

Catalog, Info dictionary and XMP

Besides the required /Type and /Pages, ISO 23504-1 permits only Version, ViewerPreferences, PageLayout, PageMode, AcroForm and Metadata in the catalog. The validator checks the catalog against the 21 standard keys outside that set and raises pvriProhibitedCatalogEntry when any of them appears, among them Outlines, Names, PageLabels, OpenAction, StructTreeRoot, MarkInfo, Lang, OCProperties and OutputIntents. The last one matters in practice: a PDF/A file always carries an output intent, so a single file cannot satisfy both profiles

The Info dictionary may hold Creator, Producer, CreationDate and ModDate; Title, Author, Subject, Keywords and Trapped raise pvriProhibitedInfoEntry. A catalog without a /Metadata stream gets pvriMissingXmpMetadata. When the stream is there but its XMP lacks any of xmp:CreateDate, xmp:ModifyDate and xmp:MetadataDate, the finding is pvriMetadataInvalid instead. pvriInfoXmpMismatch compares Producer with pdf:Producer and Creator with xmp:CreatorTool. Dates are left out of that comparison because the two encodings legitimately differ, and Info values stored as hex strings are skipped. A typical trigger is a later tool rewriting the Info Producer without touching the XMP

The remaining container rules are short. The header has to be %PDF-1.4 through %PDF-1.7 or %PDF-2.0 (pvriBadHeader), any /Type /ObjStm stream raises pvriObjStmPresent, and an encrypted file must use a %PDF-2.0 header (pvriEncryptVersionMismatch). With RequireEncryptedPdf20Profile left on, the encryption dictionary must also declare /V 5 or 6 with /R 5 or later, or the file gets pvriEncryptedPdf20ProfileInvalid

Writing the markers

TPdfRSaveOptions carries Creator, Producer, CreationDate, ModDate, DocumentId and InstanceId, and has no field for Title, Author, Subject or Keywords because the profile prohibits them. StripInfoOptionalEntries and StripCatalogOptionalEntries both default to True and remove the excluded entries from the source; set either to False and the entries stay, at the cost of conformance. TPdf.SaveAsPdfR fills empty option fields from the loaded document's own metadata and file identifiers, removes security, then writes the marker, the trimmed dictionaries, the XMP packet and the trailer /ID

uses
  System.Classes, System.SysUtils, PDFium, FPdfPdfr;

procedure WrapScanAsPdfR(const InFile, OutFile: string; Log: TStrings);
var
  Pdf: TPdf;
  Opts: TPdfRSaveOptions;
  Written: TFileStream;
  Res: TPdfRValidationResult;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := InFile;
    Pdf.Active := True;
    Opts := TPdfRSaveOptions.Default;
    Opts.Creator := 'Capture Station 4';   // Producer, dates and IDs come from the source
    if not Pdf.SaveAsPdfR(OutFile, Opts) then
      raise Exception.Create('PDF/R save failed: ' + InFile);
  finally
    Pdf.Free;
  end;

  // Check the bytes that were written, not the in-memory document
  Written := TFileStream.Create(OutFile, fmOpenRead or fmShareDenyWrite);
  try
    Res := ValidatePdfRCompliance(Written);
    if pvriPageImageMismatch in Res.Issues then
      Log.Add('A source page still has no single strip image');
    if pvriFontForbidden in Res.Issues then
      Log.Add('The source carries fonts; rescan or drop the text layer');
  finally
    Written.Free;
  end;
end;

Wrapping changes metadata and identification only. A source page that draws vector text, or carries no strip image, comes out of SaveAsPdfR with the same page content and fails the same page checks. The page content is left alone deliberately, since quietly re-rendering a scan would change the very pixels the archive is meant to preserve. Running CheckDeliveredScan from the first listing on OutFile gives the full verdict, page reads included

Where PDF/R fits in a capture pipeline

PDF/R suits deliverables where the scan itself is the record and fidelity is the whole contract: evidentiary imaging, check and remittance capture, drawing archives from a large-format scanner. Once a document needs searchable text, tagging, attachments or bookmarks, it belongs in PDF/A, because every one of those features lands on the excluded lists above

Many teams keep both. A PDF/R original is written once and never changes, and a PDF/A derivative with an OCR layer serves retrieval. The validators are independent, so one batch job can check each file against the profile it claims. The archival side is covered in PDF/A archival compliance and PDF/A preflight validation, and print output in validating print-ready PDF/X documents

The PDF/R checks shown here are part of the Small Business and Enterprise editions of PDFium Component for Delphi, C++Builder and Lazarus, next to validators for PDF/A, PDF/X, PDF/E and PDF/UA; editions and supported IDE versions are on the PDFium Component product page