Техническая статья

Measure-viewport PDF для чертежей с масштабом и карт GeoPDF

План первого этажа 1:100, экспортированный в PDF, открывается в Acrobat, и инструмент измерений выдаёт 5-метровую южную стену как 1.97 in — её длину на бумаге. Не хватает viewport /VP, чей rectilinear-словарь мер (/RL) переводит пункты в метры. Такое пишется из Delphi в HotPDF — вместе с геопространственными (/GEO) контрольными точками для карт

Что viewport даёт измерительному инструменту

Viewport — это прямоугольник на странице, приносящий с собой собственную измерительную систему координат. Необязательная запись /VP словаря страницы (ISO 32000-1, таблица 30, PDF 1.6) держит массив словарей /Type /Viewport, и каждому нужен лишь /BBox: /Name подписывает его в интерфейсе viewer, а /Measure задаёт масштаб (ISO 32000-1 §12.9, таблица 260; тот же словарь — таблица 265 в ISO 32000-2). Страница без viewport оставляет измерительные инструменты Acrobat на бумажном масштабе 1:1, так что 1.97 in — это просто 50 мм чернил, измеренные честно. /BBox пишется в default user space — сначала левый нижний угол, затем правый верхний, и этот порядок углов задаёт направление измерительных осей x и y, которые стандарт объявляет независимыми от /Rotate страницы. Если вам доведётся выпрямлять /Rotate по всем боксам страницы, каждый прямоугольник /VP — ещё один кусок геометрии, обязанный поехать вместе с содержимым

Viewport могут перекрываться, и §12.9 разрешает перекрытие порядком в массиве: reader, ищущий viewport под точкой, стартует с последней записи /VP, идёт назад и останавливается на той, чей /BBox содержит точку. Врезка лестницы 1:20 на листе 1:100, стало быть, должна идти после общелистового viewport, — добавите её раньше, и большой прямоугольник выигрывает каждый hit test, так что врезка мерится в пять раз длиннее. Расстояния следуют за точкой старта. Линия, начавшаяся внутри врезки и кончившаяся на общем плане, мерится на всём протяжении как 1:20, граница или нет

Массив viewport /VP в HotPDF на Delphi: AddRectilinearViewportEx добавляет врезку лестницы 1:20 после плана 1:100, поэтому обратный hit test выбирает врезку, и линия в 85 пунктов читается как 0.60 m, а не 3.00 m
Добавление врезки лестницы после общелистового viewport плана позволяет обратному обходу /VP найти её первой, и пролёт лестницы в 85 пунктов остаётся 0.60 m вместо 3.00 m

Арифметика за rectilinear-масштабом

В rectilinear-словаре мер (ISO 32000-1 таблица 262, таблица 267 в ISO 32000-2) четыре обязательные записи, и вся его арифметика держится на /C. /X, /D и /A — массивы number format: словари number format (таблица 263), упорядоченные от крупнейшей единицы вниз, каждый с меткой /U и коэффициентом преобразования /C. Поздние словари массива преобразуют из единицы перед ними, а ведущий стартует с другого места — в зависимости от того, какой записью владеет массив:

ЗаписьОбязательнаВедущий /C преобразует изМетрический лист 1:100
/RДаНичего; человекочитаемый текст соотношения(1 cm = 1 m)
/XДаЕдиницы default user space/U (m) /C 0.0352778
/YКогда единицы или коэффициенты y различаютсяЕдиницы default user spaceОпущен
/DДаВедущая единица /X/U (m) /C 1
/AДаВедущая единица /X в квадрате/U (sq m) /C 1
/TНетГрадусыОпущен
/SНетВедущая единица /Y, делённая на ведущую /XОпущен

