Technischer Artikel

PDF-Versions-Preflight in Delphi: RL- gegen GEO-Regeln

Ein Grundriss, den ein Architektur-CAD-Paket exportiert, hat typischerweise ein /VP-Array auf seiner ersten Seite mit zwei Viewports, und jeder Viewport trägt einen /Measure-Eintrag, damit ein Viewer Meter direkt von der Zeichnung ablesen kann. Ein GeoPDF-Kartenblatt hat dasselbe /VP-Array und denselben /Measure-Key. Der Grundriss ist gewöhnliches PDF 1.6. Das Kartenblatt braucht PDF 1.7 plus Adobe Extension Level 3. Eine Versionsprüfung, die beim Key-Namen stoppt, fordert die Extension für beide, und der Grundriss wird beim Speichern mit einer Meldung über Geodaten abgewiesen, die er nie enthielt

Ein /Measure-Key, zwei verschiedene Dictionarys

Der Key-Name verrät nur, dass ein Viewport messbar ist; das Dictionary dahinter verrät, wie. Measure-Dictionarys kamen mit PDF 1.6, und ISO 32000-1 §12.9 definiert die rectilinear-Variante: ein Skalierungsverhältnis-String in /R, Zahlenformat-Arrays in /X und /Y, dazu weitere Arrays für Distanz (/D) und Fläche (/A). Geodätische Messung kam später, als Teil von Adobe Extension Level 3 auf PDF 1.7. Sie ist markiert durch /Subtype /GEO und trägt ein geografisches Koordinatensystem in /GCS, ein optionales Anzeige-Koordinatensystem in /DCS, gepaarte Punkt-Arrays in /GPTS und /LPTS sowie bevorzugte Anzeigeeinheiten in /PDU — die Strukturen, die der Artikel zum Lesen von GeoPDF-Viewports, GPTS- und LPTS-Arrays in Delphi abgeht

Das Detail, an dem es hakt, ist der Default. §12.9 gibt /Subtype den Default-Wert RL, und viele CAD-Exporter nutzen das und lassen den Eintrag weg. Ein Checker, der einen fehlenden Subtype als "unbekannt, im Zweifel das Schlimmste annehmen" liest, macht aus jedem dieser Drawings ein geodätisches Dokument. Ein Checker, der auf den /Measure-Key matcht, kommt gar nicht erst zur Subtype-Frage, denn das Viewport-Dictionary, das den Key hält, liegt eine Ebene über dem Measure-Dictionary, das sie beantwortet

Ein /Measure-Key, zwei Dictionarys in PDFlibPas: Rectilinear-Messung, mit /RL oder weggelassenem Subtype, braucht nur PDF 1.6, ein geodätisches Dictionary braucht 1.7 ExtensionLevel 3, der Preflight entscheidet also nach dem Inhalt des Measure-Dictionarys, wo eine Key-Ebene-Regel sie nicht auseinanderhalten kann
Ein Preflight, der eine gültige Datei blockiert, ist schlimmer als ein langsamer, denn der Aufrufer bekommt eine autoritative Diagnose über ein Feature, das das Dokument nie enthielt — deshalb sitzt die unterscheidende Prüfung eine Ebene tiefer

Wie der Save-Preflight der PDF Library for Delphi zu einem Urteil kommt

PDF Library for Delphi (PDFlibPas) fährt einen Versions-Preflight innerhalb von SaveToFile, bevor irgendeine Ausgabe geschrieben wird. Er besucht jedes indirekte Objekt, steigt in verschachtelte Dictionarys und Arrays hinab, und testet jeden Knoten gegen eine Tabelle von Feature-Regeln. Jede Regel nennt eine Mindest-PDF-Version und, bei Extension-Features, ein Adobe-Extension-Level; eine Extensions-Regel kann nur von PDF 1.7 zusammen mit einem passenden /Extensions /ADBE-Eintrag im Katalog erfüllt werden. Das Ziel, gegen das geprüft wird, ist die Dokumentversion, oder die mit LockSaveVersion festgenagelte Version, plus dem Extension-Level, das der Katalog deklariert. Die erste Regel, die sowohl matcht als auch das Ziel übersteigt, beendet den Save: SaveToFile gibt 0 zurück und LastErrorCode wird zu 602, dem Wert von PDFLIB_ERROR_VERSION_COMPLIANCE

Save-Versions-Preflight in PDFlibPas: SaveToFile prüft jedes Objekt gegen eine feste Tabelle von Feature-Regeln, jede trägt eine Mindest-PDF-Version und, bei Extension-Features, ein Adobe-Extension-Level, das nur PDF 1.7 mit passendem /Extensions-Eintrag erfüllen kann, und der erste Match, den das Ziel nicht erfüllen kann, stoppt den Save mit Fehler 602
GetInformation-Keys 311, 312 und 313 machen aus einer Verweigerung eine Diagnose und melden die geforderte Version, das auslösende Feature und das festgenagelte Ziel, sodass der Aufrufer die Datei oder die Regel reparieren kann, statt zu raten

