技術記事

DelphiのPDFバージョンプリフライト:MeasureのRLとGEO規則

建築CADパッケージがエクスポートした平面図は、典型的には1ページ目に2つのビューポートを持つ/VP配列を載せ、各ビューポートが/Measureエントリを運ぶため、ビューアは図面から直接メートルを読めます。GeoPDFの地図図版も同じ/VP配列と同じ/Measureキーを持ちます。平面図は普通のPDF 1.6です。地図図版はPDF 1.7とAdobe Extension Level 3を必要とします。キー名で止まるバージョン検査は両方に拡張を要求し、平面図は、決して含んでいない地理空間内容についてのメッセージとともに保存時に拒否されることになります

1つの/Measureキー、2つの異なる辞書

キー名はビューポートが測定可能であることだけを語り、その背後の辞書だけが方法を語ります。Measure辞書はPDF 1.6で到来し、ISO 32000-1の§12.9は直線型を定義しています。/Rの縮尺比文字列、/Xと/Yの数値書式配列、さらに距離(/D)と面積(/A)の配列です。地理空間測量は後から、PDF 1.7の上に載るAdobe Extension Level 3の一部としてやってきました。/Subtype /GEOで印が付き、/GCSの地理座標系、オプションの/DCS表示座標系、/GPTSと/LPTSのペアになった点配列、/PDUの推奨表示単位を運びます — その構造はDelphiでGeoPDFのビューポート、GPTS、LPTS配列を読むで歩いています

人が引っかかる細部はデフォルトです。§12.9は/Subtypeにデフォルト値RLを与えており、多くのCADエクスポーターはそれを利用してエントリを省きます。欠けたsubtypeを「不明、最悪を想定」と読むチェッカーは、そのような図面をすべて地理空間ドキュメントへ変えてしまいます。/Measureキーで照合するチェッカーは、そもそもsubtypeの問いにたどり着きません。キーを保持するビューポート辞書は、それに答えるmeasure辞書の1つ上の階層にあるからです

PDFlibPasにおける1つの/Measureキーと2つの辞書。/RLを指定するかsubtypeを省く直線測量はPDF 1.6だけで足りる一方、地理空間辞書は1.7 ExtensionLevel 3を必要とする。そのためプリフライトは、キーレベルの規則では区別できないところを、measure辞書の内容で決める
有効なファイルを拒否するプリフライトは遅いプリフライトよりひどい。呼び出し側は、ドキュメントが決して含んでいなかった機能についての権威ある診断を受け取ることになる。識別チェックが1階層下に置かれているのはそのためだ

PDF Library for Delphiの保存プリフライトが判定に至るまで

PDF Library for Delphi(PDFlibPas)は、出力が書かれる前にSaveToFileの内側でバージョンプリフライトを実行します。すべての間接オブジェクトを訪れ、ネストした辞書と配列へ降りて、各ノードを機能規則のテーブルに対して検査します。すべての規則は最低PDFバージョンを名指しし、拡張機能ではAdobe拡張レベルを名指します。拡張規則を満足できるのは、catalogに一致する/Extensions /ADBEエントリを持つPDF 1.7だけです。検査のターゲットはドキュメントのバージョン、あるいはLockSaveVersionで固定したバージョンに、catalogが宣言する拡張レベルを加えたものです。照合し、かつターゲットを超える最初の規則が保存を終わらせます。SaveToFileは0を返し、LastErrorCodeは602、つまりPDFLIB_ERROR_VERSION_COMPLIANCEの値になります

PDFlibPasの保存時バージョンプリフライト。SaveToFileはすべてのオブジェクトを機能規則の固定テーブルと照合し、各規則は最低PDFバージョンを、拡張機能では一致する/Extensionsエントリを持つPDF 1.7だけが満たせるAdobe拡張レベルを運ぶ。ターゲットが満たせない最初の照合がエラー602で保存を止める
GetInformationのキー311、312、313が拒否を診断に変えます。要求バージョン、それを引き起こした機能、ロックされたターゲットを報告するので、呼び出し側は推測の代わりにファイルか規則を直せる

エラーコードだけでは何を直すべきか語りません。3つのGetInformationキーが残りを運びます。311は違反機能が必要とするバージョン(たとえば1.7 ExtensionLevel 3)、312は機能の名前、313は保存ターゲットがロックされているバージョンで、何もロックされていなければ空です

