Articolo tecnico

Viewport di misura PDF per disegni in scala e mappe GeoPDF

Una planimetria del piano terra in scala 1:100 esportata in PDF si apre in Acrobat, e lo strumento distanza riporta il muro sud da 5 m come 1.97 in, la sua lunghezza sulla carta. Il pezzo mancante è un viewport /VP il cui dizionario di misura rectilinear (/RL) converte i punti in metri — una cosa che HotPDF scrive da Delphi, insieme ai punti di controllo geospatial (/GEO) per le mappe

Cosa offre un viewport allo strumento di misura

Un viewport è un rettangolo sulla pagina che porta con sé il proprio sistema di coordinate di misura. La voce opzionale /VP del dizionario di pagina (ISO 32000-1 Tabella 30, PDF 1.6) contiene un array di dizionari /Type /Viewport, e ciascuno richiede solo un /BBox: /Name lo etichetta nell'interfaccia del viewer e /Measure fornisce la scala (ISO 32000-1 §12.9, Tabella 260; lo stesso dizionario è la Tabella 265 in ISO 32000-2). Una pagina senza viewport lascia gli strumenti di misura di Acrobat su una scala carta 1:1, quindi l'1.97 in è semplicemente 50 mm d'inchiostro misurati fedelmente. /BBox è scritto in default user space, angolo in basso a sinistra e poi in alto a destra, e quell'ordine degli angoli fissa la direzione degli assi x e y di misura, che lo standard dichiara indipendenti dal /Rotate della pagina. Se vi capita di appiattire il /Rotate sui box di una pagina, ogni rettangolo /VP è un altro pezzo di geometria che deve seguire il contenuto

I viewport possono sovrapporsi, e il §12.9 risolve la sovrapposizione per ordine di array: un reader che cerca il viewport sotto un punto parte dall'ultima voce /VP, itera all'indietro e si ferma a quella il cui /BBox contiene il punto. Un dettaglio di scala 1:20 su un foglio 1:100 va quindi dopo il viewport esteso a tutto il foglio — se lo accodate prima, il grande rettangolo vince ogni hit test, e il dettaglio misura cinque volte troppo. Le distanze seguono il loro punto di partenza: una linea che inizia dentro l'inserto e finisce fuori, sul disegno generale, viene misurata a 1:20 su tutta la sua lunghezza, con o senza bordo

Array di viewport /VP di HotPDF in Delphi: AddRectilinearViewportEx accoda un dettaglio scala 1:20 dopo la planimetria 1:100, così l'hit test inverso sceglie l'inserto e una linea di 85 punti legge 0.60 m, non 3.00 m
Accodare l'inserto della scala dopo il viewport della planimetria esteso a tutto il foglio lascia che la camminata inversa /VP lo trovi per primo, tenendo la rampa di 85 punti a 0.60 m anziché 3.00 m

L'aritmetica dietro una scala rectilinear

Il dizionario di misura rectilinear (ISO 32000-1 Tabella 262, Tabella 267 in ISO 32000-2) ha quattro voci obbligatorie, e tutta la sua aritmetica pende da /C. /X, /D e /A sono array di number format: dizionari di number format (Tabella 263) ordinati dall'unità più grande in giù, ciascuno con un'etichetta /U e un fattore di conversione /C. I dizionari successivi in un array convertono dall'unità che li precede, mentre il primo dizionario parte da un punto diverso a seconda della voce a cui appartiene l'array:

VoceObbligatoriaLa /C iniziale converte daFoglio metrico 1:100
/RSìNulla; testo del rapporto leggibile dalle persone(1 cm = 1 m)
/XSìUnità del default user space/U (m) /C 0.0352778
/YQuando unità o fattori di y differisconoUnità del default user spaceOmeso
/DSìL'unità /X iniziale/U (m) /C 1
/ASìL'unità /X iniziale, al quadrato/U (sq m) /C 1
/TNoGradiOmeso
/SNoUnità /Y iniziale divisa per l'unità /X inizialeOmeso

