技术文章

Delphi 中的 GeoPDF:用 PDFlibPas 读取地理空间坐标

大多数开发者会把 PDF 页面想成一张带着文字和图片的纸。带地理参照的 PDF 不止如此。它携带了足够的信息,让你能够取出页面上的一个点,按普通页面单位测量,然后报告它在现实世界里对应的经纬度。正是这一点,让 PDF 可以成为地形图、地籍测量图、洪水区展示图,或者任何既要打印又必须保留空间含义的 GIS 导出物的可用载体。几何信息就在文件里,问题只在于你的加载器能不能把它读出来

之所以这件事常被忽略,是因为 GeoPDF 打开和打印起来与普通 PDF 毫无差别。页面渲染结果不会主动告诉你,这张地图其实已经注册到了某个坐标系里。真正的注册信息挂在页面对象下面的若干字典里,本身从不被绘制,所以忽略这些字典的查看器仍然能把地图正常显示出来。可一旦你想对文件做任何空间操作,比如读取测量坐标、重投影、与其他图层叠加,就必须自己去走这些字典

现实世界里并存着两套标准

想处理真实文件的读取器,必须同时应对两种地理注册方案,因为它们都还在流通,而且某个文件可能使用任意一种。较早的一种是 OGC 08-139r2 描述的 OGC 编码,它会把一个 LGIDict,也就是地理空间注册字典,附着到页面上。它诞生于 ISO 正式标准化之前,是早期 GeoPDF 输出事实上的通用格式,所以大量遗留地图只带这一套,没有别的

现代方案则是 ISO 32000-1 第 8.8.2 节标准化的那一套。它不再只依赖一个页面级字典,而是把地理空间数据建模成一个带附属 Measure 字典的页面 Viewport,再由这个 measure 字典命名所使用的地理坐标系。Acrobat 和当前 GIS 导出器写出的就是这种编码。稳健的导入器必须同时检查两边:先读取 ISO 模型里的视口,如果没覆盖,再回退到,或额外检查,只有遗留注册信息的 LGIDict

视口及其边界

在 ISO 模型里,地理注册的基本单位是视口,而且一个页面可能有多个视口。大图纸可以把主地图放在一个矩形里,把不同缩放级别的插图放在另一个矩形里,再加一个完全不带地理参考的图例面板。每个视口都携带一个 BBox,也就是它在页面上负责的矩形区域,因此读取器才能知道某个坐标系适用于图纸上的哪个部分。查看器把用户点击的点与这些方框做命中测试,就是为了决定该使用哪个 measure 字典

var
  Pdf: TPDFlib;
  vpCount, i, vpID: Integer;
  Left, Top, Width, Height: Double;
begin
  Pdf := TPDFlib.Create;
  try
    if Pdf.LoadFromFile('topo_sheet.pdf', '') <> 1 then
      raise Exception.Create('load failed');
    Pdf.SelectPage(1);

    vpCount := Pdf.GetPageViewPortCount;
    for i := 1 to vpCount do
    begin
      vpID := Pdf.GetPageViewPortID(i);
      Left   := Pdf.GetViewPortBBox(vpID, 0);
      Top    := Pdf.GetViewPortBBox(vpID, 1);
      Width  := Pdf.GetViewPortBBox(vpID, 2);
      Height := Pdf.GetViewPortBBox(vpID, 3);
      // Left/Top/Width/Height describe the map area for this viewport
    end;
  finally
    Pdf.Free;
  end;
end;

如果 GetPageViewPortID 返回的 ViewPortID 是零,就表示这个索引位置的视口找不到,因此在把这个句柄继续传下去之前,应先检查它

measure 字典内部有什么

把页面注册到现实世界的几何关系,存放在挂到视口上的 measure 字典里。GetViewPortMeasureDict 会为给定的 ViewPortID 返回一个 MeasureDictID;如果这个视口没有 measure 字典,则返回零,而这对图例或标题面板来说是很正常的。这个字典里真正值得读的东西有三类:它引用的坐标系、把页面点与地理点绑在一起的数组,以及点数据所用的单位

注册关系本身表现为两组并行数组。GPTS 是地理点数组,里面是地理坐标系中的纬度和经度成对数据;LPTS 是页面空间点数组,按视口 BBox 的分数形式表达,因此缩放后仍然成立。LPTS 的第 n 项与 GPTS 的第 n 项指向同一个真实地点,只不过一个在页面坐标里,一个在地球坐标里。三个或更多这样的点对,足以确定一个仿射变换,或者更一般地说,一个投影变换,把视口内任意页面坐标映射成真实世界坐标。读取它们,本质上就是把两组数组并排走一遍

var
  measID, gptsCount, lptsCount, j: Integer;
  lat, lon, px, py: Double;
begin
  measID := Pdf.GetViewPortMeasureDict(vpID);
  if measID <> 0 then
  begin
    gptsCount := Pdf.GetMeasureDictGPTSCount(measID);
    lptsCount := Pdf.GetMeasureDictLPTSCount(measID);
    // GPTS holds lat/lon pairs; LPTS holds the matching page fractions.
    // Both arrays are read with one-based item indices.
    j := 1;
    while j < gptsCount do
    begin
      lat := Pdf.GetMeasureDictGPTSItem(measID, j);
      lon := Pdf.GetMeasureDictGPTSItem(measID, j + 1);
      px  := Pdf.GetMeasureDictLPTSItem(measID, j);
      py  := Pdf.GetMeasureDictLPTSItem(measID, j + 1);
      // (px, py) on the page corresponds to (lat, lon) on the ground
      Inc(j, 2);
    end;
  end;