var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  try
    if Pdf.LoadFromFile('floor-plan.pdf', '') <> 1 then
      raise Exception.Create('load failed');
    if Pdf.SaveToFile('floor-plan-out.pdf') <> 1 then
      if Pdf.LastErrorCode = PDFLIB_ERROR_VERSION_COMPLIANCE then
        // 311:要求バージョン、312:それを引き起こした機能、
        // 313:保存ターゲットがロックされているバージョン(未ロックなら'')
        Writeln('Needs ', Pdf.GetInformation(311),
          ' for ', Pdf.GetInformation(312),
          ', locked at [', Pdf.GetInformation(313), ']');
  finally
    Pdf.Free;
  end;
end;

測量内容について、テーブルは3つの規則を保持し、素の/Measureキーには意図的に規則を置きません。/VPや/UserUnitを持つページはPDF 1.6を必要とします。/PtData辞書は拡張レベル3を必要とします。measure辞書は、/Subtype /GEOと述べるか、/GCS、/DCS、/GPTS、/LPTS、/PDUのいずれかを含むときに拡張レベル3を必要とします。したがって、measure辞書が/Subtypeを省くビューポートも/RLと明記するビューポートも1.6で通過し、同じページでも1.5では/VPのせいで拒否されます。/GPTS配列を加えると、答えは1.7 ExtensionLevel 3になります

内容テストは意図的に保守的です。迷子の/GCSや/PDUも運ぶ直線辞書は地理空間として扱われます。そのようなファイルを素通しさせるより拒否するほうを選びます。それらのキーは直線モデルでは何も意味しないため、書いたのは地理空間ツールか、ファイルを壊した何かのどちらかであり、GEO対応のビューアはそれを解釈しようとするかもしれないのです。ローダーは逆方向でも同じだけ慎重です。機能スキャンはドキュメントのバージョン番号を1.7へ引き上げ得ますが、入力ファイルの代わりに/Extensions宣言を書くことはありません — 拡張の宣言は内容についての主張であり、それを行うのはあなたのコードかオーサリング呼び出しだけであるべきです

保存する前に図面のビューポートを調べる

312が地理空間機能を名指ししていて、ファイルが素の図面だと確信できるときは、measure辞書を自分で見てください。公開されているビューポート呼び出しで十分です。GetMeasureDictCoordinateSystemは/Subtypeを読み、RLに1、GEOに2、エントリがなければ0を返します — §12.9によればそれもまた直線を意味します。点配列のカウントが、地理参照データが存在するかどうかを教えます

// 読み込まれたドキュメント内のすべてのmeasure辞書を報告する。
// ここでの呼び出しはすべて読み取り専用。キーをファイルに加えるものはない。
procedure ReportMeasureDicts(Pdf: TPDFlib);
var
  Page, VP, VPID, MD, Kind: Integer;
begin
  for Page := 1 to Pdf.PageCount do
  begin
    Pdf.SelectPage(Page);
    for VP := 1 to Pdf.GetPageViewPortCount do
    begin
      VPID := Pdf.GetPageViewPortID(VP);
      MD := Pdf.GetViewPortMeasureDict(VPID);
      if MD = 0 then
        Continue;
      Kind := Pdf.GetMeasureDictCoordinateSystem(MD); // 0 = 省略(RL)、1 = RL、2 = GEO
      Writeln(Format('page %d viewport "%s": subtype %d, GPTS %d, LPTS %d',
        [Page, string(Pdf.GetViewPortName(VPID)), Kind,
         Pdf.GetMeasureDictGPTSCount(MD), Pdf.GetMeasureDictLPTSCount(MD)]));
    end;
  end;
end;

そのループには、意図的に欠けている呼び出しが2つあります。GetMeasureDictGCSDictとGetMeasureDictDCSDictです。この2つはオーサリングのために作られたget-or-createアクセサです。/GCSエントリのないmeasure辞書では、GetMeasureDictGCSDictは空のエントリを追加し、ドキュメントに拡張レベル3を宣言します。直線の平面図にプローブとして呼ぶと、辞書は今や文字通り/GCSを含み、1.6にロックされたその後の保存は、診断がたった今書き込んだ内容のせいで、正しく拒否されます。座標系が存在するかどうかを知る必要があるなら、subtypeと点配列のカウントが副作用なしにその問いに答えます

出力をPDF 1.6にロックする

固定されたバージョンは、消費者に上限があるとき出力を正直に保ちます。TPDFlib.LockSaveVersionは'1.0'から'1.7'までの文字列を受け付け、それ以外には0を返します。ドキュメントバージョンを固定し、書き手側の呼び出しが自力で引き上げるのを止めますが、保存プリフライトはロックされた値に対して引き続き走ります。1.6にロックされたCAD図面はきれいに保存されます。1.6にロックされた本物のGeoPDFは602を受け取ります。それがあなたの望む答えです。代替案は、ヘッダが内容の要求より少ないことを約束するファイルです

