기술 문서

축척 도면과 GeoPDF 지도를 위한 PDF 측정 뷰포트

PDF로 내보낸 1:100 1층 평면도를 Acrobat에서 열면 거리 도구가 5 m 남쪽 벽을 종이 위 길이인 1.97 in으로 보고합니다. 빠진 조각은 points를 미터로 바꾸는 rectilinear(/RL) 측정 사전을 지닌 /VP 뷰포트입니다 — HotPDF가 Delphi에서 기록하는 것이고, 지도용 geospatial(/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를 평탄화한다면 콘텐츠를 따라가야 하는 기하 조각 하나가 더 늘어납니다

뷰포트는 겹칠 수 있고 §12.9는 겹침을 배열 순서로 풉니다. 점 아래 뷰포트를 찾는 reader는 마지막 /VP 항목에서 시작해 뒤로 걷다가 /BBox가 그 점을 품는 하나에서 멈춥니다. 따라서 1:100 도면 위의 1:20 계단 상세 인셋은 도면 전체 뷰포트보다 나중에 와야 합니다 — 먼저 추가하면 큰 사각형이 모든 히트 테스트를 이겨서 인셋은 다섯 배로 길게 측정됩니다. 거리는 시작점을 따라갑니다. 인셋 안에서 시작해 주 평면도 위에서 끝나는 선은 경계에 닿든 안 닿든 전체 길이를 1:20으로 측정됩니다

Delphi의 HotPDF /VP 뷰포트 배열: AddRectilinearViewportEx가 1:100 평면도 뒤에 1:20 계단 상세를 추가하므로 역방향 히트 테스트가 인셋을 고르고 85포인트 선이 3.00 m이 아니라 0.60 m로 읽힙니다
계단 인셋을 도면 전체 평면 뷰포트 뒤에 추가하면 역방향 /VP 걷기가 그것을 먼저 찾아 85포인트 계단 런을 3.00 m 대신 0.60 m로 유지합니다

rectilinear 축척 뒤의 산수

rectilinear 측정 사전(ISO 32000-1 Table 262, ISO 32000-2에서는 Table 267)은 네 개의 필수 항목을 지니고, 그 산수 전부가 /C에 매달려 있습니다. /X, /D, /A는 number format 배열입니다. number format 사전들(Table 263)이 가장 큰 유닛부터 내려가는 순으로 배열된 것이고, 각각은 /U 라벨과 /C 변환 인자를 지닙니다. 배열에서 나중 사전은 바로 앞 유닛에서 변환하는 반면, 선두 사전은 그 배열을 소유한 항목이 어느 것인지에 따라 다른 출발점에서 시작합니다:

항목필수선두 /C가 변환하는 출발점1:100 미터법 도면
/R예없음, 사람이 읽는 축척 텍스트(1 cm = 1 m)
/X예기본 사용자 공간 유닛/U (m) /C 0.0352778
/Yy 유닛이나 인자가 다를 때기본 사용자 공간 유닛생략
/D예선두 /X 유닛/U (m) /C 1
/A예선두 /X 유닛의 제곱/U (sq m) /C 1
/T아니요도(각도)생략
/S아니요선두 /Y 유닛을 선두 /X 유닛으로 나눈 것생략

미터법 1:100 평면도에서 축척은 숫자 하나에 삽니다. 포인트 하나는 종이 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²로 줄어듭니다. 겉보기에 중복으로 보이는 자리에서도 모든 뷰포트의 /D와 /A에 그 선두 /C 1을 써 넣으세요. Table 262는 두 배열을 필수로 요구하면서 폴백을 정의하지 않으므로, 빠졌거나 짐작한 인자를 만난 뷰어는 즉흥연기를 하는 것이고 두 뷰어는 서로 다른 방식으로 즉흥연기를 할 수 있습니다. 전부 명시된 사전은 모든 수치를 파일 그 자체에 못 박고, 유닛들은 검토자가 표제란과 대조해 볼 수 있는 코드 안에 앉습니다. number format 모델의 나머지는 이렇게 작동합니다:

Delphi에서 1:100 PDF 측정 뷰포트를 위한 HotPDF THPDFRectilinearMeasureInfo 변환 체인: points에서 미터로 가는 /X 인자 0.0352778, 인자 1의 /D와 /A, 그리고 축척이 두 번 적용될 때 줄어든 수치들
/X number format만이 1:100 축척을 나릅니다. /D와 /A가 인자 1에 못 박히면 남쪽 벽은 5.00 m, 방은 20.00 sq m으로 읽힙니다
  • 사슬처럼 이어진 유닛은 이전 유닛 규칙을 따릅니다. 표준 자신의 예가 마일은 /C 1, 피트는 /C 5280, 인치는 /C 12에서 출발해 "1 mi 2,378 ft 7 5/8 in"을 인쇄하고 /F /F /D 8이 8분의 1로 반올림하는 방식이 그것입니다
  • /R은 사람을 위한 캡션이므로, 올바른 /C 위의 틀린 축척 문자열은 그래도 올바른 숫자를 냅니다
  • 수직 축척이 과장된 단면도, Table 262의 "in X 1 cm = 1 m, in Y 1 cm = 30 m" 사례는 /Y와 y 유닛을 x 유닛으로 바꾸는 /CYX 인자가 필요합니다. /CYX가 없으면 reader는 거리, 면적, 각도 계산을 거절할 수 있습니다

1:20 상세 인셋을 곁들인 1:100 평면도 쓰기

THPDFPage.AddRectilinearViewportEx는 뷰포트 사각형과 THPDFRectilinearMeasureInfo 레코드를 받습니다. 그 필드들은 /R, /X, /Y, /D, /A, /T, /S, /O, /CYX에 대응하며 각 number format 배열은 THPDFNumberFormatEntry 레코드로 담깁니다. 위 표는 그대로 들어가고 reader가 짐작할 것은 아무것도 남지 않습니다. 아래 코드가 돌기 전에 적용되는 호출 규칙들:

  • rectilinear 뷰포트는 PDF 1.6이 필요합니다. HotPDF는 필요할 때 THotPDF.Version을 pdf16으로 올립니다. StrictVersionLock이 켜져 있으면 메서드는 아무것도 쓰지 않고 -1을 반환합니다
  • 빈 /R, 혹은 항목이 하나도 없는 /X, /D, /A 배열은 예외를 일으킵니다. Table 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 = 1/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;
    // ... 1층 평면도와 계단 상세 인셋을 그립니다 ...

    // 사각형은 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;

네 개의 사각형 인자는 PDF 기본 사용자 공간 그대로 /BBox에 들어갑니다. 포인트 단위, 왼쪽 아래 코너에서 잰 값입니다. /X 인자가 변환하는 것이 바로 이 공간이므로 사각형과 축척은 하나의 유닛을 공유합니다. HotPDF 드로잉 호출은 다른 공간을 씁니다. 원점이 왼쪽 위이고 좌표는 1/Resolution 인치입니다. HotPDF 드로잉 유닛과 PDF points 사이의 간극이 여기서 온전히 적용됩니다 — 왼쪽 위 좌표로 그린 인셋은 y 값을 페이지 높이에 대해 뒤집고 points로 바꿔야 하며, 그러지 않으면 측정 사각형이 도면의 반대쪽 절반에 떨어집니다. 대형 포맷 페이지는 인자 하나를 더 얹습니다. 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를 쓰세요. 정수는 문자열 이스케이프로 깨질 수 없고 reader는 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도 같은 단위 사각형을 쓰며 기본값은 [0 0 0 1 1 1 1 0]입니다. HotPDF는 무엇이든 쓰기 전에 형태를 검사합니다. /GPTS는 최소 네 개 숫자의 짝수 개수가 필요하고, 비어 있지 않은 /LPTS는 그 길이와 맞아야 합니다

Delphi의 HotPDF THPDFGeoMeasureInfo GeoPDF 제어점: EPSG 4326 같은 GEOGCS에 대한 /GPTS 위도–경도 순서, PROJCS에 대한 easting–northing, 그리고 AddGeospatialViewport가 쓰는 뷰포트 단위 사각형의 /LPTS 코너들
지리계와 투영계는 /GPTS 쌍을 다른 순서로 배열하는 반면, /LPTS는 언제나 단위 사각형에서 뷰포트 /BBox의 왼쪽 아래 코너부터 셉니다

지도 뷰포트에 포인트 클라우드 붙이기

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
  // 낫라인 코너, 위도가 경도보다 앞: 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을 생략한 측정 사전은 ISO 32000-1 Table 261이 정한 기본값인 RL로 돌아옵니다. 아래 두 함수는 뷰어가 클릭으로 하는 일을 재현합니다. 역방향 걷기로 뷰포트를 찾은 다음 /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. 포인트당 0.0070556 m로 85포인트면 0.5997 m입니다. 두 AddRectilinearViewportEx 호출을 맞바꾸면 같은 85포인트 선이 평면도 뷰포트로 resolve되어 3.00 m로 읽힙니다. 직접 만들지 않은 스캔 도면 세트에는 THotPDF.AddPageRectilinearViewportEx, AddPageGeospatialViewport, SetPageViewportPointDataCloud가 SaveLoadedDocument 전에 페이지 인덱스로 로드된 문서에 같은 일을 합니다. 선, 폴리라인, 폴리곤 주석은 자기만의 /Measure를 나를 수 있고(ISO 32000-1 Tables 175와 178), GetLoadedAnnotationMeasure는 그것을 같은 RL과 GEO 레코드로 파싱합니다. 검토된 PDF의 측정 주석이 다른 도구에서 왔을 때 중요해지는 부분입니다

/D와 /A가 1에 못 박히면 0.0352778의 /X 인자가 1:100 축척을 나르는 유일한 숫자이고, 5 m 남쪽 벽은 §12.9를 따르는 어떤 reader에서도 5.00 m로 읽혀야 합니다. 같은 레코드 타입이 GetLoadedPageViewports로 돌아오므로, HotPDF Delphi PDF 컴포넌트가 쓴 파일은 다시 로드해 코드가 넣은 값과 인자들을 비교할 수 있습니다