end;

measure 字典还会通过 GetMeasureDictPDU 报告显示单位。这个调用接受 UnitIndex,其中 1 表示线性单位,2 表示面积单位,3 表示角度单位,并返回具体单位的代码,比如线性类别中的米或国际英尺。通过 GetMeasureDictBoundsItem 读取的 Bounds 数组,则描述视口里测量实际覆盖的四边形,而不一定总是整个矩形

WKT 与 EPSG 的区别

如果不知道 GPTS 里的经纬度到底属于哪个地理坐标系,那么这些数字本身是没有意义的,因为 51.5, -0.1 在 WGS 84 下落到的实际位置,与在旧的国家基准面下可能并不一样。measure 字典会通过一个坐标系字典来回答这个问题,地理坐标系可以用 GetMeasureDictGCSDict 取得。PDF 对这个坐标系提供两种可互换的描述方式,读取器必须两种都能接受

第一种是 WKT,也就是 Well-Known Text。它是一个自包含的长字符串,把基准面、椭球体、本初子午线和单位完整写出来。它比较冗长,但含义明确,而且不需要外部查找表。第二种是 EPSG 代码,也就是一个在 EPSG 注册库里索引坐标系的整数;4326 就是 WGS 84,也是大多数消费级 GPS 数据使用的框架。EPSG 很紧凑,但假设读取器能够在数据库里解析这个编号。真实文件里可能只带其中一种,也可能两种都带,所以 API 才同时暴露了 GetCSDictTypeGetCSDictEPSGGetCSDictWKTGetCSDictType 先告诉你这是地理坐标系,即 GEOGCS,返回值 1,还是投影坐标系,即 PROJCS,返回值 2,让你在信任剩余信息之前先解释对方向

var
  gcsID, csType, epsg: Integer;
  wkt: WideString;
begin
  gcsID := Pdf.GetMeasureDictGCSDict(measID);
  if gcsID <> 0 then
  begin
    csType := Pdf.GetCSDictType(gcsID);   // 1 = GEOGCS, 2 = PROJCS
    epsg   := Pdf.GetCSDictEPSG(gcsID);   // e.g. 4326 for WGS 84, 0 if absent
    wkt    := Pdf.GetCSDictWKT(gcsID);    // full text description, '' if absent
    // Prefer EPSG when present; fall back to parsing WKT otherwise.
  end;
end;

读取遗留的 LGIDict

早于视口模型的文件,或者仍然输出较旧编码的工具生成的文件,会把注册信息放在页面上的 LGIDict 里,而不是 measure 字典里。PDFlibPas 通过 GetPageLGIDictCount 报告页面拥有多少个这类字典,再通过 GetPageLGIDictContent,从 1 开始索引,返回每一个字典的原始文本。返回值就是该字典按原样写入文件时的文本内容,里面携带 OGC 08-139r2 所定义的注册字段;你的代码随后再解析这些字段,恢复出与 measure 字典提供的那种页面到世界的映射关系。写入侧则由 AddLGIDictToPage 把 LGIDict 附加到当前页面,因此如果某个老消费者仍然期待遗留形式,转换器也能把它原样往返处理

var
  lgiCount, k: Integer;
  dictText: WideString;
begin
  lgiCount := Pdf.GetPageLGIDictCount;
  for k := 1 to lgiCount do
  begin
    dictText := Pdf.GetPageLGIDictContent(k);
    // dictText carries the OGC 08-139r2 registration to parse
  end;
end;

把整个读取过程拼起来

一个完整的导入器,会把这两套方案当作每一页上的两轮遍历。先选中页面,用 GetPageViewPortCount 读取 ISO 视口;对于每一个带 measure 字典的视口,抓出它的 BBox、GPTS 与 LPTS 数组、点数据单位,以及通过坐标系字典取得的 GCS 描述;然后再检查 GetPageLGIDictCount,看是否还有视口遍历没有覆盖到的遗留注册信息。一个同时携带两者的地图,理论上应在两边给出一致结果;而如果它只携带其中一种,你也仍然能解出来,因为你两个地方都查了。一路上返回的这些句柄,ViewPortID、MeasureDictID、CSDictID,本质上都只是文档加载期间保持有效的普通整数,所以整个过程不过是围绕页面列表的几层嵌套循环,不需要额外管理什么分配

一旦你能恢复出注册关系,这一页就不再只是图片,而会变成一份数据源。如何读取页面其余内容,可以参考关于文本、图像和字体提取的文章;如果你还想把带地理参照的图纸渲染到设备上做屏幕测量,则可以继续看打印与预览 device context 指南。这里介绍的地理空间读取能力,作为 losLab PDF Library 面向 Delphi 与 C++Builder 的一部分提供,同时也和本博客其他文章涉及的加载、提取和渲染 API 配套出现