Artículo técnico

Viewports de medida PDF para planos a escala y mapas GeoPDF

Un plano de planta baja 1:100 exportado a PDF se abre en Acrobat y la herramienta de distancia reporta el muro sur de 5 m como 1.97 in, su longitud en papel. La pieza que falta es un viewport /VP cuyo diccionario de medida rectilinear (/RL) convierte puntos a metros — algo que HotPDF escribe desde Delphi, junto con puntos de control geoespaciales (/GEO) para mapas

Qué le da un viewport a la herramienta de medida

Un viewport es un rectángulo de la página que trae su propio sistema de coordenadas de medida. La entrada opcional /VP del diccionario de página (ISO 32000-1 Tabla 30, PDF 1.6) guarda un arreglo de diccionarios /Type /Viewport, y cada uno necesita solo un /BBox: /Name lo rotula en la interfaz del visor y /Measure aporta la escala (ISO 32000-1 §12.9, Tabla 260; el mismo diccionario es la Tabla 265 en ISO 32000-2). Una página sin ningún viewport deja las herramientas de medida de Acrobat a escala de papel 1:1, así que los 1.97 in son simplemente 50 mm de tinta medidos con fidelidad. El /BBox se escribe en default user space, esquina inferior-izquierda y luego superior-derecha, y ese orden de esquinas fija la dirección de los ejes x e y de medida, que la norma declara independientes del /Rotate de la página. Si alguna vez aplana el /Rotate entre los boxes de una página, cada rectángulo /VP es una pieza más de geometría que debe seguir al contenido

Los viewports pueden solaparse, y la §12.9 resuelve el solapamiento por orden del arreglo: un lector que busca el viewport bajo un punto arranca en la última entrada /VP, itera hacia atrás y se detiene en la que tenga un /BBox que contenga el punto. Un detalle de escalera 1:20 insertado en una lámina 1:100 tiene por tanto que ir después del viewport de toda la lámina — añádalo antes y el rectángulo grande gana cada hit test, con lo cual el detalle mide cinco veces de más. Las distancias siguen su punto de partida: una línea que empieza dentro del detalle y termina en el plano principal se mide a 1:20 a lo largo de toda su extensión, con borde o sin él

HotPDF arreglo de viewports /VP en Delphi: AddRectilinearViewportEx añade un detalle de escalera 1:20 después del plano 1:100, de modo que el hit test inverso elige el detalle y una línea de 85 puntos lee 0.60 m, no 3.00 m
Añadir el detalle de escalera después del viewport del plano de toda la lámina permite que el recorrido inverso del /VP lo encuentre primero, manteniendo el tramo de escalera de 85 puntos en 0.60 m en lugar de 3.00 m

La aritmética detrás de una escala rectilínea

El diccionario de medida rectilinear (ISO 32000-1 Tabla 262, Tabla 267 en ISO 32000-2) tiene cuatro entradas obligatorias, y toda su aritmética cuelga del /C. /X, /D y /A son arreglos de number format: diccionarios de formato de número (Tabla 263) ordenados desde la unidad mayor hacia abajo, cada uno con una etiqueta /U y un factor de conversión /C. Los diccionarios posteriores de un arreglo convierten desde la unidad que los precede, mientras que el diccionario inicial parte de otro lugar según qué entrada sea dueña del arreglo:

EntradaObligatoriaEl /C inicial convierte desdeLámina métrica 1:100
/RSíNada; texto de razón legible para personas(1 cm = 1 m)
/XSíUnidades del default user space/U (m) /C 0.0352778
/YCuando las unidades o factores de y difierenUnidades del default user spaceOmitida
/DSíLa unidad /X inicial/U (m) /C 1
/ASíLa unidad /X inicial, al cuadrado/U (sq m) /C 1
/TNoGradosOmitida
/SNoUnidad /Y inicial dividida por unidad /X inicialOmitida