Su una planimetria metrica 1:100 la scala sta in un numero. Un punto è 25.4 / 72 = 0.352778 mm di carta, che rappresenta 35.2778 mm di edificio, quindi /X prende /U (m) e /C 0.0352778, mentre /D parte dai metri e /A dai metri quadri e entrambi prendono /C 1. Copiare il fattore /X dentro /D ed elevarlo al quadrato per /A applica la scala due volte: il muro da 5 m torna come 0.18 m, e una stanza di 20 m² si riduce a 0.025 m². Scrivete quella /C iniziale di 1 dentro /D e /A su ogni viewport, anche dove sembra ridondante. La Tabella 262 rende entrambi gli array obbligatori e non definisce alcun ripiego, quindi un viewer che incontra un fattore mancante o indovinato sta improvvisando, e due viewer possono improvvisare in due modi diversi; un dizionario scritto per intero fissa ogni lettura nel file stesso, e le unità stanno in un codice che chi revisiona può confrontare con il cartiglio. Il resto del modello di number format funziona così:

Catena di conversione THPDFRectilinearMeasureInfo di HotPDF per un viewport di misura PDF 1:100 in Delphi: fattore /X 0.0352778 da punti a metri, fattori /D e /A di 1, e le letture ridotte quando la scala viene applicata due volte
Solo il number format /X porta la scala 1:100; con /D e /A fissati a fattore 1, il muro sud legge 5.00 m e la stanza 20.00 sq m
  • Le unità in catena seguono la regola dell'unità precedente, ed è così che l'esempio dello standard stesso stampa "1 mi 2,378 ft 7 5/8 in" da miglia a /C 1, piedi a /C 5280 e pollici a /C 12, con /F /F /D 8 che arrotonda agli ottavi
  • Il /R è una didascalia per le persone, quindi una stringa di rapporto sbagliata sopra un /C giusto produce comunque numeri giusti
  • I profili con scala verticale esagerata, il caso "in X 1 cm = 1 m, in Y 1 cm = 30 m" della Tabella 262, richiedono /Y più un fattore /CYX che converta le unità di y in unità di x; senza /CYX un reader può rifiutarsi di calcolare distanza, area e angolo

Scrivere una planimetria 1:100 con un inserto di dettaglio 1:20

THPDFPage.AddRectilinearViewportEx prende il rettangolo del viewport e un record THPDFRectilinearMeasureInfo i cui campi mappano su /R, /X, /Y, /D, /A, /T, /S, /O e /CYX, con ogni array di number format tenuto come record THPDFNumberFormatEntry. La tabella qui sopra va dentro invariata, e nulla resta da inferire per il reader. Le regole di chiamata che valgono prima che il codice qui sotto giri:

  • Un viewport rectilinear richiede PDF 1.6. HotPDF alza THotPDF.Version a pdf16 su domanda; con StrictVersionLock impostato, il metodo non scrive nulla e restituisce -1
  • Un /R vuoto, o un array /X, /D o /A senza voci, solleva un'eccezione, visto che la Tabella 262 esige tutti e quattro
  • Ogni chiamata accoda a /VP, quindi la planimetria va dentro prima dell'inserto e l'inserto finisce nel punto in cui un reader lo testa davanti a tutto il resto
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 = centesimi
end;

// Disegno metrico a 1:Scale con ogni lettura in metri
function MetricScale(const Ratio: AnsiString;
  Scale: Double): THPDFRectilinearMeasureInfo;
const
  MetersPerPoint = 0.0254 / 72;                  // lunghezza sulla carta di una 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);    // da unità X
  SetLength(Result.AreaFormats, 1);
  Result.AreaFormats[0] := NumberFormat('sq m', 1, 100);     // da unità X al quadrato
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;
    // ... disegnate la planimetria del piano terra e l'inserto della scala ...

    // I rettangoli sono in PDF default user space: punti, origine in basso a sinistra
    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;

