Technical Article

How PDF Viewports Give a Drawing Its Scale and a Map Its Coordinates

A 1:100 ground floor plan exported to PDF opens in Acrobat, and the distance tool reports the 5 m south wall as 1.97 in, its length on paper. The missing piece is a /VP viewport whose rectilinear (/RL) measure dictionary converts points to meters — something HotPDF writes from Delphi, along with geospatial (/GEO) control points for maps

What a viewport gives the measuring tool

A viewport is a rectangle on the page that brings its own measuring coordinate system. The optional /VP entry of the page dictionary (ISO 32000-1 Table 30, PDF 1.6) holds an array of /Type /Viewport dictionaries, and each one needs only a /BBox: /Name labels it in the viewer's interface and /Measure supplies the scale (ISO 32000-1 §12.9, Table 260; the same dictionary is Table 265 in ISO 32000-2). A page without any viewport leaves Acrobat's measuring tools at a 1:1 paper scale, so the 1.97 in is simply 50 mm of ink measured faithfully. /BBox is written in default user space, lower-left corner and then upper-right, and that corner order sets the direction of the measuring x and y axes, which the standard declares independent of the page's /Rotate. If you ever flatten /Rotate across a page's boxes, every /VP rectangle is one more piece of geometry that has to follow the content

Viewports may overlap, and §12.9 resolves the overlap by array order: a reader looking for the viewport under a point starts at the last /VP entry, iterates backwards and stops at the one whose /BBox contains the point. A 1:20 stair detail inset on a 1:100 sheet therefore has to come after the sheet-wide viewport — append it earlier and the big rectangle wins every hit test, so the detail measures five times too long. Distances follow their starting point. A line that begins inside the inset and ends out on the main plan is measured at 1:20 over its whole length, border or no border

HotPDF /VP viewport array in Delphi: AddRectilinearViewportEx appends a 1:20 stair detail after the 1:100 plan, so the reverse hit test picks the inset and an 85 point line reads 0.60 m, not 3.00 m
Appending the stair inset after the sheet-wide plan viewport lets the reverse /VP walk find it first, keeping the 85-point stair run at 0.60 m rather than 3.00 m

The arithmetic behind a rectilinear scale

The rectilinear measure dictionary (ISO 32000-1 Table 262, Table 267 in ISO 32000-2) has four required entries, and all of its arithmetic hangs on /C. /X, /D and /A are number format arrays: number format dictionaries (Table 263) ordered from the largest unit down, each with a /U label and a /C conversion factor. Later dictionaries in an array convert from the unit before them, while the leading dictionary starts from a different place depending on which entry owns the array:

EntryRequiredLeading /C converts from1:100 metric sheet
/RYesNothing; human-readable ratio text(1 cm = 1 m)
/XYesDefault user space units/U (m) /C 0.0352778
/YWhen y units or factors differDefault user space unitsOmitted
/DYesThe leading /X unit/U (m) /C 1
/AYesThe leading /X unit, squared/U (sq m) /C 1
/TNoDegreesOmitted
/SNoLeading /Y unit divided by leading /X unitOmitted

On a metric 1:100 plan the scale lives in one number. One point is 25.4 / 72 = 0.352778 mm of paper, which stands for 35.2778 mm of building, so /X gets /U (m) and /C 0.0352778, while /D starts from meters and /A from square meters and both get /C 1. Copying the /X factor into /D and squaring it for /A applies the scale twice: the 5 m wall comes back as 0.18 m, and a 20 m² room shrinks to 0.025 m². Write that leading /C of 1 into /D and /A on every viewport, even where it looks redundant. Table 262 makes both arrays required and defines no fallback, so a viewer that meets a missing or guessed factor is improvising, and two viewers can improvise in two different ways; a dictionary spelled out in full pins every readout in the file itself, and the units sit in code that a reviewer can check against the title block. The rest of the number format model works like this:

HotPDF THPDFRectilinearMeasureInfo conversion chain for a 1:100 PDF measurement viewport in Delphi: /X factor 0.0352778 from points to meters, /D and /A factors of 1, and the shrunken readouts when the scale is applied twice
Only the /X number format carries the 1:100 scale; with /D and /A pinned at a factor of 1, the south wall reads 5.00 m and the room 20.00 sq m
  • Chained units follow the previous-unit rule, which is how the standard's own example prints "1 mi 2,378 ft 7 5/8 in" from miles at /C 1, feet at /C 5280 and inches at /C 12, with /F /F /D 8 rounding to eighths
  • /R is a caption for people, so a wrong ratio string over a right /C still yields right numbers
  • Profiles with an exaggerated vertical scale, the "in X 1 cm = 1 m, in Y 1 cm = 30 m" case from Table 262, need /Y plus a /CYX factor that converts y units to x units; without /CYX a reader may decline to compute distance, area and angle