Ein Fehlercode allein sagt nicht, was zu fixen ist. Drei GetInformation-Keys tragen den Rest: 311 ist die Version, die das verletzende Feature braucht (etwa 1.7 ExtensionLevel 3), 312 ist der Name des Features, und 313 ist die Version, auf die das Save-Ziel festgenagelt ist, leer, wenn nichts genagelt ist

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: geforderte Version, 312: auslösendes Feature,
        // 313: Version, auf die das Save-Ziel festgenagelt ist ('' wenn offen)
        Writeln('Needs ', Pdf.GetInformation(311),
          ' for ', Pdf.GetInformation(312),
          ', locked at [', Pdf.GetInformation(313), ']');
  finally
    Pdf.Free;
  end;
end;

Für Messinhalte hält die Tabelle drei Regeln und bewusst keine Regel auf den nackten /Measure-Key. Eine Seite mit /VP oder /UserUnit braucht PDF 1.6. Ein /PtData-Dictionary braucht Extension-Level 3. Ein Measure-Dictionary braucht Extension-Level 3, wenn es /Subtype /GEO sagt oder eines von /GCS, /DCS, /GPTS, /LPTS oder /PDU enthält. Ein Viewport, dessen Measure-Dictionary /Subtype weglässt, und einer, der /RL ausschreibt, bestehen also beide bei 1.6, während dieselbe Seite bei 1.5 wegen /VP abgewiesen wird. Kommt ein /GPTS-Array dazu, wird die Antwort 1.7 ExtensionLevel 3

Der Inhaltstest ist bewusst konservativ. Ein rectilinear-Dictionary, das zusätzlich ein verirrtes /GCS oder /PDU trägt, wird als geodätisch behandelt. Wir verweigern diese Datei lieber, als sie durchzuwinken: Diese Keys bedeuten nichts im rectilinear-Modell, wer sie also schrieb, war entweder ein Geodaten-Tool oder etwas, das die Datei beschädigt hat, und ein GEO-bewusster Viewer versucht durchaus, sie zu interpretieren. Der Loader ist in der anderen Richtung ebenso sorgfältig. Sein Feature-Scan kann die Versionsnummer des Dokuments auf 1.7 heben, aber er schreibt nie im Namen einer Eingabedatei eine /Extensions-Deklaration — eine Extension zu deklarieren ist eine Aussage über den Inhalt, und nur Ihr Code oder die Authoring-Aufrufe sollten sie machen

Die Viewports einer Zeichnung vor dem Speichern prüfen

Nennt 312 ein geodätisches Feature und Sie sind sicher, dass die Datei eine schlichte Zeichnung ist, schauen Sie selbst in die Measure-Dictionarys. Die öffentlichen Viewport-Aufrufe genügen. GetMeasureDictCoordinateSystem liest /Subtype und gibt 1 für RL zurück, 2 für GEO, und 0, wenn der Eintrag fehlt — was laut §12.9 ebenfalls rectilinear bedeutet. Die Punkt-Array-Zähler verraten, ob überhaupt Georeferenzierungsdaten vorhanden sind