En un plano métrico 1:100 la escala vive en un solo número. Un punto son 25.4 / 72 = 0.352778 mm de papel, que representan 35.2778 mm de edificio, así que /X recibe /U (m) y /C 0.0352778, mientras que /D parte de metros y /A de metros cuadrados, y ambos reciben /C 1. Copiar el factor de /X en /D y elevarlo al cuadrado para /A aplica la escala dos veces: el muro de 5 m vuelve como 0.18 m y un cuarto de 20 m² se encoge a 0.025 m². Escriba ese /C inicial de 1 en /D y /A en cada viewport, incluso donde parezca redundante. La Tabla 262 declara ambos arreglos obligatorios y no define ningún fallback, de modo que un visor que se topa con un factor faltante o inventado está improvisando, y dos visores pueden improvisar de dos maneras distintas; un diccionario escrito por completo fija cada lectura dentro del propio archivo, y las unidades quedan en código que un revisor puede cotejar con el cuadro de título. El resto del modelo de formato de número funciona así:

HotPDF cadena de conversión de THPDFRectilinearMeasureInfo para un viewport de medida PDF 1:100 en Delphi: factor /X de 0.0352778 de puntos a metros, factores /D y /A de 1, y las lecturas encogidas cuando la escala se aplica dos veces
Solo el number format de /X lleva la escala 1:100; con /D y /A fijados en factor 1, el muro sur lee 5.00 m y el cuarto 20.00 sq m
  • Las unidades encadenadas siguen la regla de la unidad previa, que es como el propio ejemplo de la norma imprime "1 mi 2,378 ft 7 5/8 in" a partir de millas con /C 1, pies con /C 5280 y pulgadas con /C 12, con /F /F /D 8 redondeando a octavos
  • El /R es un rótulo para personas, así que una cadena de razón equivocada sobre un /C correcto aun así produce números correctos
  • Los perfiles con escala vertical exagerada, el caso "in X 1 cm = 1 m, in Y 1 cm = 30 m" de la Tabla 262, necesitan /Y más un factor /CYX que convierta unidades y en unidades x; sin /CYX un lector puede negarse a calcular distancia, área y ángulo

Escribir un plano 1:100 con un detalle insertado 1:20

THPDFPage.AddRectilinearViewportEx toma el rectángulo del viewport y un record THPDFRectilinearMeasureInfo cuyos campos se mapean a /R, /X, /Y, /D, /A, /T, /S, /O y /CYX, con cada arreglo de number format guardado como records THPDFNumberFormatEntry. La tabla de arriba va sin cambios, y no queda nada que el lector deba inferir. Las reglas de llamada que aplican antes de que el código de abajo corra:

  • Un viewport rectilinear necesita PDF 1.6. HotPDF sube THotPDF.Version a pdf16 bajo demanda; con StrictVersionLock activado, el método no escribe nada y devuelve -1
  • Un /R vacío, o un arreglo /X, /D o /A sin entradas, lanza una excepción, porque la Tabla 262 exige los cuatro
  • Cada llamada añade al /VP, así que el plano va antes del detalle y el detalle queda donde un lector lo prueba antes que todo lo demás
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 = centésimas
end;

// Plano métrico a 1:Scale con todas las lecturas en metros
function MetricScale(const Ratio: AnsiString;
  Scale: Double): THPDFRectilinearMeasureInfo;
const
  MetersPerPoint = 0.0254 / 72;                  // longitud en papel de una unidad
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);    // desde unidades X
  SetLength(Result.AreaFormats, 1);
  Result.AreaFormats[0] := NumberFormat('sq m', 1, 100);     // desde unidades X al cuadrado
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;
    // ... dibuje el plano de la planta baja y el detalle de escalera ...

    // Los rectángulos están en default user space del PDF: puntos, origen abajo-izquierda
    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;

