Article technique

Viewports de mesure PDF : échelles et cartes GeoPDF

Un plan de rez-de-chaussée au 1:100 exporté en PDF s'ouvre dans Acrobat, et l'outil de distance rapporte le mur sud de 5 m comme 1.97 in, sa longueur sur papier. La pièce manquante est un viewport /VP dont le dictionnaire de mesure rectiligne (/RL) convertit les points en mètres — une chose qu'HotPDF écrit depuis Delphi, avec les points de contrôle géospatiaux (/GEO) pour les cartes

Ce que le viewport donne à l'outil de mesure

Un viewport est un rectangle sur la page qui apporte son propre système de coordonnées de mesure. L'entrée optionnelle /VP du dictionnaire de page (ISO 32000-1 Table 30, PDF 1.6) porte un tableau de dictionnaires /Type /Viewport, et chacun ne demande qu'un /BBox : /Name le libelle dans l'interface du viewer et /Measure fournit l'échelle (ISO 32000-1 §12.9, Table 260 ; le même dictionnaire est la Table 265 de l'ISO 32000-2). Une page sans viewport laisse les outils de mesure d'Acrobat à une échelle papier 1:1, si bien que les 1.97 in sont simplement 50 mm d'encre mesurés fidèlement. /BBox s'écrit en espace utilisateur par défaut, coin inférieur gauche puis supérieur droit, et cet ordre de coins fixe la direction des axes x et y de mesure, que la norme déclare indépendants du /Rotate de la page. Si un jour vous aplatissez le /Rotate à travers les boîtes d'une page, chaque rectangle /VP est une géométrie de plus qui doit suivre le contenu

Les viewports peuvent se chevaucher, et le §12.9 résout le chevauchement par l'ordre du tableau : un lecteur qui cherche le viewport sous un point part de la dernière entrée /VP, itère à rebours et stoppe à celle dont le /BBox contient le point. Un encart de détail d'escalier au 1:20 sur une feuille au 1:100 doit donc venir après le viewport de la feuille entière — accolé plus tôt, le grand rectangle gagne chaque test de frappe, et le détail se mesure cinq fois trop long. Les distances suivent leur point de départ. Une ligne qui commence dans l'encart et finit sur le plan principal se mesure au 1:20 sur toute sa longueur, bordure ou non

Tableau de viewports /VP avec HotPDF en Delphi : AddRectilinearViewportEx accole un détail d'escalier au 1:20 après le plan au 1:100, si bien que le test de frappe à rebours choisit l'encart et une ligne de 85 points se lit 0.60 m, pas 3.00 m
Accoler l'encart d'escalier après le viewport du plan pleine feuille laisse le parcours /VP à rebours le trouver en premier, gardant la volée d'escalier de 85 points à 0.60 m plutôt qu'à 3.00 m

L'arithmétique derrière une échelle rectiligne

Le dictionnaire de mesure rectiligne (ISO 32000-1 Table 262, Table 267 dans ISO 32000-2) a quatre entrées requises, et toute son arithmétique pend à /C. /X, /D et /A sont des tableaux de formats de nombre : des dictionnaires de format de nombre (Table 263) ordonnés de la plus grande unité vers le bas, chacun avec un label /U et un facteur de conversion /C. Les dictionnaires suivants d'un tableau convertissent depuis l'unité qui les précède, tandis que le premier dictionnaire part d'un autre endroit selon l'entrée qui possède le tableau :

EntréeRequiseLe premier /C convertit depuisFeuille métrique 1:100
/ROuiRien ; texte de ratio lisible par un humain(1 cm = 1 m)
/XOuiUnités de l'espace utilisateur par défaut/U (m) /C 0.0352778
/YQuand les unités y ou les facteurs diffèrentUnités de l'espace utilisateur par défautOmise
/DOuiL'unité /X de tête/U (m) /C 1
/AOuiL'unité /X de tête, au carré/U (sq m) /C 1
/TNonDegrésOmise
/SNonUnité /Y de tête divisée par l'unité /X de têteOmise

Sur un plan métrique au 1:100, l'échelle tient dans un seul nombre. Un point fait 25.4 / 72 = 0.352778 mm de papier, qui représente 35.2778 mm de bâtiment, si bien que /X reçoit /U (m) et /C 0.0352778, tandis que /D part des mètres et /A des mètres carrés et que tous deux reçoivent /C 1. Copier le facteur /X dans /D et l'élever au carré pour /A applique l'échelle deux fois : le mur de 5 m revient comme 0.18 m, et une pièce de 20 m² rétrécit à 0.025 m². Écrivez ce /C de tête valant 1 dans /D et /A sur chaque viewport, même là où il semble redondant. La Table 262 rend les deux tableaux requis et ne définit aucun repli, si bien qu'un viewer qui rencontre un facteur manquant ou deviné improvise, et deux viewers peuvent improviser de deux façons différentes ; un dictionnaire écrit en entier épingle chaque affichage dans le fichier lui-même, et les unités se trouvent dans du code qu'un relecteur peut confronter au cartouche. Le reste du modèle de format de nombre marche ainsi :

Chaîne de conversion de THPDFRectilinearMeasureInfo de HotPDF pour un viewport de mesure PDF 1:100 en Delphi : facteur /X 0.0352778 des points vers les mètres, facteurs /D et /A de 1, et les affichages rétrécis quand l'échelle est appliquée deux fois
Seul le format de nombre /X porte l'échelle 1:100 ; avec /D et /A épinglés à un facteur de 1, le mur sud se lit 5.00 m et la pièce 20.00 sq m
  • Les unités chaînées suivent la règle de l'unité précédente, c'est ainsi que l'exemple de la norme elle-même imprime "1 mi 2,378 ft 7 5/8 in" à partir de miles à /C 1, pieds à /C 5280 et pouces à /C 12, avec /F /D 8 arrondissant aux huitièmes
  • /R est une légende pour les humains, si bien qu'une chaîne de ratio fausse sur un /C juste produit quand même des nombres justes
  • Les profils à échelle verticale exagérée, le cas "in X 1 cm = 1 m, in Y 1 cm = 30 m" de la Table 262, demandent /Y plus un facteur /CYX qui convertit les unités y en unités x ; sans /CYX un lecteur peut refuser de calculer distance, aire et angle

Écrire un plan au 1:100 avec un encart de détail au 1:20

THPDFPage.AddRectilinearViewportEx prend le rectangle du viewport et un enregistrement THPDFRectilinearMeasureInfo dont les champs se mappent sur /R, /X, /Y, /D, /A, /T, /S, /O et /CYX, chaque tableau de formats de nombre étant porté comme enregistrements THPDFNumberFormatEntry. La table ci-dessus part telle quelle, et rien ne reste à inférer pour le lecteur. Les règles d'appel qui s'appliquent avant que le code ci-dessous ne tourne :

  • Un viewport rectiligne demande PDF 1.6. HotPDF monte THotPDF.Version à pdf16 à la demande ; avec StrictVersionLock actif, la méthode n'écrit rien et renvoie -1
  • Un /R vide, ou un tableau /X, /D ou /A sans entrées, lève une exception, puisque la Table 262 exige les quatre
  • Chaque appel accole à /VP, si bien que le plan part avant l'encart et que l'encart finit là où un lecteur le teste avant tout le reste
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 = centièmes
end;

// Dessin métrique au 1:Scale avec chaque affichage en mètres
function MetricScale(const Ratio: AnsiString;
  Scale: Double): THPDFRectilinearMeasureInfo;
const
  MetersPerPoint = 0.0254 / 72;                  // longueur papier d'une 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);    // depuis les unités X
  SetLength(Result.AreaFormats, 1);
  Result.AreaFormats[0] := NumberFormat('sq m', 1, 100);     // depuis les unités X au carré
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;
    // ... dessiner le plan du rez-de-chaussée et l'encart du détail escalier ...

    // Les rectangles sont en espace utilisateur PDF par défaut : points, origine bas-gauche
    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;

Les quatre arguments du rectangle partent dans /BBox tels quels, en espace utilisateur par défaut PDF : des points, mesurés depuis le coin inférieur gauche. C'est l'espace depuis lequel le facteur /X convertit, donc rectangle et échelle partagent une unité. Les appels de dessin de HotPDF en utilisent une autre, avec l'origine en haut à gauche et des coordonnées en 1/Resolution de pouce, et l'écart entre les unités de dessin HotPDF et les points PDF s'applique ici de plein fouet — un encart dessiné en coordonnées haut-gauche demande ses valeurs y retournées contre la hauteur de page et converties en points, sinon le rectangle de mesure atterrit sur l'autre moitié de la feuille. Les pages grands formats ajoutent un facteur de plus. Avec /UserUnit fixé via THPDFPage.SetUserUnit, une unité par défaut vaut UserUnit / 72 de pouce, si bien que le facteur /X se met à l'échelle par la même valeur

Quel ordre les coordonnées GPTS utilisent-elles ?

Un viewport de carte porte un dictionnaire de mesure avec /Subtype /GEO, né comme extension Adobe de PDF 1.7 et devenu partie de la norme en PDF 2.0 (ISO 32000-2 §12.10.2, Table 269). /GCS est requis et porte un dictionnaire /Type /GEOGCS (Table 270) ou un dictionnaire /Type /PROJCS (Table 271), identifié par un code /EPSG ou une chaîne /WKT ; pour un système géographique, la norme permet l'un des deux et interdit les deux ensemble. Utilisez EPSG dès que le système de coordonnées a un code. Un entier ne peut pas être cassé par des échappements de chaîne, et les lecteurs le résolvent contre le même registre qu'utilisent les outils SIG, tandis que WKT reste pour les projections personnalisées qui n'ont pas de code. /DCS nomme optionnellement un autre système pour afficher les positions. /PDU liste les unités d'affichage préférées par nom — linéaires, aires et angulaires, comme KM, SQKM et DEG — et HotPDF ne l'écrit que quand les trois unités de THPDFGeoMeasureInfo sont remplies

Les paires /GPTS sont latitude puis longitude quand /GCS est géographique, et coordonnées est puis nord quand il est projeté. L'ordre bascule. Des paires x/y SIG fournies telles quelles dans /GPTS sortent justes pour une carte UTM et fausses pour WGS 84. EPSG:4326 lui-même met la latitude devant la longitude, GeoJSON et la plupart des APIs de cartes web font l'inverse, et une carte des rues de Zurich enregistrée en ordre web-map montre le résultat : 8.54° est se lit comme une latitude, 47.37° nord comme une longitude, et le curseur rapporte des positions près de la frontière Éthiopie–Somalie. /LPTS porte les points correspondants dans le carré unitaire du viewport, où (0, 0) est le coin inférieur gauche de /BBox et (1, 1) le supérieur droit, une paire par paire /GPTS ; des coordonnées de page collées là mettent les points de contrôle à des centaines de carrés de la carte. /Bounds, le trait de cadre (neatline) qui limite où la géoréférence est valide, utilise le même carré unitaire et prend par défaut [0 0 0 1 1 1 1 0]. HotPDF vérifie la forme avant d'écrire quoi que ce soit : /GPTS demande un compte pair d'au moins quatre nombres, et un /LPTS non vide doit égaler sa longueur

Points de contrôle GeoPDF de THPDFGeoMeasureInfo de HotPDF en Delphi : ordre latitude-longitude de /GPTS pour un GEOGCS comme EPSG 4326, esting-northing pour un PROJCS, et coins /LPTS dans le carré unitaire du viewport utilisé par AddGeospatialViewport
Les systèmes de coordonnées géographiques et projetés ordonnent les paires /GPTS différemment, tandis que /LPTS compte toujours depuis le coin inférieur gauche du /BBox du viewport dans un carré unitaire

Attacher un nuage de points à un viewport de carte

Un viewport GEO peut aussi porter un dictionnaire de données de points (ISO 32000-2 §12.10.5, Table 272) : /Type /PtData, /Subtype /Cloud, un tableau /Names qui sert d'en-têtes de colonnes et un tableau /XPTS de lignes. LAT et LON en degrés et ALT en mètres sont des en-têtes prédéfinis, et la norme traite les lignes comme une collection non ordonnée. Structuralement, /XPTS s'imbrique tandis que /GPTS reste à plat : un tableau interne par point, dimensionné sur /Names, donc [[47.3769 8.5417 408] [47.3780 8.5400 410]]. THPDFPage.SetViewportPointDataCloud accepte quand même une seule liste à plat, la forme que produit un export CSV, et la découpe en lignes selon le compte de colonnes ; une valeur qui parse comme un nombre avec séparateur décimal à point devient un nombre PDF, tout le reste reste une chaîne, et un compte qui n'est pas multiple du compte de colonnes lève une exception. Les deux appels demandent PDF 2.0, et l'indice de viewport que renvoie AddGeospatialViewport est l'endroit où le nuage s'attache

procedure AddCityMap(Page: THPDFPage);
const
  // Coins du trait de cadre, latitude avant longitude : 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);
  // Les mêmes coins dans le carré unitaire du 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 reste vide
  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 garde grossièrement le rapport 12.8 km x 7.8 km de la carte
  Map := Page.AddGeospatialViewport('City center', 36, 252, 576, 576, Geo);
  if Map < 0 then
    raise Exception.Create('GEO viewports need PDF 2.0');

  // Trois colonnes, donc chaque trois valeurs deviennent une ligne /XPTS
  Page.SetViewportPointDataCloud(Map, ['LAT', 'LON', 'ALT'],
    ['47.3769', '8.5417', '408',
     '47.3780', '8.5400', '410']);
end;

Relire les viewports et mesurer côté serveur

THotPDF.GetLoadedPageViewports renvoie un THPDFViewportInfoArray pour un indice de page à partir de zéro, un THPDFViewportInfo par entrée /VP dans l'ordre du fichier, avec le BBox, le Name, le MeasureSubtype, l'enregistrement Rectilinear ou Geospatial parsé et les lignes PtData éventuelles. Un dictionnaire de mesure qui omet /Subtype revient en RL, le défaut que la Table 261 de l'ISO 32000-1 assigne. Les deux fonctions ci-dessous reproduisent ce qu'un viewer fait avec un clic : trouver le viewport par le parcours à rebours, puis transformer une distance de page en unités réelles via /X (ou /Y avec /CYX) et le facteur /D de tête. Le test des bornes utilise Min et Max parce qu'un cadre de mesure tourné contre la page peut lister ses coins dans un autre ordre

uses
  SysUtils, Math, HPDFDoc;

// Parcourt /VP à rebours depuis la dernière entrée ; le BBox contenant atteint le plus tôt gagne
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;

// Distance entre deux points de page, dans l'unité /D de tête
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);            // le point de départ décide
      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;

