导出成 PDF 的 1:100 底层平面图在 Acrobat 里打开,测距工具把 5 m 的南墙报成 1.97 in,也就是它在纸面上的长度。缺的是一个 /VP 视口,它的直线式(/RL)测量字典负责把点换算成米——HotPDF 从 Delphi 就能写出这套东西,地图用的地理空间(/GEO)控制点也不在话下
视口给测量工具带来了什么
视口是页面上的一个矩形,自带一套测量坐标系。页面字典的可选条目 /VP(ISO 32000-1 表 30,PDF 1.6)装着一组 /Type /Viewport 字典,每个只需要一个 /BBox:/Name 是它在阅读器界面里的标签,/Measure 提供比例(ISO 32000-1 §12.9,表 260;同一字典在 ISO 32000-2 里是表 265)。没有任何视口的页面会让 Acrobat 的测量工具停在 1:1 纸面比例,所以那 1.97 in 不过是把 50 mm 的墨迹忠实地量了一遍。/BBox 写在默认用户空间里,左下角在前、右上角在后,这对角的顺序决定测量 x、y 轴的方向,规范声明它与页面的 /Rotate 无关。哪天你要把 /Rotate 摊平进页面的各个 box,每个 /VP 矩形也是必须跟着内容走的一份几何
视口可以重叠,§12.9 按数组顺序裁决重叠:阅读器找某个点底下的视口时,从最后一个 /VP 条目开始倒着走,停在第一个 /BBox 罩住该点的条目上。所以 1:100 图纸上的 1:20 楼梯大样图必须排在整幅视口之后——先追加它的话,大矩形会赢得每一次命中测试,大样图量出来会长五倍。距离跟着起点走。一条从大样图内部出发、落在主平面图上的线,全程按 1:20 计量,管它跨不跨边界
直线式比例背后的算术
直线式测量字典(ISO 32000-1 表 262,ISO 32000-2 里是表 267)有四个必备条目,它的全部算术都挂在 /C 上。/X、/D 和 /A 是数字格式数组:由大到小排列的数字格式字典(表 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 图纸上,比例就活在一个数字里。1 点等于 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²。每个视口都把这个值为 1 的队首 /C 写进 /D 和 /A,哪怕它看着多余。表 262 把这两个数组定为必备,也没定义任何回退,遇到因子缺失或靠猜的阅读器就是在即兴发挥,两个阅读器可以即兴出两种结果;一份写全的字典把每个读数都钉死在文件里,单位又落在代码上,审图的人可以拿它对着图签核对。数字格式模型的其余部分是这样工作的:
- 链式单位遵守前单位规则,规范自带的例子就是这么把“1 mi 2,378 ft 7 5/8 in”打印出来的:英里 /C 1、英尺 /C 5280、英寸 /C 12,/F /F /D 8 圆整到八分之一
- /R 是给人看的说明文字,所以 /C 正确时,哪怕比例字符串写错,读数照样正确
- 纵向比例夸张的剖面图——表 262 里那个“x 方向 1 cm = 1 m,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 数组,都会抛异常,因为表 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 = hundredths
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 默认用户空间:单位为点,原点在左下
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 默认用户空间:单位为点,从左下角起量。/X 因子换算的正是这个空间,所以矩形和比例共用一个单位。HotPDF 的绘图调用用的是另一套:原点在左上角,坐标以 1/Resolution 英寸计,HotPDF 绘图单位与 PDF 点的差异在这里全额生效——用左上角坐标画的大样图,y 值要按页高翻转、再换算成点,否则测量矩形会落到图纸的另一半。大幅面页面还要再乘一个因子。通过 THPDFPage.SetUserUnit 设置 /UserUnit 后,一个默认单位等于 UserUnit / 72 英寸,/X 因子也得按同值缩放
GPTS 坐标用的是哪种顺序?
地图视口带一个 /Subtype /GEO 的测量字典,它起初是 Adobe 对 PDF 1.7 的扩展,到 PDF 2.0 才进入标准(ISO 32000-2 §12.10.2,表 269)。/GCS 是必备的,装着 /Type /GEOGCS 字典(表 270)或 /Type /PROJCS 字典(表 271),靠 /EPSG 代码或 /WKT 字符串标识;地理坐标系时,规范只允许二者取一,两个都写则不行。坐标系有代码就用 EPSG:整数不会被字符串转义搞坏,阅读器拿它去查的注册表与 GIS 工具用的是同一份;WKT 留给没有代码的自定义投影。/DCS 可选,用于指定显示位置用的另一套坐标系。/PDU 按名字列出首选显示单位——长度、面积、角度,比如 KM、SQKM、DEG——HotPDF 只在 THPDFGeoMeasureInfo 的三个单位都填了的情况下才写它
/GPTS 数对在 /GCS 为地理坐标系时是纬度在前、经度在后,投影坐标系时是东坐标在前、北坐标在后。顺序是翻转的。把 GIS 的 x/y 对直接灌进 /GPTS,UTM 图对了,WGS 84 就错了。EPSG:4326 自己就是纬度在经度前,GeoJSON 和多数 web 地图 API 反着来;一张按 web 地图顺序注册的苏黎世街道图会现出原形:东经 8.54° 被当成纬度,北纬 47.37° 被当成经度,光标报出的位置跑到了埃塞俄比亚和索马里边界附近。/LPTS 在视口的单位方形里存放与之一一对应的点:(0, 0) 是 /BBox 的左下角,(1, 1) 是右上角,每个 /GPTS 数对对应一对;把页面坐标粘进这里,控制点会偏离地图几百个方格。/Bounds 是限定地理参考有效范围的图廓线,用同一个单位方形,缺省为 [0 0 0 1 1 1 1 0]。HotPDF 在写出之前先检查形状:/GPTS 需要至少四个数的偶数个,非空的 /LPTS 必须与它等长
把点云挂到地图视口上
GEO 视口还可以带一个点数据字典(ISO 32000-2 §12.10.5,表 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 number,其余保持字符串,个数不是列数整数倍时抛异常。两个调用都要 PDF 2.0,AddGeospatialViewport 返回的视口索引就是点云的挂载点
procedure AddCityMap(Page: THPDFPage);
const
// 图廓线角点,纬度在经度前: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 按从 0 起算的页码返回 THPDFViewportInfoArray,每个 /VP 条目一个 THPDFViewportInfo,按文件顺序排列,含 BBox、Name、MeasureSubtype、解析好的 Rectilinear 或 Geospatial 记录,以及所有 PtData 行。省略 /Subtype 的测量字典按 RL 报回,那是 ISO 32000-1 表 261 指定的缺省。下面两个函数复刻阅读器处理一次点击的全过程:倒序遍历找到视口,再经由 /X(或配 /CYX 的 /Y)和队首 /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 点乘每点 0.0070556 m 得 0.5997 m。把两个 AddRectilinearViewportEx 调用对调,同一条 85 点的线就落到平面图视口头上,读数 3.00 m。面对一套不是你出的扫描图纸,THotPDF.AddPageRectilinearViewportEx、AddPageGeospatialViewport 和 SetPageViewportPointDataCloud 按页码在已加载文档上做同样的活,然后 SaveLoadedDocument。线、折线、多边形注释可以自带 /Measure(ISO 32000-1 表 175 和表 178),GetLoadedAnnotationMeasure 把它解析成同样的 RL 与 GEO 记录;等那份送审 PDF 里的测量注释出自别的工具时,这一点就很要紧
/D 和 /A 钉在 1 上,0.0352778 这个 /X 因子就是承载 1:100 比例的唯一数字,任何遵循 §12.9 的阅读器都该把 5 m 的南墙读成 5.00 m。GetLoadedPageViewports 返回的也是同一套记录类型,所以 HotPDF Delphi PDF 组件写出的文件可以重新载入,把因子和你的代码当初写入的值逐一比对