Writing a 1:100 plan with a 1:20 detail inset

THPDFPage.AddRectilinearViewportEx takes the viewport rectangle and a THPDFRectilinearMeasureInfo record whose fields map onto /R, /X, /Y, /D, /A, /T, /S, /O and /CYX, with each number format array held as THPDFNumberFormatEntry records. The table above goes in unchanged, and nothing is left for the reader to infer. The calling rules that apply before the code below runs:

  • A rectilinear viewport needs PDF 1.6. HotPDF raises THotPDF.Version to pdf16 on demand; with StrictVersionLock set, the method writes nothing and returns -1
  • An empty /R, or an /X, /D or /A array with no entries, raises an exception, since Table 262 requires all four
  • Each call appends to /VP, so the plan goes in before the inset and the inset ends up where a reader tests it ahead of everything else
uses
  SysUtils, HPDFDoc;

function NumberFormat(const UnitLabel: AnsiString; Factor: Double;
  Precision: Integer): THPDFNumberFormatEntry;
begin
  Result := Default(THPDFNumberFormatEntry);
  Result.UnitLabel := UnitLabel;                 // /U
  Result.ConversionFactor := Factor;             // /C
  Result.FractionStyle := mnfDecimal;            // /F /D
  Result.Denominator := Precision;               // 100 = hundredths
end;

// Metric drawing at 1:Scale with every readout in meters
function MetricScale(const Ratio: AnsiString;
  Scale: Double): THPDFRectilinearMeasureInfo;
const
  MetersPerPoint = 0.0254 / 72;                  // paper length of one unit
begin
  Result := Default(THPDFRectilinearMeasureInfo);
  Result.ScaleRatio := Ratio;                    // /R
  SetLength(Result.XFormats, 1);
  Result.XFormats[0] := NumberFormat('m', MetersPerPoint * Scale, 1000);
  SetLength(Result.DistanceFormats, 1);
  Result.DistanceFormats[0] := NumberFormat('m', 1, 100);    // from X units
  SetLength(Result.AreaFormats, 1);
  Result.AreaFormats[0] := NumberFormat('sq m', 1, 100);     // from X units squared
end;

procedure WritePlanSheet(const FileName: string);
var
  Pdf: THotPDF;
  Plan, Detail: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := FileName;
    Pdf.BeginDoc;
    // ... draw the ground floor plan and the stair detail inset ...

    // Rectangles are PDF default user space: points, origin bottom-left
    Plan := Pdf.CurrentPage.AddRectilinearViewportEx('Ground floor 1:100',
      36, 36, 576, 756, MetricScale('1 cm = 1 m', 100));
    Detail := Pdf.CurrentPage.AddRectilinearViewportEx('Stair detail 1:20',
      396, 72, 576, 252, MetricScale('1 cm = 20 cm', 20));
    if (Plan < 0) or (Detail < 0) then
      raise Exception.Create('StrictVersionLock keeps the file below PDF 1.6');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

The four rectangle arguments go into /BBox as they are, in PDF default user space: points, measured from the bottom-left corner. That is the space the /X factor converts from, so rectangle and scale share one unit. HotPDF drawing calls use a different one, with the origin at the top left and coordinates in 1/Resolution inch, and the gap between HotPDF drawing units and PDF points applies here in full — an inset drawn at top-left coordinates needs its y values flipped against the page height and converted to points, or the measuring rectangle lands on the opposite half of the sheet. Large-format pages add one more factor. With /UserUnit set through THPDFPage.SetUserUnit, one default unit is UserUnit / 72 inch, so the /X factor scales by the same value

Which order do GPTS coordinates use?