if Pdf.LockSaveVersion('1.6') <> 1 then
  raise Exception.Create('unsupported version string');
try
  if Pdf.SaveToFile('floor-plan-16.pdf') <> 1 then
    if Pdf.LastErrorCode = PDFLIB_ERROR_VERSION_COMPLIANCE then
      // 1.6を超える本物の内容、たとえばGEOのmeasure辞書
      raise Exception.CreateFmt('Locked at 1.6 but %s needs %s',
        [string(Pdf.GetInformation(312)), string(Pdf.GetInformation(311))]);
finally
  Pdf.UnlockSaveVersion;
end;

バージョンに触れる前に311と312を読んでください。機能が本物の地理空間内容なら、正直な選択肢は2つあります。拡張を宣言したままドキュメントを1.7で解放しておくか、1.6専用の消費者へ送るコピーから地理空間データを落とすかです。誤りを消すためだけにロックを引き上げるのは避けるべき選択肢です — その選択は「下流の読み手は出荷するものを扱えるのか」という本質的な問いを隠します。API経由で地理空間内容を組み立てるとき、SetMeasureDictCoordinateSystemやSetMeasureDictGPTSCountのようなSetMeasureDict*セッターは自ら拡張レベル3を宣言するため、オーサリングされたファイルはラベル付きでプリフライトに届きます。境界のある、証拠に裏打ちされたチェックという同じ習慣は、工学ドキュメントのためのPDF/E-1オーサーモードプリフライトにも現れます。CAD図面がバージョン番号の代わりに適合標準と出会う場所です

規則スキャンがテーブルをその場で読む理由

プリフライトのコストはおよそオブジェクトノード数 × 規則数で、ノード数にはすべての配列内のすべての数値と名前が含まれます。そのため規則あたりのオーバーヘッドが見た目以上に効いてきます。テーブルの各規則は2つのAnsiStringフィールドを持つレコードです。照合文字列と機能名です。そのようなレコードをローカル変数へコピーすると、DelphiやFPCは2つの参照カウントを動かし、ローカルが以前保持していたものを解放します。規則ごと、ノードごとに1度ずつなら、ターゲットバージョンに適用されず、次の行で捨てられる規則のためにも、文字列の簿記を支払っていることになります

PDFlibPasの規則スキャン。文字列フィールドを持つ規則レコードのコピーは訪問のたびに参照カウントを調整する一方、読み取り専用のテーブルエントリをconstパラメータとして渡せば、文字列に触れずその場で読める
読み取り専用テーブルをその場で読むことは規則照合パスから参照カウント作業を取り除く。一方、フル保存は依然として機能検出、オブジェクト復号、直列化のコストを払う

規則テーブルはユニット初期化時に一度だけ満たされ、その後は変更されないため、スキャンはテーブルエントリをconstレコードパラメータで照合コードへ渡し、ある場所で読みます。constレコードパラメータは参照渡しなので、規則の検査はどちらの文字列フィールドにも触れず、参照カウントは元の位置に留まります。効果は規則照合パスに集中しています。本物の保存はオブジェクトの復号、遅延機能検出、出力の直列化も行い、それらの段階は規則の渡し方に影響されません。規則の順序、しきい値、311と312の最初の失敗診断はこれに依存せず、保存間で規則の結果はキャッシュされないため、編集して2度保存したドキュメントは両方のときに完全に検査されます

文字列、インターフェース、動的配列を含むレコードのテーブルをループが歩く場所では、同じパターンを自分のDelphiコードで確認する価値があります:

type
  TMyRule = record
    Name: string;      // マネージドフィールド:コピーは参照カウント作業のコスト
    Threshold: Integer;
  end;

// 読み手自身のコード、パターンとして提示
function Applies(const Rule: TMyRule; Value: Integer): Boolean;
begin
  Result := Value >= Rule.Threshold;  // const:コピーも参照カウント往来もない
end;

// ホットループでは、ローカルレコードへ
// 代入せずテーブルエントリを直接渡す
for I := 0 to High(Rules) do
  if Applies(Rules[I], V) then
    Inc(Hits);

測量ビューポート、地理空間辞書、バージョンロック、保存時の適合チェックはすべて、Delphi、C++Builder、Lazarusプロジェクト向けのPDF Library for Delphiに同梱されています