技術文章

PDF 量測視區:讓圖面有比例、地圖有座標

匯出成 PDF 的 1:100 一樓平面圖在 Acrobat 裡打開,距離工具把 5 m 的南牆報成 1.97 in——那是它在紙上的長度。缺的那一塊是帶直線量測(/RL)量測字典、能把 point 換算成公尺的 /VP 視區——HotPDF 從 Delphi 就能寫出來,連同地圖用的地理空間(/GEO)控制點

視區給量測工具的東西是什麼

視區是頁面上的一個矩形,自帶一套量測座標系統。頁面字典的選填條目 /VP(ISO 32000-1 Table 30,PDF 1.6)放的是 /Type /Viewport 字典的陣列,每個字典只需要一個 /BBox:/Name 在檢視器的介面裡標示它,/Measure 供應比例(ISO 32000-1 §12.9,Table 260;同一字典在 ISO 32000-2 是 Table 265)。頁面上沒有任何視區時,Acrobat 的量測工具停在 1:1 的紙上比例,所以那個 1.97 in 不過是 50 mm 墨水被忠實量出來的結果。/BBox 以預設使用者空間書寫,先左下角再右上角,這個角落順序決定量測 x、y 軸的方向,而標準聲明這與頁面的 /Rotate 無關。哪天您要把 /Rotate 攤平到頁面的各個框,每個 /VP 矩形都是又一塊必須跟著內容走的幾何

視區可以重疊,而 §12.9 按陣列順序解決重疊:讀取器找某個點底下的視區時,從最後一個 /VP 條目開始、往回迭代,停在第一個 /BBox 包含該點的條目。所以 1:100 圖頁上的 1:20 樓梯細部插圖必須排在全頁視區之後——先附加它,大矩形就贏下每一次命中測試,細部會量出五倍的長度。距離跟著起點走:一條起點在插圖裡、終點落在主平面圖上的線,全長都按 1:20 量測,跨不跨邊界都一樣

HotPDF 在 Delphi 中的 /VP 視區陣列:AddRectilinearViewportEx 在 1:100 平面圖之後附加 1:20 樓梯細部,反向命中測試挑中插圖,85 point 的線讀出 0.60 m 而非 3.00 m
把樓梯插圖排在全頁平面圖視區之後,反向的 /VP 走訪就能先找到它,85 point 的樓梯踏步保持在 0.60 m 而不是 3.00 m

直線比例背後的算術

直線量測字典(ISO 32000-1 Table 262,ISO 32000-2 裡是 Table 267)有四個必填條目,它全部的算術都掛在 /C 上。/X、/D 與 /A 是數字格式陣列:按單位從大到小排序的數字格式字典(Table 263),每個帶 /U 標籤與 /C 換算因子。陣列裡靠後的字典從前一個單位換算,排頭的字典則從哪裡起算,取決於擁有這個陣列的是哪個條目:

條目必填排頭 /C 的換算起點1:100 公制圖頁
/R是無;給人讀的比例文字(1 cm = 1 m)
/X是預設使用者空間單位/U (m) /C 0.0352778
/Yy 單位或因子不同時預設使用者空間單位省略
/D是排頭 /X 的單位/U (m) /C 1
/A是排頭 /X 單位的平方/U (sq m) /C 1
/T否度省略
/S否排頭 /Y 單位除以排頭 /X 單位省略

在公制 1:100 平面圖上,比例就活在一個數字裡。一個 point 是 25.4 / 72 = 0.352778 mm 的紙,代表 35.2778 mm 的建築,所以 /X 拿 /U (m) 與 /C 0.0352778;/D 從公尺起算、/A 從平方公尺起算,兩者都拿 /C 1。把 /X 的因子抄進 /D、平方後放進 /A,等於把比例套了兩次:5 m 的牆回報成 0.18 m,20 m² 的房間縮成 0.025 m²。每個視區都把排頭那個 /C 1 寫進 /D 與 /A,就算看起來多餘也照寫。Table 262 把兩個陣列定成必填、又沒定義任何回退,遇到缺失或猜出來的因子,檢視器就是在即興發揮,而兩個檢視器可以即興出兩種結果;把字典完整寫出來,每個讀數就釘死在檔案本身,單位也落在審圖人能對照圖框核對的程式碼裡。數字格式模型其餘的玩法如下:

HotPDF THPDFRectilinearMeasureInfo 在 Delphi 中為 1:100 PDF 量測視區構成的換算鏈:/X 因子 0.0352778 從 point 到公尺、/D 與 /A 因子為 1,以及比例套兩次時縮水的讀數
只有 /X 數字格式承載 1:100 比例;/D 與 /A 釘在因子 1 時,南牆讀出 5.00 m、房間讀出 20.00 sq m
  • 串接的單位遵循前一單位規則,標準自己的範例就是這樣印出 "1 mi 2,378 ft 7 5/8 in" 的:英里 /C 1、英尺 /C 5280、英寸 /C 12,再加 /F /F /D 8 四捨五入到八分之一
  • /R 是寫給人看的說明文字,比例字串寫錯、/C 對,數字照樣是對的
  • 垂直比例被誇大的剖面圖,即 Table 262 那個 "in X 1 cm = 1 m, in Y 1 cm = 30 m" 的情況,需要 /Y 加上一個把 y 單位換算成 x 單位的 /CYX 因子;沒有 /CYX,讀取器可能拒絕計算距離、面積與角度

寫一份帶 1:20 細部插圖的 1:100 平面圖

THPDFPage.AddRectilinearViewportEx 收視區矩形與一個 THPDFRectilinearMeasureInfo 記錄,其欄位一一對應 /R、/X、/Y、/D、/A、/T、/S、/O 與 /CYX,每個數字格式陣列以 THPDFNumberFormatEntry 記錄承載。上面的表原封不動填進去,讀取器沒有任何東西需要自己推斷。下面程式碼執行前適用的呼叫規則:

  • 直線視區需要 PDF 1.6。HotPDF 會按需把 THotPDF.Version 升到 pdf16;設了 StrictVersionLock,方法什麼都不寫並回傳 -1
  • 空的 /R,或沒有任何條目的 /X、/D、/A 陣列,都會拋例外,因為 Table 262 要求全部四項
  • 每次呼叫都是附加到 /VP,所以平面圖先進去、插圖後進去,插圖最後落在讀取器會最先測試到的位置
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 = 百分位
end;

// 1:Scale 的公製圖面,所有讀數以公尺呈現
function MetricScale(const Ratio: AnsiString;
  Scale: Double): THPDFRectilinearMeasureInfo;
const
  MetersPerPoint = 0.0254 / 72;                  // 一個單位的紙上長度
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);    // 從 X 單位起算
  SetLength(Result.AreaFormats, 1);
  Result.AreaFormats[0] := NumberFormat('sq m', 1, 100);     // 從 X 單位的平方起算
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;
    // ... 畫出一樓平面圖與樓梯細部插圖 ...

    // 矩形用 PDF 預設使用者空間:point,原點在左下
    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;

四個矩形參數原樣進 /BBox,用的是 PDF 預設使用者空間:以 point 計、從左下角量起。這正是 /X 因子換算起點的空間,矩形與比例共用一個單位。HotPDF 的繪圖呼叫用的是另一套:原點在左上、座標以 1/Resolution 英吋計,HotPDF 繪圖單位與 PDF point 之間的落差在這裡全額生效——用左上座標畫的插圖,y 值要對頁高翻轉再換成 point,否則量測矩形會落在圖頁的另一半。大尺寸頁面再加一個因子:透過 THPDFPage.SetUserUnit 設了 /UserUnit 之後,一個預設單位是 UserUnit / 72 英吋,/X 因子按同一個值縮放

GPTS 座標用哪一種順序?

地圖視區帶著一個 /Subtype /GEO 的量測字典,它起家是 PDF 1.7 的 Adobe 延伸,到 PDF 2.0 併入標準(ISO 32000-2 §12.10.2,Table 269)。/GCS 必填,內含 /Type /GEOGCS 字典(Table 270)或 /Type /PROJCS 字典(Table 271),以 /EPSG 代碼或 /WKT 字串標識;地理座標系時,標準允許兩者擇一、禁止並存。座標系有代碼就用 EPSG:整數不會被字串轉義弄壞,讀取器對照的又是 GIS 工具用的同一套註冊表;WKT 留給沒有代碼的自訂投影。/DCS 選填,指名另一個顯示位置用的座標系。/PDU 按名稱列出偏好顯示單位——線性、面積、角度,例如 KM、SQKM 與 DEG——HotPDF 只在 THPDFGeoMeasureInfo 三個單位都填好時才寫它

/GPTS 配對在 /GCS 是地理座標系時是緯度在前、經度在後;投影座標系則是 easting 在前、northing 在後。順序會翻轉。把 GIS 的 x/y 配對直接塞進 /GPTS,UTM 地圖正確、WGS 84 就錯。EPSG:4326 本身把緯度排在經度前面,GeoJSON 與多數網路地圖 API 則相反;把蘇黎世街道圖按網路地圖順序註冊,結果看得見:東經 8.54° 被當成緯度讀、北緯 47.37° 被當成經度讀,游標回報的位置落在衣索比亞—索馬利亞邊界附近。/LPTS 放的是視區單位方塊裡的對應點:(0, 0) 是 /BBox 左下角、(1, 1) 是右上角,一個 /GPTS 配對配一組;把頁面座標貼進去,控制點會偏離地圖幾百個方塊。/Bounds 是限制地理參照有效範圍的 neatline,用同一個單位方塊,預設 [0 0 0 1 1 1 1 0]。HotPDF 在寫入之前先驗形狀:/GPTS 需要至少四個數字、且總數為偶數,非空的 /LPTS 長度必須與之相符