A map viewport carries a measure dictionary with /Subtype /GEO, which began as an Adobe extension to PDF 1.7 and became part of the standard in PDF 2.0 (ISO 32000-2 §12.10.2, Table 269). /GCS is required and holds a /Type /GEOGCS dictionary (Table 270) or a /Type /PROJCS dictionary (Table 271), identified by an /EPSG code or a /WKT string; for a geographic system the standard allows one of the two and forbids both together. Use EPSG whenever the coordinate system has a code. An integer cannot be broken by string escaping, and readers resolve it against the same registry GIS tools use, while WKT stays for the custom projections that have no code. /DCS optionally names another system for displaying positions. /PDU lists preferred display units by name — linear, area and angular, such as KM, SQKM and DEG — and HotPDF writes it only when all three units in THPDFGeoMeasureInfo are filled in

/GPTS pairs are latitude then longitude when /GCS is geographic, and easting then northing when it is projected. The order flips. GIS x/y pairs fed straight into /GPTS come out right for a UTM map and wrong for WGS 84. EPSG:4326 itself puts latitude ahead of longitude, GeoJSON and most web map APIs do the opposite, and a Zurich street map registered in web-map order shows the result: 8.54° east is read as a latitude, 47.37° north as a longitude, and the cursor reports positions near the Ethiopia–Somalia border. /LPTS holds the matching points in the viewport's unit square, where (0, 0) is the lower-left of /BBox and (1, 1) the upper-right, one pair per /GPTS pair; page coordinates pasted there put the control points hundreds of squares off the map. /Bounds, the neatline that limits where the georeference is valid, uses the same unit square and defaults to [0 0 0 1 1 1 1 0]. HotPDF checks the shape before writing anything: /GPTS needs an even count of at least four numbers, and a non-empty /LPTS must match its length

HotPDF THPDFGeoMeasureInfo GeoPDF control points in Delphi: /GPTS latitude-longitude order for a GEOGCS such as EPSG 4326, easting-northing for a PROJCS, and /LPTS corners in the viewport unit square used by AddGeospatialViewport
Geographic and projected coordinate systems order /GPTS pairs differently, while /LPTS always counts from the lower-left corner of the viewport /BBox in a unit square

Attaching a point cloud to a map viewport

A GEO viewport may also carry a point data dictionary (ISO 32000-2 §12.10.5, Table 272): /Type /PtData, /Subtype /Cloud, a /Names array that works as column headers and an /XPTS array of rows. LAT and LON in degrees and ALT in meters are predefined headers, and the standard treats the rows as an unordered collection. Structurally, /XPTS nests while /GPTS stays flat: one inner array per point, sized to /Names, so [[47.3769 8.5417 408] [47.3780 8.5400 410]]. THPDFPage.SetViewportPointDataCloud still accepts one flat list, the shape a CSV export produces, and slices it into rows by the column count; a value that parses as a number with a period decimal separator becomes a PDF number, anything else stays a string, and a count that is not a multiple of the column count raises an exception. Both calls need PDF 2.0, and the viewport index AddGeospatialViewport returns is where the cloud attaches

procedure AddCityMap(Page: THPDFPage);
const
  // Neatline corners, latitude before longitude: NW, NE, SE, SW
  CornerLatLon: array[0..7] of Double =
    (47.40, 8.45,  47.40, 8.62,  47.33, 8.62,  47.33, 8.45);
  // The same corners in the viewport unit square
  CornerUnit: array[0..7] of Double =
    (0, 1,  1, 1,  1, 0,  0, 0);
var
  Geo: THPDFGeoMeasureInfo;
  Map, I: Integer;
begin
  Geo := Default(THPDFGeoMeasureInfo);
  Geo.GCS.CSType := cstGEOGCS;
  Geo.GCS.EPSG := 4326;                  // WGS 84; WKT stays empty
  Geo.LinearUnit := 'KM';
  Geo.AreaUnit := 'SQKM';
  Geo.AngularUnit := 'DEG';
  SetLength(Geo.GPTS, Length(CornerLatLon));
  SetLength(Geo.LPTS, Length(CornerUnit));
  for I := 0 to High(CornerLatLon) do
  begin
    Geo.GPTS[I] := CornerLatLon[I];
    Geo.LPTS[I] := CornerUnit[I];
  end;

  // 540 x 324 pt keeps roughly the 12.8 km x 7.8 km aspect of the map
  Map := Page.AddGeospatialViewport('City center', 36, 252, 576, 576, Geo);
  if Map < 0 then
    raise Exception.Create('GEO viewports need PDF 2.0');

  // Three columns, so every three values become one /XPTS row
  Page.SetViewportPointDataCloud(Map, ['LAT', 'LON', 'ALT'],
    ['47.3769', '8.5417', '408',
     '47.3780', '8.5400', '410']);
