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
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:
| Entrada | Obligatoria | El /C inicial convierte desde | Hoja métrica 1:100 |
|---|---|---|---|
| /R | Sí | Nada; texto de ratio legible por personas | (1 cm = 1 m) |
| /X | Sí | Unidades del user space por defecto | /U (m) /C 0.0352778 |
| /Y | Cuando las unidades o factores en y difieren | Unidades del user space por defecto | Se omite |
| /D | Sí | La unidad inicial de /X | /U (m) /C 1 |
| /A | Sí | La unidad inicial de /X, al cuadrado | /U (sq m) /C 1 |
| /T | No | Grados | Se omite |
| /S | No | Unidad inicial de /Y dividida por unidad inicial de /X | Se 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í:
- 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.Versionapdf16a demanda; conStrictVersionLockactivado, 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
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