Sortie attendue pour la feuille écrite plus tôt : Stair detail 1:20 (1 cm = 20 cm): 0.60 m, puisque 85 points à 0.0070556 m par point font 0.5997 m. Échangez les deux appels AddRectilinearViewportEx et la même ligne de 85 points se résout vers le viewport du plan et se lit 3.00 m. Pour un jeu de dessins numérisés que vous n'avez pas produit, THotPDF.AddPageRectilinearViewportEx, AddPageGeospatialViewport et SetPageViewportPointDataCloud font le même travail sur un document chargé par indice de page avant SaveLoadedDocument. Les annotations ligne, polyligne et polygone peuvent porter leur propre /Measure (ISO 32000-1 Tables 175 et 178), et GetLoadedAnnotationMeasure le parse dans les mêmes enregistrements RL et GEO, ce qui compte quand les annotations de mesure d'un PDF relu viennent d'un autre outil

Avec /D et /A épinglés à 1, le facteur /X de 0.0352778 est le seul nombre qui porte l'échelle 1:100, et le mur sud de 5 m doit se lire 5.00 m dans tout lecteur qui suit le §12.9. Le même type d'enregistrement revient de GetLoadedPageViewports, si bien qu'un fichier écrit par le composant PDF Delphi HotPDF peut être rechargé et ses facteurs comparés aux valeurs que votre code y a mises