HotPDF THPDFGeoMeasureInfo 在 Delphi 中寫出的 GeoPDF 控制點:GEOGCS(如 EPSG 4326)的 /GPTS 為緯度—經度順序、PROJCS 為 easting—northing 順序,以及 AddGeospatialViewport 所用視區單位方塊裡的 /LPTS 角點
地理與投影座標系安排 /GPTS 配對順序的方式不同,/LPTS 則永遠從視區 /BBox 的左下角在單位方塊裡起算

把點雲掛到地圖視區上

GEO 視區還可以帶一個點資料字典(ISO 32000-2 §12.10.5,Table 272):/Type /PtData、/Subtype /Cloud、充當欄位標頭的 /Names 陣列,以及裝各列的 /XPTS 陣列。以度為單位的 LAT 與 LON、以公尺為單位的 ALT 是預定義標頭,標準把各列視為無序集合。結構上,/XPTS 是巢狀的而 /GPTS 保持扁平:每個點一個內層陣列,長度對齊 /Names,如 [[47.3769 8.5417 408] [47.3780 8.5400 410]]。THPDFPage.SetViewportPointDataCloud 也接受單一扁平清單——CSV 匯出產生的那種形狀——並按欄數切成各列;能以句點小數分隔符解析成數字的值變成 PDF 數字,其餘保持字串,個數不是欄數的倍數就拋例外。兩個呼叫都需要 PDF 2.0,點雲掛在 AddGeospatialViewport 回傳的視區索引上

procedure AddCityMap(Page: THPDFPage);
const
  // Neatline 角點,緯度在前經度在後: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);
  // 同一批角點在視區單位方塊裡的樣子
  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 保持空白
  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 大致保住地圖 12.8 km x 7.8 km 的長寬比
  Map := Page.AddGeospatialViewport('City center', 36, 252, 576, 576, Geo);
  if Map < 0 then
    raise Exception.Create('GEO viewports need PDF 2.0');

  // 三個欄位,所以每三個值成為一列 /XPTS
  Page.SetViewportPointDataCloud(Map, ['LAT', 'LON', 'ALT'],
    ['47.3769', '8.5417', '408',
     '47.3780', '8.5400', '410']);
end;

讀回視區、在伺服器上量測

THotPDF.GetLoadedPageViewports 對零起算的頁面索引回傳 THPDFViewportInfoArray,按檔案順序每個 /VP 條目一個 THPDFViewportInfo,含 BBox、Name、MeasureSubtype、解析好的 Rectilinear 或 Geospatial 記錄,以及任何 PtData 列。省略 /Subtype 的量測字典回報為 RL,那是 ISO 32000-1 Table 261 指定的預設值。下面兩個函式重現檢視器對一次點擊做的事:用反向走訪找到視區,再透過 /X(或 /Y 加 /CYX)與排頭 /D 因子,把頁面距離換成實際單位。邊界測試用 Min 與 Max,因為相對頁面旋轉過的量測框,各角點的列出順序可能不同

uses
  SysUtils, Math, HPDFDoc;

// 從最後一個條目起反向走訪 /VP;最先抵達的包含 BBox 勝出
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;

// 兩個頁面點之間的距離,以排頭 /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);            // 起點說了算
      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;

前面那張圖頁的預期輸出是 Stair detail 1:20 (1 cm = 20 cm): 0.60 m:85 個 point、每 point 0.0070556 m,合計 0.5997 m。把兩個 AddRectilinearViewportEx 呼叫對調,同一條 85 point 的線會解析到平面圖視區,讀出 3.00 m。處理不是您產出的掃描圖集時,THotPDF.AddPageRectilinearViewportEx、AddPageGeospatialViewport 與 SetPageViewportPointDataCloud 在載入的文件上按頁面索引做同樣的事,最後 SaveLoadedDocument 存檔。線、折線與多邊形註解可以自帶 /Measure(ISO 32000-1 Table 175 與 Table 178),GetLoadedAnnotationMeasure 把它解析成同樣的 RL 與 GEO 記錄——當受審 PDF 裡的量測註解出自別的工具時,這就派上用場

/D 與 /A 釘在 1 之後,承載 1:100 比例的數字只剩 /X 因子 0.0352778,遵循 §12.9 的任何讀取器都該把 5 m 的南牆讀成 5.00 m。同一種記錄型別也會從 GetLoadedPageViewports 回來,所以HotPDF Delphi PDF component 寫出的檔案可以再載入,把因子與您程式碼放進去的值逐一比對