Los cuatro argumentos del rectángulo entran al /BBox tal cual, en default user space del PDF: puntos, medidos desde la esquina inferior-izquierda. Ese es el espacio desde el que convierte el factor /X, así que rectángulo y escala comparten una unidad. Las llamadas de dibujo de HotPDF usan otra, con el origen arriba a la izquierda y coordenadas en fracciones de 1/Resolution de pulgada, y la brecha entre unidades de dibujo de HotPDF y puntos PDF aplica aquí con toda su fuerza — un detalle dibujado en coordenadas de arriba-izquierda necesita sus valores y volteados contra la altura de la página y convertidos a puntos, o el rectángulo de medida cae en la mitad contraria de la lámina. Las páginas de formato grande añaden un factor más: con /UserUnit fijado vía THPDFPage.SetUserUnit, una unidad por defecto son UserUnit / 72 pulgadas, así que el factor /X se escala por el mismo valor

¿En qué orden van las coordenadas GPTS?

Un viewport de mapa lleva un diccionario de medida con /Subtype /GEO, que nació como extensión de Adobe para PDF 1.7 y pasó a ser parte del estándar en PDF 2.0 (ISO 32000-2 §12.10.2, Tabla 269). El /GCS es obligatorio y guarda un diccionario /Type /GEOGCS (Tabla 270) o un diccionario /Type /PROJCS (Tabla 271), identificados por un código /EPSG o una cadena /WKT; para un sistema geográfico la norma permite uno de los dos y prohíbe ambos juntos. Use EPSG siempre que el sistema de coordenadas tenga código: un entero no se puede romper con escape de cadenas, y los lectores lo resuelven contra el mismo registro que usan las herramientas GIS, mientras que WKT queda para las proyecciones a medida que no tienen código. El /DCS nombra opcionalmente otro sistema para mostrar posiciones. El /PDU lista unidades de visualización preferidas por nombre — lineales, de área y angulares, como KM, SQKM y DEG — y HotPDF solo lo escribe cuando las tres unidades de THPDFGeoMeasureInfo están llenas

Los pares /GPTS son latitud y luego longitud cuando el /GCS es geográfico, y este y luego norte cuando es proyectado. El orden se voltea. Pares x/y de GIS alimentando /GPTS directo salen bien para un mapa UTM y mal para WGS 84. EPSG:4326 en sí pone la latitud antes de la longitud, GeoJSON y la mayoría de las APIs de mapas web hacen lo contrario, y un mapa de calles de Zúrich registrado en orden de mapa web muestra el resultado: 8.54° este se lee como latitud, 47.37° norte como longitud, y el cursor reporta posiciones cerca de la frontera Etiopía–Somalia. El /LPTS guarda los puntos correspondientes en el cuadrado unitario del viewport, donde (0, 0) es la esquina inferior-izquierda del /BBox y (1, 1) la superior-derecha, un par por cada par /GPTS; coordenadas de página pegadas ahí dejan los puntos de control a cientos de cuadrados del mapa. El /Bounds, la neatline que limita dónde es válida la georreferencia, usa el mismo cuadrado unitario y por defecto es [0 0 0 1 1 1 1 0]. HotPDF revisa la forma antes de escribir nada: el /GPTS necesita un conteo par de al menos cuatro números, y un /LPTS no vacío debe igualar su longitud

HotPDF puntos de control GeoPDF de THPDFGeoMeasureInfo en Delphi: orden latitud-longitud de /GPTS para un GEOGCS como EPSG 4326, este-norte para un PROJCS, y esquinas /LPTS en el cuadrado unitario del viewport que usa AddGeospatialViewport
Los sistemas de coordenadas geográficos y proyectados ordenan los pares /GPTS de forma distinta, mientras que el /LPTS siempre cuenta desde la esquina inferior-izquierda del /BBox del viewport en un cuadrado unitario

Adjuntar una nube de puntos a un viewport de mapa