На метрическом плане 1:100 масштаб живёт в одном числе. Один пункт — это 25.4 / 72 = 0.352778 мм бумаги, которые изображают 35.2778 мм здания, поэтому /X получает /U (m) и /C 0.0352778, тогда как /D стартует от метров, /A — от квадратных метров, и обоим достаётся /C 1. Скопировать коэффициент /X в /D и возвести его в квадрат для /A — значит применить масштаб дважды: 5-метровая стена возвращается как 0.18 m, а комната в 20 м² сжимается до 0.025 m². Впишите этот ведущий /C из 1 в /D и /A в каждом viewport, даже там, где он кажется избыточным. Таблица 262 делает оба массива обязательными и не определяет запасного варианта, так что viewer, встретивший отсутствующий или наугад подставленный коэффициент, импровизирует, а два viewer могут импровизировать по-разному; словарь, выписанный целиком, приколачивает каждое показание к самому файлу, и единицы сидят в коде, который рецензент может сверить с рамкой чертежа. Остальное в модели number format работает так:

Цепочка преобразований THPDFRectilinearMeasureInfo в HotPDF для измерительного viewport PDF 1:100 на Delphi: коэффициент /X 0.0352778 из пунктов в метры, коэффициенты /D и /A равные 1 и сжавшиеся показания, когда масштаб применён дважды
Только number format /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 /D 8 округляет до восьмых
  • /R — подпись для людей, поэтому неверная строка соотношения поверх верного /C всё равно даёт верные числа
  • Профили с преувеличенным вертикальным масштабом — случай «in X 1 cm = 1 m, in Y 1 cm = 30 m» из таблицы 262 — нуждаются в /Y плюс коэффициент /CYX, переводящий единицы y в единицы x; без /CYX reader может отказаться считать расстояние, площадь и угол

Пишем план 1:100 с врезкой 1:20

THPDFPage.AddRectilinearViewportEx принимает прямоугольник viewport и запись THPDFRectilinearMeasureInfo, чьи поля ложатся на /R, /X, /Y, /D, /A, /T, /S, /O и /CYX, причём каждый массив number format хранится записями THPDFNumberFormatEntry. Таблица выше входит внутрь без изменений, и никаких домыслов на стороне reader не остаётся. Правила вызова, действующие до запуска кода ниже:

  • Rectilinear viewport требует PDF 1.6. HotPDF поднимает THotPDF.Version до pdf16 по требованию; при установленном StrictVersionLock метод ничего не пишет и возвращает -1
  • Пустой /R или массив /X, /D или /A без записей поднимает исключение, поскольку таблица 262 требует все четыре
  • Каждый вызов дописывает в /VP, поэтому план идёт первым, врезка — за ним, и в итоге врезка оказывается там, где reader тестирует её раньше всего остального
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 default user space: пункты, начало внизу слева
    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 default user space: пункты, отсчёт от левого нижнего угла. Это то пространство, из которого преобразует коэффициент /X, так что прямоугольник и масштаб живут в одной единице. Рисующие вызовы HotPDF пользуются другой, с началом слева вверху и координатами в 1/Resolution дюйма, и разрыв между единицами рисования HotPDF и пунктами PDF действует здесь в полную силу — врезка, нарисованная в координатах с верхнего левого угла, требует перевернуть y относительно высоты страницы и перевести в пункты, иначе измерительный прямоугольник приземлится на противоположную половину листа. Крупноформатные страницы добавляют ещё один множитель: при /UserUnit, выставленном через THPDFPage.SetUserUnit, одна единица по умолчанию — это UserUnit / 72 дюйма, поэтому коэффициент /X масштабируется на ту же величину

В каком порядке идут координаты GPTS?

Viewport карты несёт словарь мер с /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 всегда, когда у системы координат есть код. Целое число нельзя сломать экранированием строк, и reader сверяет его с тем же реестром, которым пользуются GIS-инструменты, а WKT остаётся для кастомных проекций, у которых кода нет. /DCS опционально называет другую систему для отображения позиций. /PDU перечисляет предпочтительные единицы отображения по имени — линейные, площади и угловые, вроде KM, SQKM и DEG, — и HotPDF пишет её только когда все три единицы в THPDFGeoMeasureInfo заполнены

