Artículo técnico

Viewports de medida PDF para planos a escala y mapas GeoPDF

Un plano de planta a 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 sobre el papel. Lo que falta es un viewport /VP cuyo diccionario de medida rectilíneo (/RL) convierta puntos a metros — algo que HotPDF escribe desde Delphi, junto con puntos de control geoespaciales (/GEO) para mapas

Qué aporta un viewport a la herramienta de medida

Un viewport es un rectángulo sobre 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 array de diccionarios /Type /Viewport, y cada uno necesita solo un /BBox: /Name lo etiqueta en la interfaz del viewer 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. /BBox se escribe en el user space por defecto, 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 aplanas /Rotate en todas las cajas de una página, cada rectángulo /VP es una pieza más de geometría que tiene que seguir al contenido

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

Array de viewports /VP de HotPDF en Delphi: AddRectilinearViewportEx añade un detalle de escalera 1:20 después del plano 1:100, así que el hit test inverso elige el detalle y una línea de 85 puntos marca 0.60 m, no 3.00 m
Añadir el detalle de escalera después del viewport del plano completo deja que el recorrido inverso de /VP lo encuentre primero, y el tramo de escalera de 85 puntos marca 0.60 m en lugar de 3.00 m

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

El diccionario de medida rectilíneo (ISO 32000-1 Tabla 262, Tabla 267 en ISO 32000-2) tiene cuatro entradas obligatorias, y toda su aritmética cuelga de /C. /X, /D y /A son arrays de formatos numéricos: diccionarios de formato numérico (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 array convierten desde la unidad que tienen delante, mientras que el primer diccionario parte de un sitio distinto según qué entrada posea el array:

EntradaObligatoriaEl /C inicial convierte desdeHoja métrica 1:100
/RSíNada; texto de ratio legible por personas(1 cm = 1 m)
/XSíUnidades del user space por defecto/U (m) /C 0.0352778
/YCuando las unidades o factores en y difierenUnidades del user space por defectoSe omite
/DSíLa unidad inicial de /X/U (m) /C 1
/ASíLa unidad inicial de /X, al cuadrado/U (sq m) /C 1
/TNoGradosSe omite
/SNoUnidad inicial de /Y dividida por unidad inicial de /XSe omite

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 una sala de 20 m² se encoge a 0.025 m². Escribe ese /C inicial de 1 en /D y /A en cada viewport, incluso donde parezca redundante. La Tabla 262 hace obligatorios ambos arrays y no define ningún fallback, así que un viewer que se encuentra con un factor ausente o inventado está improvisando, y dos viewers pueden improvisar de dos maneras distintas; un diccionario escrito por completo fija cada lectura dentro del propio fichero, y las unidades quedan en código que un revisor puede cotejar con el cuadro de rotulación. El resto del modelo de formatos numéricos funciona así:

Cadena de conversión de THPDFRectilinearMeasureInfo de HotPDF para un viewport de medida PDF 1:100 en Delphi: factor /X 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 formato numérico de /X lleva la escala 1:100; con /D y /A fijados en un factor de 1, el muro sur marca 5.00 m y la sala 20.00 sq m
  • Las unidades encadenadas siguen la regla de la unidad anterior, que es cómo 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
  • /R es un rótulo para personas, así que una cadena de ratio equivocada sobre un /C correcto sigue produciendo 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 a 1:20

THPDFPage.AddRectilinearViewportEx toma el rectángulo del viewport y un registro THPDFRectilinearMeasureInfo cuyos campos se corresponden con /R, /X, /Y, /D, /A, /T, /S, /O y /CYX, con cada array de formatos numéricos guardado como registros THPDFNumberFormatEntry. La tabla de arriba va tal cual y no queda nada que el lector tenga que inferir. Las reglas de llamada que aplican antes de ejecutar el código de abajo:

  • Un viewport rectilíneo necesita PDF 1.6. HotPDF eleva THotPDF.Version a pdf16 a demanda; con StrictVersionLock activado, el método no escribe nada y devuelve -1
  • Un /R vacío, o un array /X, /D o /A sin entradas, lanza una excepción, ya que la Tabla 262 exige las cuatro
  • Cada llamada añade al final de /VP, así que el plano va antes que el detalle y el detalle queda donde un lector lo prueba por delante de 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;

// Dibujo 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;
    // ... dibuja el plano de planta y el detalle de escalera ...

    // Los rectángulos van en user space por defecto de 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 van al /BBox tal cual, en el user space por defecto de PDF: puntos, medidos desde la esquina inferior izquierda. Ese es el espacio desde el que convierte el factor de /X, así que rectángulo y escala comparten unidad. Las llamadas de dibujo de HotPDF usan otra distinta, con el origen arriba a la izquierda y coordenadas en fracciones de 1/Resolution de pulgada, y la diferencia entre las unidades de dibujo de HotPDF y los puntos PDF aplica aquí entera — un detalle dibujado en coordenadas de arriba-izquierda necesita sus valores y invertidos frente a la altura de página y convertidos a puntos, o el rectángulo de medida cae en la mitad opuesta de la hoja. Las páginas de gran formato añaden un factor más: con /UserUnit fijado mediante THPDFPage.SetUserUnit, una unidad por defecto son UserUnit / 72 pulgadas, así que el factor de /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 a PDF 1.7 y pasó a la norma en PDF 2.0 (ISO 32000-2 §12.10.2, Tabla 269). /GCS es obligatorio y guarda un diccionario /Type /GEOGCS (Tabla 270) o un diccionario /Type /PROJCS (Tabla 271), identificado 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. Usa EPSG siempre que el sistema de coordenadas tenga código: un entero no lo rompe ningún 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 sin código. /DCS nombra opcionalmente otro sistema para mostrar posiciones. /PDU lista unidades de visualización preferidas por nombre — lineales, de área y angulares, como KM, SQKM y DEG — y HotPDF solo la escribe cuando las tres unidades de THPDFGeoMeasureInfo están rellenas

Los pares /GPTS van latitud y luego longitud cuando /GCS es geográfico, y este y luego norte cuando es proyectado. El orden se invierte. Los pares x/y de GIS metidos directamente en /GPTS salen bien para un mapa UTM y mal para WGS 84. EPSG:4326 pone la latitud delante de la longitud, GeoJSON y la mayoría de las APIs de mapas web hacen lo contrario, y un callejero de Zúrich registrado en orden de mapa web muestra el resultado: 8.54° este se lee como latitud y 47.37° norte como longitud, y el cursor reporta posiciones cerca de la frontera entre Etiopía y Somalia. /LPTS guarda los puntos correspondientes en el cuadrado unitario del viewport, donde (0, 0) es la esquina inferior izquierda de /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. /Bounds, la línea de corte que limita dónde es válida la georreferencia, usa el mismo cuadrado unitario y por defecto vale [0 0 0 1 1 1 1 0]. HotPDF comprueba la forma antes de escribir nada: /GPTS necesita un número par de al menos cuatro valores, y un /LPTS no vacío debe igualar su longitud

Puntos de control GeoPDF de THPDFGeoMeasureInfo de HotPDF 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 /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 puede llevar además un diccionario de datos de puntos (ISO 32000-2 §12.10.5, Tabla 272): /Type /PtData, /Subtype /Cloud, un array /Names que funciona como cabeceras de columna y un array /XPTS de filas. LAT y LON en grados y ALT en metros son cabeceras predefinidas, y la norma trata las filas como una colección sin orden. Estructuralmente, /XPTS anida mientras /GPTS se queda plano: un array interno por punto, dimensionado según /Names, así [[47.3769 8.5417 408] [47.3780 8.5400 410]]. THPDFPage.SetViewportPointDataCloud acepta todavía una lista plana, la forma que produce una exportación CSV, y la corta en filas según el número de columnas; un valor que parsea como número con separador decimal de punto se convierte en número PDF, cualquier otra cosa se queda como cadena, y un conteo que no sea múltiplo del número 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 de longitud: NO, NE, SE, SO
  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 más o menos la proporción 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 forman una fila /XPTS
  Page.SetViewportPointDataCloud(Map, ['LAT', 'LON', 'ALT'],
    ['47.3769', '8.5417', '408',
     '47.3780', '8.5400', '410']);
end;

Leer los 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 fichero, con el BBox, el Name, el MeasureSubtype, el registro 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 viewer con un clic: encontrar el viewport con el recorrido inverso y convertir una distancia de página en unidades reales mediante /X (o /Y con /CYX) y el factor inicial de /D. La prueba de límites usa Min y Max porque un marco de medida rotado frente a la página puede listar sus esquinas en otro orden

uses
  SysUtils, Math, HPDFDoc;

// Recorre /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 inicial de /D
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 hoja 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. Intercambia las dos llamadas AddRectilinearViewportEx y la misma línea de 85 puntos resuelve al viewport del plano y marca 3.00 m. Para un juego de planos escaneados que no produjiste tú, 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 registros RL y GEO, lo que importa cuando las anotaciones de medida de un PDF revisado vinieron de otra herramienta

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