Technical Article

PDF Version Preflight in Delphi: Measure RL vs GEO Rules

A floor plan exported by an architectural CAD package typically has a /VP array on its first page with two viewports, and each viewport carries a /Measure entry so that a viewer can read metres straight off the drawing. A GeoPDF map sheet has the same /VP array and the same /Measure key. The floor plan is ordinary PDF 1.6. The map sheet needs PDF 1.7 plus Adobe Extension Level 3. A version check that stops at the key name will demand the extension for both, and the floor plan gets refused at save time with a message about geospatial content it never contained

One /Measure key, two different dictionaries

The key name tells you that a viewport is measurable; only the dictionary behind it tells you how. Measure dictionaries arrived in PDF 1.6, and ISO 32000-1 §12.9 defines the rectilinear kind: a scale ratio string in /R, number format arrays in /X and /Y, and further arrays for distance (/D) and area (/A). Geospatial measurement came later, as part of Adobe Extension Level 3 on top of PDF 1.7. It is marked by /Subtype /GEO and carries a geographic coordinate system in /GCS, an optional display coordinate system in /DCS, paired point arrays in /GPTS and /LPTS, and preferred display units in /PDU — the structures walked in reading GeoPDF viewports, GPTS and LPTS arrays in Delphi

The detail that trips people up is the default. §12.9 gives /Subtype a default value of RL, and plenty of CAD exporters take advantage of that and leave the entry out. A checker that reads a missing subtype as 'unknown, assume the worst' turns every one of those drawings into a geospatial document. A checker that matches on the /Measure key never even reaches the subtype question, because the viewport dictionary that holds the key is one level above the measure dictionary that answers it

One /Measure key, two dictionaries in PDFlibPas: rectilinear measurement, with /RL or the subtype omitted, needs only PDF 1.6, while a geospatial dictionary needs 1.7 ExtensionLevel 3, so the preflight decides by the measure dictionary's content where a key-level rule cannot tell them apart
A preflight that blocks a valid file is worse than a slow one, because the caller receives an authoritative diagnostic about a feature the document never contained, which is why the distinguishing check sits one level down

How the PDF Library for Delphi save preflight reaches a verdict

PDF Library for Delphi (PDFlibPas) runs a version preflight inside SaveToFile before any output is written. It visits every indirect object, descending into nested dictionaries and arrays, and tests each node against a table of feature rules. Every rule names a minimum PDF version and, for extension features, an Adobe extension level; an extension rule can only be met by PDF 1.7 together with a matching /Extensions /ADBE entry in the catalog. The target it checks against is the document version, or the version you pinned with LockSaveVersion, plus whatever extension level the catalog declares. The first rule that both matches and exceeds that target ends the save: SaveToFile returns 0 and LastErrorCode becomes 602, the value of PDFLIB_ERROR_VERSION_COMPLIANCE

Save-time version preflight in PDFlibPas: SaveToFile checks every object against a fixed table of feature rules, each carrying a minimum PDF version and, for extension features, an Adobe extension level that only PDF 1.7 with a matching /Extensions entry can satisfy, and the first match the target cannot satisfy stops the save with error 602
GetInformation keys 311, 312 and 313 turn a refusal into a diagnosis, reporting the required version, the feature that triggered it and the locked target, so the caller can fix the file or the rule instead of guessing

An error code alone does not tell you what to fix. Three GetInformation keys carry the rest: 311 is the version the offending feature needs (for example 1.7 ExtensionLevel 3), 312 is the feature's name, and 313 is the version the save target is locked at, empty when nothing is locked

var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  try
    if Pdf.LoadFromFile('floor-plan.pdf', '') <> 1 then
      raise Exception.Create('load failed');
    if Pdf.SaveToFile('floor-plan-out.pdf') <> 1 then
      if Pdf.LastErrorCode = PDFLIB_ERROR_VERSION_COMPLIANCE then
        // 311: required version, 312: feature that triggered it,
        // 313: version the save target is locked at ('' when unlocked)
        Writeln('Needs ', Pdf.GetInformation(311),
          ' for ', Pdf.GetInformation(312),
          ', locked at [', Pdf.GetInformation(313), ']');
  finally
    Pdf.Free;
  end;
end;

For measurement content the table holds three rules and deliberately no rule on the bare /Measure key. A page with /VP or /UserUnit needs PDF 1.6. A /PtData dictionary needs extension level 3. A measure dictionary needs extension level 3 when it says /Subtype /GEO or contains any of /GCS, /DCS, /GPTS, /LPTS or /PDU. So a viewport whose measure dictionary omits /Subtype and one that spells out /RL both pass at 1.6, while the same page at 1.5 is refused because of /VP. Add a /GPTS array and the answer becomes 1.7 ExtensionLevel 3

The content test is conservative on purpose. A rectilinear dictionary that also carries a stray /GCS or /PDU is treated as geospatial. We would rather refuse that file than wave it through: those keys mean nothing in the rectilinear model, so whoever wrote them was either a geospatial tool or something that damaged the file, and a GEO-aware viewer may well try to interpret them. The loader is equally careful in the other direction. Its feature scan can raise the document's version number to 1.7, but it never writes an /Extensions declaration on behalf of an input file — declaring an extension is a claim about content, and only your code or the authoring calls should make it

Checking a drawing's viewports before you save

When 312 names a geospatial feature and you are sure the file is a plain drawing, look at the measure dictionaries yourself. The public viewport calls are enough. GetMeasureDictCoordinateSystem reads /Subtype and returns 1 for RL, 2 for GEO, and 0 when the entry is absent — which per §12.9 also means rectilinear. The point-array counts tell you whether any georeferencing data is present