Un viewport GEO también puede cargar un diccionario de datos de puntos (ISO 32000-2 §12.10.5, Tabla 272): /Type /PtData, /Subtype /Cloud, un arreglo /Names que funciona como encabezados de columna y un arreglo /XPTS de filas. LAT y LON en grados y ALT en metros son encabezados predefinidos, y la norma trata las filas como una colección sin orden. Estructuralmente, /XPTS se anida mientras /GPTS queda plano: un arreglo interno por punto, dimensionado según /Names, o sea [[47.3769 8.5417 408] [47.3780 8.5400 410]]. THPDFPage.SetViewportPointDataCloud acepta igualmente una lista plana — la forma que produce un export CSV — y la rebana en filas según el conteo de columnas; un valor que parsea como número con punto decimal se vuelve un número PDF, cualquier otra cosa queda como cadena, y un conteo que no es múltiplo del conteo de columnas lanza una excepción. Ambas llamadas necesitan PDF 2.0, y el índice de viewport que devuelve AddGeospatialViewport es donde se engancha la nube

procedure AddCityMap(Page: THPDFPage);
const
  // Esquinas de la neatline, latitud antes que longitud: 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);
  // Las mismas esquinas en el cuadrado unitario del viewport
  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 queda vacío
  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 conserva a grandes rasgos el aspecto de 12.8 km x 7.8 km del mapa
  Map := Page.AddGeospatialViewport('City center', 36, 252, 576, 576, Geo);
  if Map < 0 then
    raise Exception.Create('GEO viewports need PDF 2.0');

  // Tres columnas, así que cada tres valores se vuelven una fila /XPTS
  Page.SetViewportPointDataCloud(Map, ['LAT', 'LON', 'ALT'],
    ['47.3769', '8.5417', '408',
     '47.3780', '8.5400', '410']);
end;

Leer viewports y medir en el servidor

THotPDF.GetLoadedPageViewports devuelve un THPDFViewportInfoArray para un índice de página base cero, un THPDFViewportInfo por entrada /VP en orden de archivo, con el BBox, el Name, el MeasureSubtype, el record Rectilinear o Geospatial parseado y las filas PtData que haya. Un diccionario de medida que omite /Subtype vuelve como RL, el valor por defecto que asigna la Tabla 261 de ISO 32000-1. Las dos funciones de abajo reproducen lo que hace un visor con un clic: encontrar el viewport con el recorrido inverso y convertir una distancia de página a unidades reales vía /X (o /Y con /CYX) y el factor del /D inicial. La prueba de límites usa Min y Max porque un marco de medida rotado contra la página puede listar sus esquinas en otro orden

uses
  SysUtils, Math, HPDFDoc;

// Recorra /VP hacia atrás desde la última entrada; gana el BBox contenedor alcanzado antes
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;

// Distancia entre dos puntos de página, en la unidad del /D inicial
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);            // el punto de partida decide
      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;

Salida esperada para la lámina escrita antes: Stair detail 1:20 (1 cm = 20 cm): 0.60 m, porque 85 puntos a 0.0070556 m por punto son 0.5997 m. Invierta las dos llamadas AddRectilinearViewportEx y la misma línea de 85 puntos resuelve al viewport del plano y lee 3.00 m. Para un juego de planos escaneados que usted no produjo, THotPDF.AddPageRectilinearViewportEx, AddPageGeospatialViewport y SetPageViewportPointDataCloud hacen el mismo trabajo sobre un documento cargado por índice de página antes de SaveLoadedDocument. Las anotaciones de línea, polilínea y polígono pueden llevar su propio /Measure (ISO 32000-1 Tablas 175 y 178), y GetLoadedAnnotationMeasure lo parsea en los mismos records RL y GEO, lo que importa cuando las anotaciones de medida en un PDF revisado vinieron de otra herramienta

Con /D y /A fijados en 1, el factor /X de 0.0352778 es el único número que porta la escala 1:100, y el muro sur de 5 m debería leer 5.00 m en cualquier lector que siga la §12.9. El mismo tipo de record vuelve de GetLoadedPageViewports, así que un archivo escrito por el componente PDF HotPDF para Delphi puede recargarse y sus factores compararse contra los valores que su código puso