План первого этажа 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, граница или нет
Арифметика за 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 работает так:
- Цепочки единиц следуют правилу предыдущей единицы — именно так собственный пример стандарта печатает «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 обязан совпасть с ней по длине
Прицепляем облако точек к 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, можно перезагрузить и сверить его коэффициенты с теми значениями, что положил туда ваш код