匯出成 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 量測,跨不跨邊界都一樣
直線比例背後的算術
直線量測字典(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 |
| /Y | y 單位或因子不同時 | 預設使用者空間單位 | 省略 |
| /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 把兩個陣列定成必填、又沒定義任何回退,遇到缺失或猜出來的因子,檢視器就是在即興發揮,而兩個檢視器可以即興出兩種結果;把字典完整寫出來,每個讀數就釘死在檔案本身,單位也落在審圖人能對照圖框核對的程式碼裡。數字格式模型其餘的玩法如下:
- 串接的單位遵循前一單位規則,標準自己的範例就是這樣印出 "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 長度必須與之相符
把點雲掛到地圖視區上
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 寫出的檔案可以再載入,把因子與您程式碼放進去的值逐一比對