Пары /GPTS — широта, потом долгота, когда /GCS географическая, и easting, потом northing, когда она проекционная. Порядок переворачивается. Пары x/y из GIS, вбитые в /GPTS напрямую, выходят правильными для карты UTM и неправильными для WGS 84. Сам EPSG:4326 ставит широту впереди долготы, GeoJSON и большинство web map API делают наоборот, и уличная карта Цюриха, зарегистрированная в web-map порядке, показывает результат: 8.54° восточной долготы читается как широта, 47.37° северной — как долгота, и курсор отчитывается позициями у границы Эфиопии и Сомали. /LPTS держит соответствующие точки в единичном квадрате viewport, где (0, 0) — левый нижний угол /BBox, а (1, 1) — правый верхний, по паре на каждую пару /GPTS; вставленные туда страничные координаты уводят контрольные точки на сотни квадратов от карты. /Bounds — neatline, ограничивающая область действия геопривязки, — пользуется тем же единичным квадратом и по умолчанию равна [0 0 0 1 1 1 1 0]. HotPDF проверяет форму, прежде чем что-то писать: /GPTS нужно чётное количество не менее четырёх чисел, а непустой /LPTS обязан совпасть с ней по длине

Контрольные точки GeoPDF THPDFGeoMeasureInfo в HotPDF на Delphi: порядок /GPTS широта-долгота для GEOGCS вроде EPSG 4326, easting-northing для PROJCS и углы /LPTS в единичном квадрате viewport, который использует AddGeospatialViewport
Географические и проекционные системы координат упорядочивают пары /GPTS по-разному, тогда как /LPTS всегда отсчитывает от левого нижнего угла viewport /BBox в единичном квадрате

Прицепляем облако точек к viewport карты

GEO viewport может нести и словарь точечных данных (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-числом, всё прочее остаётся строкой, а количество, не кратное числу колонок, поднимает исключение. Обоим вызовам нужен 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);
  // Те же углы в единичном квадрате 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 остаётся пустым
  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;

Читаем viewport обратно и мерим на сервере

THotPDF.GetLoadedPageViewports возвращает THPDFViewportInfoArray для нуль-базированного индекса страницы — по одному THPDFViewportInfo на запись /VP в файловом порядке, с BBox, Name, MeasureSubtype, разобранной записью Rectilinear или Geospatial и строками PtData, если есть. Словарь мер без /Subtype возвращается как RL — умолчание, которое назначает таблица 261 ISO 32000-1. Две функции ниже воспроизводят то, что viewer делает по клику: найти viewport обратным обходом, затем превратить страничное расстояние в реальные единицы через /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 пунктов по 0.0070556 m за пункт — это 0.5997 m. Поменяйте местами два вызова AddRectilinearViewportEx, и та же линия в 85 пунктов разрешится в viewport плана и прочтётся как 3.00 m. Для сканированного комплекта чертежей, который вы не производили, THotPDF.AddPageRectilinearViewportEx, AddPageGeospatialViewport и SetPageViewportPointDataCloud делают ту же работу над загруженным документом по индексу страницы перед SaveLoadedDocument. Аннотации линии, полилинии и полигона могут нести собственный /Measure (ISO 32000-1, таблицы 175 и 178), и GetLoadedAnnotationMeasure разбирает его в те же записи RL и GEO, что важно, когда измерительные аннотации в проверяемом PDF пришли из другого инструмента

При /D и /A, приколоченных к 1, коэффициент /X 0.0352778 — единственное число, несущее масштаб 1:100, и 5-метровая южная стена должна читаться 5.00 m в любом reader, следующем §12.9. Тот же тип записи приходит обратно из GetLoadedPageViewports, поэтому файл, написанный компонентом HotPDF Delphi PDF, можно перезагрузить и сверить его коэффициенты с теми значениями, что положил туда ваш код