I quattro argomenti del rettangolo vanno in /BBox così come sono, in PDF default user space: punti, misurati dall'angolo in basso a sinistra. È lo spazio da cui il fattore /X converte, quindi rettangolo e scala condividono una sola unità. Le chiamate di disegno di HotPDF usano un'altra unità, con l'origine in alto a sinistra e coordinate in pollici da 1/Resolution, e il divario fra unità di disegno HotPDF e punti PDF qui vale per intero — un inserto disegnato con coordinate alto-sinistra ha bisogno dei suoi valori di y ribaltati contro l'altezza della pagina e convertiti in punti, altrimenti il rettangolo di misura atterra sulla metà opposta del foglio. Le pagine in grande formato aggiungono un fattore ulteriore: con /UserUnit impostato tramite THPDFPage.SetUserUnit, una unità default vale UserUnit / 72 di pollice, quindi il fattore /X scala dello stesso valore

Quale ordine usano le coordinate GPTS?

Un viewport di mappa porta un dizionario di misura con /Subtype /GEO, nato come estensione Adobe al PDF 1.7 e diventato parte dello standard nel PDF 2.0 (ISO 32000-2 §12.10.2, Tabella 269). /GCS è obbligatorio e contiene un dizionario /Type /GEOGCS (Tabella 270) o un dizionario /Type /PROJCS (Tabella 271), identificato da un codice /EPSG o da una stringa /WKT; per un sistema geografico lo standard ne permette uno dei due e vieta entrambi insieme. Usate EPSG ogni volta che il sistema di coordinate ha un codice: un intero non può essere corrotto dall'escaping di stringhe, e i reader lo risolvono nello stesso registro che usano gli strumenti GIS, mentre il WKT resta per le proiezioni personalizzate prive di codice. /DCS nomina opzionalmente un altro sistema per mostrare le posizioni. /PDU elenca per nome le unità di visualizzazione preferite — lineari, di area e angolari, come KM, SQKM e DEG — e HotPDF la scrive solo quando tutte e tre le unità in THPDFGeoMeasureInfo sono compilate

Le coppie /GPTS sono latitudine poi longitudine quando /GCS è geografico, ed easting poi northing quando è proiettato. L'ordine si ribalta. Coppie x/y GIS immerse direttamente in /GPTS vengono fuori giuste per una mappa UTM e sbagliate per WGS 84. EPSG:4326 stesso mette la latitudine avanti alla longitudine, GeoJSON e la maggior parte delle API di web map fanno l'opposto, e una mappa stradale di Zurigo registrata in ordine web map mostra il risultato: 8.54° est viene letto come latitudine, 47.37° nord come longitudine, e il cursore riporta posizioni vicino al confine Etiopia–Somalia. /LPTS contiene i punti corrispondenti nel quadrato unitario del viewport, dove (0, 0) è l'angolo in basso a sinistra di /BBox e (1, 1) quello in alto a destra, una coppia per ogni coppia /GPTS; coordinate di pagina incollate lì mettono i punti di controllo a centinaia di quadrati dalla mappa. /Bounds, la neatline che limita dove la georeferenziazione è valida, usa lo stesso quadrato unitario e vale [0 0 0 1 1 1 1 0] per default. HotPDF controlla la forma prima di scrivere qualsiasi cosa: /GPTS richiede un numero pari di valori, almeno quattro, e una /LPTS non vuota deve eguagliarne la lunghezza

Punti di controllo GeoPDF THPDFGeoMeasureInfo di HotPDF in Delphi: ordine latitudine-longitudine di /GPTS per un GEOGCS come EPSG 4326, easting-northing per un PROJCS, e angoli /LPTS nel quadrato unitario del viewport usato da AddGeospatialViewport
I sistemi di coordinate geografici e proiettati ordinano le coppie /GPTS in modo diverso, mentre /LPTS conta sempre dall'angolo in basso a sinistra del /BBox del viewport in un quadrato unitario

Attaccare una nuvola di punti a un viewport di mappa

