Ein als PDF exportierter Grundriss im Maßstab 1:100 öffnet in Acrobat, und das Entfernungswerkzeug meldet die 5 m Südwand als 1.97 in, ihre Länge auf dem Papier. Was fehlt, ist ein /VP-Viewport, dessen Rectilinear-Dictionary (/RL) Points in Meter umrechnet — etwas, das HotPDF aus Delphi schreibt, zusammen mit geospatialen (/GEO) Kontrollpunkten für Karten
Was ein Viewport dem Messwerkzeug bringt
Ein Viewport ist ein Rechteck auf der Seite, das sein eigenes Mess-Koordinatensystem mitbringt. Der optionale /VP-Eintrag des Page Dictionary (ISO 32000-1 Tabelle 30, PDF 1.6) enthält ein Array von /Type /Viewport-Dictionarys, und jedes braucht nur eine /BBox: /Name beschriftet es in der Oberfläche des Viewers, und /Measure liefert den Maßstab (ISO 32000-1 §12.9, Tabelle 260; dasselbe Dictionary ist Tabelle 265 in ISO 32000-2). Eine Seite ohne jeden Viewport lässt Acrobats Messwerkzeuge auf einem 1:1-Papiermaßstab, also ist das 1.97 in einfach 50 mm Tinte, treu gemessen. /BBox wird im Default User Space geschrieben, Ecke unten links und dann oben rechts, und diese Ecken-Reihenfolge legt die Richtung der Messachsen x und y fest, die die Norm für unabhängig vom /Rotate der Seite erklärt. Falls Sie je /Rotate über die Boxen einer Seite hinweg egalisieren, ist jedes /VP-Rechteck ein weiteres Geometrie-Stück, das dem Inhalt folgen muss
Viewports dürfen sich überlappen, und §12.9 löst die Überlappung über die Array-Reihenfolge: Ein Reader, der den Viewport unter einem Punkt sucht, beginnt beim letzten /VP-Eintrag, iteriert rückwärts und stoppt bei dem, dessen /BBox den Punkt enthält. Ein Treppen-Detailausschnitt 1:20 auf einem Blatt 1:100 muss daher nach dem blattweiten Viewport kommen — hängen Sie ihn früher an, gewinnt das große Rechteck jeden Hit-Test, und das Detail misst fünfmal zu lang. Distanzen folgen ihrem Startpunkt. Eine Linie, die im Ausschnitt beginnt und draußen auf dem Hauptplan endet, wird über ihre ganze Länge mit 1:20 gemessen, Rahmen hin oder her
Die Arithmetik hinter einem Rectilinear-Maßstab
Das Rectilinear-Measure-Dictionary (ISO 32000-1 Tabelle 262, Tabelle 267 in ISO 32000-2) hat vier Pflicht-Einträge, und seine gesamte Arithmetik hängt an /C. /X, /D und /A sind Number-Format-Arrays: Number-Format-Dictionarys (Tabelle 263), geordnet von der größten Einheit abwärts, jedes mit einem /U-Label und einem /C-Umrechnungsfaktor. Spätere Dictionarys in einem Array rechnen von der Einheit vor ihnen um, während das führende Dictionary von einem anderen Ort startet, je nachdem, welcher Eintrag das Array besitzt:
| Eintrag | Pflicht | Führendes /C rechnet von | 1:100 metrisches Blatt |
|---|---|---|---|
| /R | Ja | Nichts; menschenlesbarer Ratio-Text | (1 cm = 1 m) |
| /X | Ja | Default-User-Space-Einheiten | /U (m) /C 0.0352778 |
| /Y | Wenn y-Einheiten oder -Faktoren abweichen | Default-User-Space-Einheiten | Weggelassen |
| /D | Ja | Die führende /X-Einheit | /U (m) /C 1 |
| /A | Ja | Die führende /X-Einheit, quadriert | /U (sq m) /C 1 |
| /T | Nein | Grad | Weggelassen |
| /S | Nein | Führende /Y-Einheit geteilt durch führende /X-Einheit | Weggelassen |
Auf einem metrischen 1:100-Plan steckt der Maßstab in einer einzigen Zahl. Ein Point sind 25.4 / 72 = 0.352778 mm Papier, wofür 35.2778 mm Gebäude stehen, also bekommt /X ein /U (m) und /C 0.0352778, während /D von Metern und /A von Quadratmetern startet und beide /C 1 bekommen. Den /X-Faktor nach /D zu kopieren und ihn für /A zu quadrieren wendet den Maßstab doppelt an: Die 5 m Wand kommt als 0.18 m zurück, und ein 20 m² Raum schrumpft auf 0.025 m². Schreiben Sie dieses führende /C von 1 in /D und /A bei jedem Viewport, auch wo es redundant wirkt. Tabelle 262 macht beide Arrays zur Pflicht und definiert keinen Fallback, also improvisiert ein Viewer, der auf einen fehlenden oder geratenen Faktor stößt, und zwei Viewer können auf zweierlei Weise improvisieren; ein vollständig ausgeschriebenes Dictionary nagt jeden Readout in der Datei selbst fest, und die Einheiten sitzen in Code, den ein Prüfer gegen das Schriftfeld abgleichen kann. Der Rest des Number-Format-Modells funktioniert so:
- Verkettete Einheiten folgen der Previous-Unit-Regel, womit das eigene Beispiel der Norm "1 mi 2,378 ft 7 5/8 in" ausgeben kann: Meilen bei /C 1, Feet bei /C 5280 und Inches bei /C 12, mit /F /F /D 8, gerundet auf Achtel
- /R ist eine Beschriftung für Menschen, also liefert ein falscher Ratio-String über einem richtigen /C trotzdem richtige Zahlen
- Profile mit übertriebtem Vertikalmaßstab, der Fall "in X 1 cm = 1 m, in Y 1 cm = 30 m" aus Tabelle 262, brauchen /Y plus einen /CYX-Faktor, der y-Einheiten in x-Einheiten umrechnet; ohne /CYX kann ein Reader es ablehnen, Distanz, Fläche und Winkel zu berechnen
Einen 1:100-Plan mit einem 1:20-Detailausschnitt schreiben
THPDFPage.AddRectilinearViewportEx nimmt das Viewport-Rechteck und einen THPDFRectilinearMeasureInfo-Record, dessen Felder auf /R, /X, /Y, /D, /A, /T, /S, /O und /CYX abgebildet sind, wobei jedes Number-Format-Array als THPDFNumberFormatEntry-Records vorliegt. Die Tabelle oben geht unverändert hinein, und dem Reader bleibt nichts zu erschließen. Die Aufrufregeln, die gelten, bevor der Code unten läuft:
- Ein Rectilinear-Viewport braucht PDF 1.6. HotPDF hebt
THotPDF.Versionbei Bedarf aufpdf16an; mit gesetztemStrictVersionLockschreibt die Methode nichts und gibt -1 zurück - Ein leeres /R oder ein /X-, /D- oder /A-Array ohne Einträge wirft eine Exception, denn Tabelle 262 verlangt alle vier
- Jeder Aufruf hängt an /VP an, also kommt der Plan vor den Ausschnitt, und der Ausschnitt landet dort, wo ein Reader ihn vor allem anderen testet
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 = Hundertstel
end;
// Metrische Zeichnung bei 1:Scale mit jedem Readout in Metern
function MetricScale(const Ratio: AnsiString;
Scale: Double): THPDFRectilinearMeasureInfo;
const
MetersPerPoint = 0.0254 / 72; // Papierlänge einer Einheit
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); // aus X-Einheiten
SetLength(Result.AreaFormats, 1);
Result.AreaFormats[0] := NumberFormat('sq m', 1, 100); // aus X-Einheiten quadriert
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;
// ... Grundriss und Treppen-Detailausschnitt zeichnen ...
// Rechtecke sind PDF Default User Space: Points, Ursprung unten links
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;
Die vier Rechteck-Argumente gehen unverändert in die /BBox, im PDF Default User Space: Points, gemessen von der Ecke unten links. Das ist der Raum, von dem der /X-Faktor umrechnet, also teilen Rechteck und Maßstab eine Einheit. HotPDF-Zeichenaufrufe benutzen eine andere, mit dem Ursprung oben links und Koordinaten in 1/Resolution Inch, und die Kluft zwischen HotPDF-Zeicheneinheiten und PDF Points greift hier in voller Härte — ein Ausschnitt in Top-Left-Koordinaten braucht seine y-Werte gespiegelt an der Seitenhöhe und in Points umgerechnet, sonst landet das Messrechteck auf der falschen Hälfte des Blatts. Großformat-Seiten legen einen weiteren Faktor obendrauf. Mit /UserUnit, gesetzt über THPDFPage.SetUserUnit, ist eine Default-Einheit UserUnit / 72 Inch, also skaliert der /X-Faktor um denselben Wert
Welche Reihenfolge verwenden GPTS-Koordinaten?
Ein Karten-Viewport trägt ein Measure-Dictionary mit /Subtype /GEO, das als Adobe-Erweiterung zu PDF 1.7 begann und in PDF 2.0 Teil der Norm wurde (ISO 32000-2 §12.10.2, Tabelle 269). /GCS ist Pflicht und hält ein /Type /GEOGCS-Dictionary (Tabelle 270) oder ein /Type /PROJCS-Dictionary (Tabelle 271), identifiziert über einen /EPSG-Code oder einen /WKT-String; für ein geographisches System erlaubt die Norm eines von beiden und verbietet beide zusammen. Nehmen Sie EPSG, wann immer das Koordinatensystem einen Code hat. Ein Integer lässt sich durch String-Escaping nicht kaputt machen, und Reader lösen ihn gegen dieselbe Registry auf, die auch GIS-Tools benutzen, während WKT für die Custom-Projektionen ohne Code bleibt. /DCS benennt optional ein anderes System zur Anzeige von Positionen. /PDU listet bevorzugte Anzeigeeinheiten beim Namen auf — linear, Fläche und Winkel, etwa KM, SQKM und DEG — und HotPDF schreibt es nur, wenn alle drei Einheiten in THPDFGeoMeasureInfo gefüllt sind
/GPTS-Paare sind Breite dann Länge, wenn /GCS geographisch ist, und Ostwert dann Nordwert, wenn es projiziert ist. Die Reihenfolge kippt. GIS-x/y-Paare, direkt in /GPTS gefüttert, kommen bei einer UTM-Karte richtig heraus und bei WGS 84 falsch. EPSG:4326 selbst stellt die Breite vor die Länge, GeoJSON und die meisten Web-Map-APIs machen es umgekehrt, und eine Zürcher Straßenkarte, in Web-Map-Reihenfolge registriert, zeigt das Ergebnis: 8.54° Ost wird als Breite gelesen, 47.37° Nord als Länge, und der Cursor meldet Positionen nahe der Äthiopien–Somalia-Grenze. /LPTS hält die passenden Punkte im Einheitsquadrat des Viewports, wobei (0, 0) die Ecke unten links der /BBox ist und (1, 1) oben rechts, ein Paar pro /GPTS-Paar; hineingeklebte Seitenkoordinaten setzen die Kontrollpunkte hunderte Quadrate neben die Karte. /Bounds, die Neatline, die begrenzt, wo die Georeferenz gültig ist, benutzt dasselbe Einheitsquadrat, und ihr Default ist [0 0 0 1 1 1 1 0]. HotPDF prüft die Form, bevor es irgendetwas schreibt: /GPTS braucht eine gerade Anzahl von mindestens vier Zahlen, und ein nicht-leeres /LPTS muss zur Länge passen
Eine Punktwolke an einen Karten-Viewport hängen
Ein GEO-Viewport darf zusätzlich ein Point-Data-Dictionary tragen (ISO 32000-2 §12.10.5, Tabelle 272): /Type /PtData, /Subtype /Cloud, ein /Names-Array, das als Spaltenköpfe dient, und ein /XPTS-Array von Zeilen. LAT und LON in Grad und ALT in Metern sind vordefinierte Köpfe, und die Norm behandelt die Zeilen als ungeordnete Sammlung. Strukturell verschachtelt sich /XPTS, während /GPTS flach bleibt: ein inneres Array pro Punkt, bemessen nach /Names, also [[47.3769 8.5417 408] [47.3780 8.5400 410]]. THPDFPage.SetViewportPointDataCloud akzeptiert weiterhin eine einzige flache Liste, die Form, die ein CSV-Export produziert, und schneidet sie anhand der Spaltenzahl in Zeilen; ein Wert, der als Zahl mit Punkt-Dezimaltrenner parst, wird eine PDF-Zahl, alles andere bleibt ein String, und eine Anzahl, die kein Vielfaches der Spaltenzahl ist, wirft eine Exception. Beide Aufrufe brauchen PDF 2.0, und der Viewport-Index, den AddGeospatialViewport zurückgibt, ist die Stelle, an der die Wolke hängt
procedure AddCityMap(Page: THPDFPage);
const
// Neatline-Ecken, Breite vor Länge: 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);
// Dieselben Ecken im Viewport-Einheitsquadrat
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 bleibt leer
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 hält ungefähr das 12.8 km x 7.8 km Seitenverhältnis der Karte
Map := Page.AddGeospatialViewport('City center', 36, 252, 576, 576, Geo);
if Map < 0 then
raise Exception.Create('GEO viewports need PDF 2.0');
// Drei Spalten, also werden je drei Werte eine /XPTS-Zeile
Page.SetViewportPointDataCloud(Map, ['LAT', 'LON', 'ALT'],
['47.3769', '8.5417', '408',
'47.3780', '8.5400', '410']);
end;
Viewports zurücklesen und auf dem Server messen
THotPDF.GetLoadedPageViewports gibt für einen nullbasierten Seitenindex ein THPDFViewportInfoArray zurück, einen THPDFViewportInfo pro /VP-Eintrag in Dateireihenfolge, mit BBox, Name, MeasureSubtype, dem geparsten Rectilinear- oder Geospatial-Record und allen PtData-Zeilen. Ein Measure-Dictionary ohne /Subtype kommt als RL zurück, dem Default, den ISO 32000-1 Tabelle 261 zuweist. Die beiden Funktionen unten reproduzieren, was ein Viewer mit einem Klick macht: den Viewport über den umgekehrten Walk finden, dann eine Seiten-Distanz über /X (oder /Y mit /CYX) und den führenden /D-Faktor in echte Einheiten überführen. Der Bounds-Test benutzt Min und Max, weil ein gegen die Seite gedrehter Messrahmen seine Ecken in anderer Reihenfolge listen kann
uses
SysUtils, Math, HPDFDoc;
// /VP rückwärts ab dem letzten Eintrag durchgehen; die zuerst erreichte BBox gewinnt
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;
// Distanz zwischen zwei Seitenpunkten, in der führenden /D-Einheit
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); // der Startpunkt entscheidet
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;
Erwartete Ausgabe für das vorhin geschriebene Blatt: Stair detail 1:20 (1 cm = 20 cm): 0.60 m, denn 85 Points bei 0.0070556 m pro Point sind 0.5997 m. Vertauscht man die beiden AddRectilinearViewportEx-Aufrufe, löst dieselbe 85-Point-Linie zum Plan-Viewport auf und liest 3.00 m. Für einen gescannten Zeichnungssatz, den Sie nicht selbst erzeugt haben, erledigen THotPDF.AddPageRectilinearViewportEx, AddPageGeospatialViewport und SetPageViewportPointDataCloud dieselbe Arbeit an einem geladenen Dokument per Seitenindex, vor SaveLoadedDocument. Line-, Polyline- und Polygon-Annotationen können ihr eigenes /Measure tragen (ISO 32000-1 Tabellen 175 und 178), und GetLoadedAnnotationMeasure parst es in dieselben RL- und GEO-Records, was zählt, wenn die Mess-Annotationen in einem geprüften PDF aus einem anderen Tool stammen
Mit /D und /A auf 1 festgenagelt ist der /X-Faktor 0.0352778 die einzige Zahl, die den 1:100-Maßstab trägt, und die 5 m Südwand sollte in jedem Reader, der §12.9 folgt, als 5.00 m erscheinen. Derselbe Record-Typ kommt aus GetLoadedPageViewports zurück, also lässt sich eine Datei, die die HotPDF Delphi PDF-Komponente schrieb, neu laden und ihre Faktoren gegen die Werte vergleichen, die Ihr Code hineingeschrieben hat