// Jedes Measure-Dictionary im geladenen Dokument melden.
// Alle Aufrufe hier lesen nur; keiner fügt der Datei Keys hinzu.
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 = weggelassen (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;

Zwei Aufrufe fehlen in dieser Schleife mit Absicht: GetMeasureDictGCSDict und GetMeasureDictDCSDict. Die beiden sind Get-or-Create-Accessoren, gebaut für Authoring. An einem Measure-Dictionary ohne /GCS-Eintrag fügt GetMeasureDictGCSDict einen leeren hinzu und deklariert Extension-Level 3 für das Dokument. Als Sonde auf einem rectilinearen Grundriss gerufen, enthält das Dictionary jetzt wirklich ein /GCS; ein darauf folgender, auf 1.6 festgenagelter Save wird korrekt abgewiesen, und zwar wegen eines Inhalts, den Ihre Diagnose gerade geschrieben hat. Wer wissen will, ob ein Koordinatensystem existiert, bekommen das Subtype und die Punkt-Array-Zähler, ohne Seiteneffekte

Die Ausgabe bei PDF 1.6 festnageln

Eine festgenagelte Version hält die Ausgabe ehrlich, wenn Ihre Konsumenten eine Decke haben. TPDFlib.LockSaveVersion akzeptiert die Strings '1.0' bis '1.7' und gibt für alles andere 0 zurück. Sie fixiert die Dokumentversion und hält Writer-seitige Aufrufe davon ab, sie eigenmächtig anzuheben, doch der Save-Preflight läuft weiterhin gegen den festgenagelten Wert. Eine auf 1.6 festgenagelte CAD-Zeichnung speichert sauber. Eine echte GeoPDF auf 1.6 bekommt 602, und das ist die Antwort, die Sie wollen, denn die Alternative ist eine Datei, deren Header weniger verspricht, als ihr Inhalt braucht

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
      // Echter Inhalt über 1.6, etwa ein GEO-Measure-Dictionary
      raise Exception.CreateFmt('Locked at 1.6 but %s needs %s',
        [string(Pdf.GetInformation(312)), string(Pdf.GetInformation(311))]);
finally
  Pdf.UnlockSaveVersion;
end;

Lesen Sie 311 und 312, bevor Sie die Version anfassen. Ist das Feature echter Geodateninhalt, haben Sie zwei ehrliche Optionen: das Dokument auf 1.7 offen lassen mit deklarierter Extension, oder die Geodaten aus der Kopie entfernen, die an einen reinen-1.6-Konsumenten geht. Die Sperre nur anzuheben, um den Fehler loszuwerden, ist die zu vermeidende Option. Sie verdeckt die eigentliche Frage — kann der downstream-Reader lesen, was Sie ausliefern? Bauen Sie Geodateninhalt über die API, deklarieren die SetMeasureDict*-Setter wie SetMeasureDictCoordinateSystem und SetMeasureDictGPTSCount Extension-Level 3 selbst, authoring-erzeugte Dateien kommen also bereits gelabelt zum Preflight. Dieselbe Gewohnheit begrenzter, beleggestützter Prüfungen zeigt der Artikel zum PDF/E-1-Author-Mode-Preflight für Engineering-Dokumente, wo CAD-Zeichnungen auf einen Konformitätsstandard treffen statt auf eine Versionsnummer

Warum der Regel-Scan die Tabelle an Ort und Stelle liest

Die Kosten des Preflights liegen grob bei Objektknoten mal Regeln, und die Knotenzahl schließt jede Zahl und jeden Namen in jedem Array ein. Das macht den Overhead pro Regel wichtiger, als er aussieht. Jede Regel in der Tabelle ist ein Record mit zwei AnsiString-Feldern, dem Match-String und dem Feature-Namen. Einen solchen Record in eine lokale Variable zu kopieren bringt Delphi oder FPC dazu, zwei Referenzzähler zu justieren und freizugeben, was die lokale vorher hielt. Pro Regel pro Knoten einmal ausgeführt heißt das: Man zahlt die String-Buchhaltung sogar für Regeln, die auf die Zielversion nicht zutreffen und in der allernächsten Zeile weggeworfen werden

Regel-Scan in PDFlibPas: Ein Record mit String-Feldern zu kopieren justiert bei jedem Besuch Referenzzähler, während die Übergabe des read-only-Tabelleneintrags als const-Parameter ihn an Ort und Stelle liest, ohne die Strings anzufassen
Die read-only-Tabelle an Ort und Stelle zu lesen nimmt die Referenzzähler-Arbeit aus dem Regel-Matching-Pfad, während ein voller Save weiterhin für Feature-Erkennung, Objekt-Dekodierung und Serialisierung zahlt

Die Regeltabelle wird einmal bei der Unit-Initialisierung gefüllt und danach nie verändert, der Scan reicht Tabelleneinträge seinem Matching-Code also über const-Record-Parameter weiter und liest sie, wo sie liegen. Ein const-Record-Parameter wird per Referenz übergeben, das Testen einer Regel fasst also keines der beiden String-Felder an, und die Referenzzähler bleiben, wo sie waren. Die Wirkung konzentriert sich auf den Regel-Matching-Pfad. Ein echter Save dekodiert außerdem Objekte, fährt verzögerte Feature-Erkennung und serialisiert die Ausgabe, und diese Stufen sind unberührt davon, wie Regeln übergeben werden. Regelreihenfolge, Schwellen und die Erstverletzung-Diagnose in 311 und 312 hängen nicht davon ab, und kein Regelergebnis wird zwischen Saves gecached, ein Dokument, das Sie zweimal speichern, wird also beide Male vollständig geprüft

Dasselbe Muster lohnt einen Blick im eigenen Delphi-Code, wo immer eine Schleife über eine Tabelle von Records läuft, die Strings, Interfaces oder dynamische Arrays enthalten:

type
  TMyRule = record
    Name: string;      // verwaltetes Feld: Kopien kosten Referenzzähler-Arbeit
    Threshold: Integer;
  end;

// Eigener Code des Lesers, als Muster gezeigt
function Applies(const Rule: TMyRule; Value: Integer): Boolean;
begin
  Result := Value >= Rule.Threshold;  // const: keine Kopie, kein Referenzzähler-Verkehr
end;

// Im Hot Loop den Tabelleneintrag direkt übergeben, statt ihn
// zuerst einer lokalen Record-Variable zuzuweisen
for I := 0 to High(Rules) do
  if Applies(Rules[I], V) then
    Inc(Hits);

Mess-Viewports, geodätische Dictionarys, Versions-Locking und die Save-Zeit-Konformitätsprüfung shippen alle in der PDF Library for Delphi, verfügbar für Delphi-, C++Builder- und Lazarus-Projekte