PDF に書き出した 1:100 の 1 階平面図を Acrobat で開くと、距離ツールは 5 m の南壁を紙上の長さにすぎない 1.97 in と報告してきます。欠けている部品は、rectilinear(/RL)の measure 辞書でポイントをメートルへ変換する /VP viewport です。HotPDF なら Delphi からこれを書き込めます。地図向けの geospatial(/GEO)control point も一緒に
viewport は計測ツールに何を与えるか
viewport はページ上の矩形で、それ専用の計測座標系を帯びてきます。ページ辞書の任意エントリ /VP(ISO 32000-1 Table 30、PDF 1.6)は /Type /Viewport 辞書の配列を収め、各辞書に必須なのは /BBox だけです。/Name はビューアの UI での名前、/Measure が縮尺を供給します(ISO 32000-1 §12.9、Table 260。同じ辞書は ISO 32000-2 では Table 265)。viewport のないページでは Acrobat の計測ツールは 1:1 の紙スケールのままなので、1.97 in は 50 mm のインクを忠実に測った結果でしかありません。/BBox は default user space で書かれ、左下隅、右上隅の順です。この隅の順序が計測 x 軸と y 軸の向きを決め、規格はこれをページの /Rotate とは独立であると宣言しています。ページの各 box に /Rotate を焼き込むときは、/VP の矩形も content に追随すべき幾何の 1 つに加わります
viewport は重なり得ます。§12.9 はこの重なりを配列の順序で解決します。点の下の viewport を探すリーダーは最後の /VP エントリから始めて後ろ向きに進み、その /BBox が点を含むものに到達したところで止まります。したがって 1:100 シートに置く 1:20 の階段詳細インセットは、シート全体の viewport の後ろに来なければなりません。先に追加すると大きな矩形がすべての hit test を勝ち取り、詳細部分は 5 倍の長さに測られてしまいます。距離は始点に従います。インセット内で始まり main plan の外で終わる線分は、境界の内外にかかわらず全長を 1:20 で測られます
rectilinear 縮尺の裏にある計算
rectilinear の measure 辞書(ISO 32000-1 Table 262、ISO 32000-2 では Table 267)には必須エントリが 4 つあり、その計算はすべて /C に掛かっています。/X、/D、/A は number format 配列です。number format 辞書(Table 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 単位の 2 乗 | /U (sq m) /C 1 |
| /T | いいえ | 度 | 省略 |
| /S | いいえ | 先頭の /Y 単位÷先頭の /X 単位 | 省略 |
メートルの 1:100 図面なら、縮尺は 1 つの数に宿ります。1 ポイントは紙の 25.4 / 72 = 0.352778 mm で、これは建物の 35.2778 mm を表します。だから /X には /U (m) と /C 0.0352778 を与え、/D はメートルから、/A は平方メートルから始めて、両方に /C 1 を与えます。/X の係数を /D にコピーし、/A のために 2 乗すると、縮尺が二重に掛かります。5 m の壁は 0.18 m で返ってきて、20 m² の部屋は 0.025 m² に縮みます。この先頭の /C 1 は冗長に見える場所でも、すべての viewport の /D と /A に書き込みましょう。Table 262 は両配列を必須とし、フォールバックを定義しないので、係数が欠けていたり推測だったりするビューアは即興で埋めていて、2 つのビューアが 2 通りに即興することもあり得ます。全文を書き下した辞書はすべての読み取りをファイル自体に釘付けにし、単位は reviewer が title block と突き合わせて確認できるコードの中に座ります。number format モデルの残りはこう動きます:
- 連結された単位は前の単位ルールに従います。規格自身の例が「1 mi 2,378 ft 7 5/8 in」と表示される仕組みです。/C 1 のマイル、/C 5280 のフィート、/C 12 のインチから組み立て、/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 がなければ、リーダーは距離・面積・角度の計算を拒んでも文句は言えません
1:20 詳細インセット付きの 1:100 図面を書く
THPDFPage.AddRectilinearViewportEx は viewport の矩形と、フィールドが /R、/X、/Y、/D、/A、/T、/S、/O、/CYX に対応する THPDFRectilinearMeasureInfo レコードを取ります。各 number format 配列は THPDFNumberFormatEntry レコードとして保持されます。上の表はそのまま入力に使え、リーダーが推論すべきものは何も残りません。下のコードが走る前に効く呼び出し規則:
- rectilinear viewport には PDF 1.6 が必要です。HotPDF は必要になった時点で
THotPDF.Versionをpdf16に引き上げます。StrictVersionLockが設定されているときは、メソッドは何も書かず -1 を返します - 空の /R、エントリのない /X、/D、/A 配列は例外を上げます。Table 262 が 4 つすべてを要求するからです
- 各呼び出しは /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 = 百分の 1 単位
end;
// 1:Scale のメートル図面、読み取りはすべてメートル
function MetricScale(const Ratio: AnsiString;
Scale: Double): THPDFRectilinearMeasureInfo;
const
MetersPerPoint = 0.0254 / 72; // 1 単位の紙上の長さ
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 単位の 2 乗から
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 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;
矩形の 4 つの引数はそのまま /BBox に入ります。PDF default user space、つまりポイント、左下隅から測ります。/X 係数が換算元とする空間もこれなので、矩形と縮尺は 1 つの単位を共有します。HotPDF の描画呼び出しは別の座標系で、原点は左上、座標は 1/Resolution インチ刻みです。HotPDF の描画単位と PDF ポイントの隔たりがここでは丸ごと効きます。左上座標で描いたインセットは、y 値をページ高さに対して裏返してポイントへ換算する必要があります。そうしないと計測矩形はシートの反対側に着地します。大判ページは係数を 1 つ足します。THPDFPage.SetUserUnit で /UserUnit が設定されると、1 default 単位は UserUnit / 72 インチなので、/X 係数は同じ値で拡大縮小します
GPTS の座標はどちらの順序か
地図の viewport は /Subtype /GEO の measure 辞書を運びます。これは PDF 1.7 への Adobe 拡張として始まり、PDF 2.0 で規格の一部になりました(ISO 32000-2 §12.10.2、Table 269)。/GCS は必須で、/EPSG コードか /WKT 文字列で識別される /Type /GEOGCS 辞書(Table 270)か /Type /PROJCS 辞書(Table 271)を収めます。地理座標系では規格は両者のいずれか一方を許し、両方の併用を禁じます。座標系にコードがあるなら EPSG を使いましょう。整数は文字列エスケープで壊れることがなく、リーダーは GIS ツールと同じレジストリで解決します。WKT は、コードを持たない独自投影のために取っておきます。/DCS は表示用の別座標系を任意で名指します。/PDU は推奨表示単位を名前で列挙するもので——linear、area、angular の 3 種、たとえば KM、SQKM、DEG——HotPDF は THPDFGeoMeasureInfo の 3 単位がすべて埋まっているときにだけ書き込みます
/GPTS の組は、/GCS が地理座標系なら緯度→経度、投影座標系なら easting→northing の順です。順序がひっくり返る。GIS の x/y ペアをそのまま /GPTS に流し込むと、UTM 地図では正しく、WGS 84 では間違って出ます。EPSG:4326 自体は経度より先に緯度を置き、GeoJSON と大半の web map API はその逆です。web map 順で登録されたチューリッヒの街路図が結果を見せてくれます。東経 8.54° が緯度と読まれ、北緯 47.37° が経度と読まれ、カーソルはエチオピア・ソマリア国境付近の位置を報告します。/LPTS は対応する点を viewport の単位正方形で持ちます。(0, 0) が /BBox の左下、(1, 1) が右上で、/GPTS の組 1 つにつき組 1 つ。ページ座標をそこに貼ると control point は地図から何百正方形も外れます。georeference の有効範囲を限る neatline である /Bounds も同じ単位正方形を使い、既定値は [0 0 0 1 1 1 1 0] です。HotPDF は何かを書く前に形を検査します。/GPTS は 4 個以上の偶数個の数値が必要で、空でない /LPTS はその長さに一致しなければなりません
地図 viewport にポイントクラウドを付ける
GEO viewport はさらに point data 辞書を運べます(ISO 32000-2 §12.10.5、Table 272)。/Type /PtData、/Subtype /Cloud、列ヘッダーとして働く /Names 配列、行の /XPTS 配列です。度の LAT と LON、メートルの ALT は事前定義ヘッダーで、規格は行を順序なしのコレクションとして扱います。構造上、/XPTS は入れ子になり /GPTS は平坦のままです。点ごとに 1 つの内部配列、サイズは /Names に一致。[[47.3769 8.5417 408] [47.3780 8.5400 410]] のような形です。THPDFPage.SetViewportPointDataCloud はそれでも 1 つの平坦なリストを受け付けます。CSV 書き出しが作る形です。これを列数で行へ切り分け、ピリオド小数点で数値として解釈できる値は PDF の数値に、それ以外は文字列のままになります。列数の倍数でない個数は例外を上げます。両呼び出しとも PDF 2.0 が必要で、AddGeospatialViewport が返す viewport インデックスがクラウドの取り付け先です
procedure AddCityMap(Page: THPDFPage);
const
// Neatline の 4 隅、緯度が経度に先行: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);
// 同じ 4 隅を 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');
// 3 列なので、3 値ごとに 1 つの /XPTS 行になる
Page.SetViewportPointDataCloud(Map, ['LAT', 'LON', 'ALT'],
['47.3769', '8.5417', '408',
'47.3780', '8.5400', '410']);
end;
viewport の読み戻しとサーバー側での計測
THotPDF.GetLoadedPageViewports は 0 始まりのページインデックスに対して THPDFViewportInfoArray を返します。/VP エントリごとに 1 つの THPDFViewportInfo、ファイル順で、BBox、Name、MeasureSubtype、パース済みの Rectilinear か Geospatial のレコード、あれば PtData の行を伴います。/Subtype を省いた measure 辞書は RL として返ります。ISO 32000-1 Table 261 が割り当てる既定値です。下の 2 つの関数は、ビューアがクリックでやることを再現します。逆順の歩行で viewport を見つけ、ページ距離を /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;
// ページ上の 2 点間の距離、先頭の /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 だからです。2 つの AddRectilinearViewportEx 呼び出しを入れ替えると、同じ 85 ポイントの線が平面図 viewport に落ちて 3.00 m と読まれます。自分で作ったのではないスキャン図面群には、THotPDF.AddPageRectilinearViewportEx、AddPageGeospatialViewport、SetPageViewportPointDataCloud が、読み込んだ文書へページインデックスで SaveLoadedDocument の前に同じ仕事をしてくれます。line、polyline、polygon 注釈は自分専用の /Measure を運べます(ISO 32000-1 Tables 175 と 178)。GetLoadedAnnotationMeasure はそれを同じ RL と GEO のレコードにパースし、レビュー済み PDF の計測注釈が他のツール由来のときに効いてきます
/D と /A を 1 に釘付けにすれば、1:100 の縮尺を運ぶ数は /X 係数の 0.0352778 ただ一つになり、§12.9 に従うどんなリーダーでも 5 m の南壁は 5.00 m と読むはずです。同じレコード型は GetLoadedPageViewports から返るので、HotPDF Delphi PDF component が書いたファイルは読み戻して、係数を自分のコードが入れた値と突き合わせられます