Un viewport GEO può anche portare un dizionario di dati puntuali (ISO 32000-2 §12.10.5, Tabella 272): /Type /PtData, /Subtype /Cloud, un array /Names che funziona da intestazioni di colonna e un array /XPTS di righe. LAT e LON in gradi e ALT in metri sono intestazioni predefinite, e lo standard tratta le righe come una collezione non ordinata. Strutturalmente /XPTS si annida mentre /GPTS resta piatto: un array interno per punto, dimensionato su /Names, quindi [[47.3769 8.5417 408] [47.3780 8.5400 410]]. THPDFPage.SetViewportPointDataCloud accetta comunque una sola lista piatta, la forma che produce un export CSV, e la affetta in righe secondo il numero di colonne; un valore che si fa il parse come numero con separatore decimale punto diventa un numero PDF, tutto il resto resta una stringa, e un conteggio che non è multiplo del numero di colonne solleva un'eccezione. Entrambe le chiamate richiedono PDF 2.0, e l'indice di viewport che AddGeospatialViewport restituisce è dove la nuvola si aggancia

procedure AddCityMap(Page: THPDFPage);
const
  // Angoli della neatline, latitudine prima della longitudine: 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);
  // I stessi angoli nel quadrato 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 resta vuoto
  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 all'incirca l'aspetto 12.8 km x 7.8 km della mappa
  Map := Page.AddGeospatialViewport('City center', 36, 252, 576, 576, Geo);
  if Map < 0 then
    raise Exception.Create('GEO viewports need PDF 2.0');

  // Tre colonne, così ogni tre valori diventano una riga /XPTS
  Page.SetViewportPointDataCloud(Map, ['LAT', 'LON', 'ALT'],
    ['47.3769', '8.5417', '408',
     '47.3780', '8.5400', '410']);
end;

Rileggere i viewport e misurare sul server

THotPDF.GetLoadedPageViewports restituisce un THPDFViewportInfoArray per un indice di pagina a base zero, un THPDFViewportInfo per voce /VP in ordine di file, con il BBox, il Name, il MeasureSubtype, il record Rectilinear o Geospatial già fatto il parse e le eventuali righe PtData. Un dizionario di misura che omette /Subtype torna come RL, il default che la Tabella 261 di ISO 32000-1 assegna. Le due funzioni qui sotto riproducono quello che un viewer fa con un click: trovare il viewport con la camminata inversa, poi trasformare una distanza sulla pagina in unità reali tramite /X (o /Y con /CYX) e il fattore /D iniziale. Il test dei bounds usa Min e Max perché una cornice di misura ruotata rispetto alla pagina può elencare i propri angoli in un altro ordine

uses
  SysUtils, Math, HPDFDoc;

// Percorrete /VP all'indietro dall'ultima voce; vince il BBox contenitore raggiunto prima
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;

// Distanza fra due punti della pagina, nell'unità /D iniziale
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);            // decide il punto di partenza
      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;

Output atteso per il foglio scritto prima: Stair detail 1:20 (1 cm = 20 cm): 0.60 m, dato che 85 punti a 0.0070556 m per punto fanno 0.5997 m. Scambiate le due chiamate AddRectilinearViewportEx e la stessa linea da 85 punti si risolve sul viewport della planimetria e legge 3.00 m. Per un corredo di disegni scannerizzato che non avete prodotto voi, THotPDF.AddPageRectilinearViewportEx, AddPageGeospatialViewport e SetPageViewportPointDataCloud fanno lo stesso lavoro su un documento caricato, per indice di pagina, prima di SaveLoadedDocument. Le annotazioni line, polyline e polygon possono portare un /Measure proprio (ISO 32000-1 Tabelle 175 e 178), e GetLoadedAnnotationMeasure lo scompatta negli stessi record RL e GEO, cosa che conta quando le annotazioni di misura in un PDF revisionato vengono da un altro strumento

Con /D e /A fissati a 1, il fattore /X di 0.0352778 è l'unico numero che porta la scala 1:100, e il muro sud da 5 m dovrebbe leggere 5.00 m in qualunque reader che segua il §12.9. Lo stesso tipo di record torna da GetLoadedPageViewports, quindi un file scritto dal componente PDF HotPDF per Delphi può essere ricaricato e i suoi fattori confrontati con i valori che il vostro codice ci ha messo