end;

Reading viewports back and measuring on the server

THotPDF.GetLoadedPageViewports returns a THPDFViewportInfoArray for a zero-based page index, one THPDFViewportInfo per /VP entry in file order, with the BBox, Name, MeasureSubtype, the parsed Rectilinear or Geospatial record and any PtData rows. A measure dictionary that omits /Subtype comes back as RL, the default ISO 32000-1 Table 261 assigns. The two functions below reproduce what a viewer does with a click: find the viewport with the reverse walk, then turn a page distance into real units through /X (or /Y with /CYX) and the leading /D factor. The bounds test uses Min and Max because a measuring frame rotated against the page can list its corners in another order

uses
  SysUtils, Math, HPDFDoc;

// Walk /VP backwards from the last entry; the containing BBox reached soonest wins
function ViewportAt(const VPs: THPDFViewportInfoArray; X, Y: Double): Integer;
var
  I: Integer;
begin
  for I := High(VPs) downto 0 do
    if (X >= Min(VPs[I].BBoxLeft, VPs[I].BBoxRight)) and
       (X <= Max(VPs[I].BBoxLeft, VPs[I].BBoxRight)) and
       (Y >= Min(VPs[I].BBoxBottom, VPs[I].BBoxTop)) and
       (Y <= Max(VPs[I].BBoxBottom, VPs[I].BBoxTop)) then
      Exit(I);
  Result := -1;
end;

// Distance between two page points, in the leading /D unit
function MeasuredDistance(const VP: THPDFViewportInfo;
  X1, Y1, X2, Y2: Double): Double;
var
  RL: THPDFRectilinearMeasureInfo;
  DX, DY: Double;
begin
  RL := VP.Rectilinear;
  if (Length(RL.XFormats) = 0) or (Length(RL.DistanceFormats) = 0) then
    raise Exception.Create('Viewport has no usable /X or /D array');
  DX := (X2 - X1) * RL.XFormats[0].ConversionFactor;
  if Length(RL.YFormats) > 0 then
  begin
    if not RL.HasCYX then
      raise Exception.Create('/Y without /CYX: distance is undefined');
    DY := (Y2 - Y1) * RL.YFormats[0].ConversionFactor * RL.CYX;
  end
  else
    DY := (Y2 - Y1) * RL.XFormats[0].ConversionFactor;
  Result := Sqrt(Sqr(DX) + Sqr(DY)) * RL.DistanceFormats[0].ConversionFactor;
end;

procedure MeasureStairRun;
var
  Pdf: THotPDF;
  VPs: THPDFViewportInfoArray;
  Hit: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('floor-plan.pdf') > 0 then
    begin
      VPs := Pdf.GetLoadedPageViewports(0);
      Hit := ViewportAt(VPs, 450, 150);            // the start point decides
      if (Hit >= 0) and (VPs[Hit].MeasureSubtype = 'RL') then
        Writeln(Format('%s (%s): %.2f m', [string(VPs[Hit].Name),
          string(VPs[Hit].Rectilinear.ScaleRatio),
          MeasuredDistance(VPs[Hit], 450, 150, 450, 235)]));
    end;
  finally
    Pdf.Free;
  end;
end;

Expected output for the sheet written earlier: Stair detail 1:20 (1 cm = 20 cm): 0.60 m, since 85 points at 0.0070556 m per point is 0.5997 m. Swap the two AddRectilinearViewportEx calls and the same 85-point line resolves to the plan viewport and reads 3.00 m. For a scanned drawing set you did not produce, THotPDF.AddPageRectilinearViewportEx, AddPageGeospatialViewport and SetPageViewportPointDataCloud do the same work on a loaded document by page index before SaveLoadedDocument. Line, polyline and polygon annotations can carry their own /Measure (ISO 32000-1 Tables 175 and 178), and GetLoadedAnnotationMeasure parses it into the same RL and GEO records, which matters when the measurement annotations in a reviewed PDF came from another tool

With /D and /A pinned at 1, the /X factor of 0.0352778 is the only number that carries the 1:100 scale, and the 5 m south wall should read 5.00 m in any reader that follows §12.9. The same record type comes back from GetLoadedPageViewports, so a file written by the HotPDF Delphi PDF component can be reloaded and its factors compared against the values your code put in