// Report every measure dictionary in the loaded document.
// All calls here only read; none of them adds keys to the file.
procedure ReportMeasureDicts(Pdf: TPDFlib);
var
  Page, VP, VPID, MD, Kind: Integer;
begin
  for Page := 1 to Pdf.PageCount do
  begin
    Pdf.SelectPage(Page);
    for VP := 1 to Pdf.GetPageViewPortCount do
    begin
      VPID := Pdf.GetPageViewPortID(VP);
      MD := Pdf.GetViewPortMeasureDict(VPID);
      if MD = 0 then
        Continue;
      Kind := Pdf.GetMeasureDictCoordinateSystem(MD); // 0 = omitted (RL), 1 = RL, 2 = GEO
      Writeln(Format('page %d viewport "%s": subtype %d, GPTS %d, LPTS %d',
        [Page, string(Pdf.GetViewPortName(VPID)), Kind,
         Pdf.GetMeasureDictGPTSCount(MD), Pdf.GetMeasureDictLPTSCount(MD)]));
    end;
  end;
end;

Two calls are missing from that loop on purpose: GetMeasureDictGCSDict and GetMeasureDictDCSDict. Those two are get-or-create accessors built for authoring. On a measure dictionary without a /GCS entry, GetMeasureDictGCSDict adds an empty one and declares extension level 3 for the document. Call it as a probe on a rectilinear floor plan and the dictionary now genuinely contains /GCS; a following save locked at 1.6 is refused, correctly, for content your diagnostic just wrote. If you need to know whether a coordinate system exists, the subtype and the point-array counts answer that without side effects

Locking the output at PDF 1.6

A pinned version keeps the output honest when your consumers have a ceiling. TPDFlib.LockSaveVersion accepts the strings '1.0' through '1.7' and returns 0 for anything else. It fixes the document version and stops writer-side calls from raising it on their own, but the save preflight still runs against the locked value. A CAD drawing locked at 1.6 saves cleanly. A real GeoPDF locked at 1.6 gets 602, and that is the answer you want, because the alternative is a file whose header promises less than its content requires

if Pdf.LockSaveVersion('1.6') <> 1 then
  raise Exception.Create('unsupported version string');
try
  if Pdf.SaveToFile('floor-plan-16.pdf') <> 1 then
    if Pdf.LastErrorCode = PDFLIB_ERROR_VERSION_COMPLIANCE then
      // Real content above 1.6, for example a GEO measure dictionary
      raise Exception.CreateFmt('Locked at 1.6 but %s needs %s',
        [string(Pdf.GetInformation(312)), string(Pdf.GetInformation(311))]);
finally
  Pdf.UnlockSaveVersion;
end;

Read 311 and 312 before touching the version. If the feature is genuine geospatial content, you have two honest choices: leave the document unlocked at 1.7 with the extension declared, or drop the geospatial data from the copy you send to a 1.6-only consumer. Raising the lock just to make the error go away is the option to avoid. It hides the real question — can the downstream reader handle what you are shipping? When you build geospatial content through the API, the SetMeasureDict* setters such as SetMeasureDictCoordinateSystem and SetMeasureDictGPTSCount declare extension level 3 themselves, so authored files arrive at the preflight already labelled. The same habit of bounded, evidence-backed checks shows up in the PDF/E-1 author-mode preflight for engineering documents, where CAD drawings meet a conformance standard instead of a version number

Why the rule scan reads the table in place

The preflight's cost is roughly object nodes times rules, and the node count includes every number and name inside every array. That makes the per-rule overhead matter more than it looks. Each rule in the table is a record with two AnsiString fields, the match string and the feature name. Copying such a record into a local variable makes Delphi or FPC bump two reference counts and release whatever the local held before. Done once per rule per node, that means paying the string bookkeeping even for rules that do not apply to the target version and are thrown away on the very next line

Rule scan in PDFlibPas: copying a rule record that holds string fields adjusts reference counts on every visit, while passing the read-only table entry as a const parameter reads it in place without touching the strings
Reading the read-only table in place removes reference-count work from the rule-matching path, while a full save still pays for feature detection, object decoding and serialisation

The rule table is filled once at unit initialisation and never modified afterwards, so the scan hands table entries to its matching code through const record parameters and reads them where they live. A const record parameter is passed by reference, so testing a rule touches neither string field and the reference counts stay where they were. The effect is concentrated on the rule-matching path. A real save also decodes objects, runs deferred feature detection and serialises the output, and those stages are untouched by how rules are passed. Rule order, thresholds and the first-failure diagnostic in 311 and 312 do not depend on it, and no rule result is cached between saves, so a document you edit and save twice is checked in full both times

The same pattern is worth checking in your own Delphi code wherever a loop walks a table of records that contain strings, interfaces or dynamic arrays:

type
  TMyRule = record
    Name: string;      // managed field: copies cost reference-count work
    Threshold: Integer;
  end;

// Reader's own code, shown as a pattern
function Applies(const Rule: TMyRule; Value: Integer): Boolean;
begin
  Result := Value >= Rule.Threshold;  // const: no copy, no refcount traffic
end;

// In the hot loop, pass the table entry directly instead of
// assigning it to a local record first
for I := 0 to High(Rules) do
  if Applies(Rules[I], V) then
    Inc(Hits);

Measurement viewports, geospatial dictionaries, version locking and the save-time compliance check all ship in PDF Library for Delphi, available for Delphi, C++Builder and Lazarus projects