Teknik Makale

Delphi'de PDF Yer İmi ve Annotation Action'larını Okuma

Yukarı akıştan gelen bir PDF klasörünü devralıyorsunuz ve görev önemsiz görünüyor: bana hangi yer imlerinin harici URL'ye atladığını, hangilerinin JavaScript çalıştırdığını ve iç olanların gerçekte nereye indiğini söyle. Sonra API başvurusunu açıyorsunuz ve kütüphanenin bu action'ların her birini oluşturabildiğini, fakat geri okumak için hiçbir şey sunmadığını görüyorsunuz. Bu asimetri PDF araçlarında her yerdedir. https://example.com açan yer imi yazmak tek satırdır; mevcut bir yer imine "ne yapıyorsun ve hangi hedefe gidiyorsun" diye sormak ise çoğunlukla ham nesne ağacını /A, /S, /Dest ve neredeyse kimsenin ilk seferde doğru yapamadığı fit-type varyantlarının dallanması üzerinden elle yürümek demektir

PDFlibPas, Delphi ve C++Builder için yerel Object Pascal PDF kütüphanesidir ve uzun süre aynı boşluğa sahipti: zengin yazma tarafı ayarlayıcıları, ama size yalnızca çıplak bir TPDFObject döndüren ve geri kalanını mağara keşfi gibi size bırakan getter'lar. v3.77.0 sürümü bunu, action türünü, action yükünü ve hedef geometrisini düz kayıtlar olarak bildiren küçük typed introspection çağrıları kümesiyle kısmen kapattı. Bu makale, bu çağrıların ISO 32000-1 action ve destination modeline nasıl eşlendiğini ve bu kodun elle yazılmış sürümlerini sessizce yanlış yapan üç somut tuzağı anlatır

Action okumak neden yazmaktan zordur

PDF içindeki action, alt türünü adlandıran /S anahtarına sahip bir sözlüktür: GoTo, GoToR, URI, Launch, Named, JavaScript ve nadiren karşılaşılan daha uzun bir kuyruk (ISO 32000-1 §12.6.4). Sorun şudur: yük her alt tür için farklı anahtarda yaşar ve tekdüze bir "hedefi ver" alanı yoktur. URI action'ı adresini /URI içinde tutar. GoToR veya Launch action'ı dosya tanımını /F içinde tutar. JavaScript action'ı betiğini /JS içinde tutar; bu, dizge de olabilir stream de. GoTo action'ı ise kendi başına hiç yük taşımaz; hedefi, /D üzerinde asılı duran bir destination'dır ve sonra bunu ayrıca çözmeniz gerekir

Action yazarken türünü baştan biliyorsunuzdur, dolayısıyla bunların hiçbiri önemli değildir. Okurken önce /S üzerine dallanmanız, sonra doğru anahtara girmeniz ve aynı mantıksal kavramın — "bu action'ın işaret ettiği şey" — üç uyumsuz yolla kodlandığını yönetmeniz gerekir. Typed getter'ların yuttuğu dallanma tam olarak budur. GetOutlineActionInfo ile GetAnnotActionInfo, ikisi de bir TPDFlibActionInfo kaydı döndürür:

type
  TPDFlibActionKind = (akNone, akGoTo, akGoToR, akURI,
                       akLaunch, akNamed, akJavaScript);

  TPDFlibActionInfo = record
    Kind: TPDFlibActionKind;
    URI: AnsiString;          // populated for akURI
    JavaScript: WideString;   // populated for akJavaScript
    FileName: AnsiString;     // populated for akGoToR / akLaunch
    OpenInNewWindow: Boolean; // akGoToR / akLaunch
  end;

Kayıt, hangi alanların anlamlı olduğunu Kind üzerinden size söyler. Kind, akURI olarak dönerse URI alanını okuyup gerisini görmezden gelin. akGoTo olarak dönerse yük alanlarının hiçbiri uygulanmaz ve aşağıda anlatılan destination tarafına geçersiniz. akNone ise, yer iminde ya da annotation'da hiç action olmadığında verilen dürüst cevaptır; anlamını tahmin edeceğiniz bir sıfır değildir

Yer imini bulmak için outline ağacında gezinme

Bir yer imini inceleyebilmek için önce tanıtıcısını edinmeniz gerekir. PDFlibPas outline düğümlerini tamsayı kimliğiyle tanımlar ve FindOutlineByTitle, görünen metin üzerinden birini bulur; aramanın ne kadar derine gideceğini açıkça kontrol edersiniz:

type
  TPDFlibOutlineSearchDepth =
    (osdSiblingsOnly, osdChildrenOnly, osdFullSubTree);

function FindOutlineByTitle(const Title: WideString;
  StartOutlineID: Integer;
  Depth: TPDFlibOutlineSearchDepth): Integer;

Burada üzerinde durmaya değer bölüm Depth bağımsız değişkenidir. osdSiblingsOnly, başlangıç düğümünün seviyesindeki sibling zincirini tarar ve durur; eş düzey yer imini bulur ama o eş düzeyin çocuklarına asla inmez. osdChildrenOnly, başlangıç düğümünün anlık çocuklarına bir seviye aşağı bakar. osdFullSubTree ise tüm dal boyunca özyinelemeli iner. Yanlış olanı seçmek sessiz kaçırmadır, hata değildir: iki seviye derinde yaşayan başlık için sibling-only arama basitçe sıfır döndürür ve siz yer iminin hiç olmadığını sanırsınız. Belge kökünden aramak için başlangıç kimliği olarak GetFirstOutline geçin

var
  Lib: TPDFlib;
  FoundID: Integer;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('report.pdf', '') = 1 then
    begin
      // Search the whole tree from the root for a nested bookmark.
      FoundID := Lib.FindOutlineByTitle('Appendix B',
        Lib.GetFirstOutline, osdFullSubTree);
      if FoundID <> 0 then
        // FoundID is now a handle you can pass to the action and
        // destination getters below.
        ;
    end;
  finally
    Lib.Free;
  end;
end;

Eşleştirme, tam başlık dizgesi üzerinde WideString olarak yapılır; bu yüzden büyük-küçük harf duyarlıdır ve Unicode metne belgede saklandığı biçimiyle saygı gösterir. Kaynak PDF'leriniz tutarsız üreticilerden geliyorsa, aradığınız başlığı belgenin sakladığı biçimle aynı şekilde normalize edin; yoksa hayalet kaçırmaların peşinden koşarsınız

Yer iminin action ve hedefini çözme

Tanıtıcı elinizdeyken GetOutlineActionInfo typed görünümü verir. Desen şudur: çağırın, Kind üzerine switch yapın ve o türün doldurduğu alanı okuyun

var
  Info: TPDFlibActionInfo;
begin
  Info := Lib.GetOutlineActionInfo(FoundID);
  case Info.Kind of
    akURI:
      Writeln('Opens URL: ', Info.URI);
    akGoToR, akLaunch:
      Writeln('Opens file: ', Info.FileName,
        ' (new window: ', Info.OpenInNewWindow, ')');
    akJavaScript:
      Writeln('Runs script: ', string(Info.JavaScript));
    akGoTo:
      Writeln('Jumps within this document');  // see destination below
    akNamed:
      Writeln('Named action (NextPage, Print, etc.)');
    akNone:
      Writeln('Bookmark has no action');
  end;
end;

İlk gerçek tuzak burada yaşar ve uygulama sırasında test geri bildiriminin ortaya çıkardığı nokta budur. Eski bir getter olan GetActionURL vardır ve URI action'ını okumak için ona uzanmak, dışarıdan bakınca bariz görünen hatadır. GetActionURL, dosya tanımını /F anahtarı üzerinden çözer. Bu, hedefi gerçekten dosya olan GoToR ve Launch için doğrudur, ama URI action'ı için bütünüyle yanlış anahtardır. URI action'ının adresi, action'ın kendi /URI anahtarı üzerindeki düz dizgedir; file spec değildir. URI action'ını file-spec yoluna verirseniz boş ya da anlamsız sonuç alırsınız. Typed getter bunu dahili olarak /URI değerini akURI için doğrudan okuyarak ve file-spec çözümleyicisini yalnızca akGoToR ile akLaunch için çağırarak çözer; elle yazılmış sürümlerin bulanıklaştırma eğiliminde olduğu ayrım da tam budur

Destination fit türleri ve arkasındaki geometri

Bir akGoTo action'ı "bu belge içinde gez" anlamına gelir; ama size nereye veya nasıl sorularının cevabını vermez. Bu, destination'ın işidir ve destination'lar insanların beklediğinden daha çok nüans taşır. PDF destination'ı yalnızca sayfa numarası değildir; aynı zamanda görüntüleyicinin o sayfayı nasıl çerçevelemesi gerektiğini söyleyen "fit" tanımıyla birlikte gelen sayfadır (ISO 32000-1 §12.3.2.2). GetOutlineDestinationInfo bunu bir kayıt olarak döndürür:

type
  TPDFlibDestinationKind = (dkNone, dkXYZ, dkFit, dkFitH,
    dkFitV, dkFitR, dkFitB, dkFitBH, dkFitBV);

  TPDFlibDestinationInfo = record
    Kind: TPDFlibDestinationKind;
    Page: Integer;   // 1-based; 0 when unresolved
    Left, Top, Right, Bottom, Zoom: Double;
  end;

Sekiz fit türü farklı çerçeveleme sorularını cevaplar. dkXYZ, belirli bir noktayı açık zoom ile sol üst köşeye yerleştirir; dolayısıyla Left, Top ve Zoom alanlarını kullanır. dkFit, tüm sayfayı pencereye sığdırır ve koordinatları yok sayar. dkFitH ile dkFitV, sayfa genişliğini veya yüksekliğini tek ilgili koordinatla (üst kenar ya da sol kenar) sığdırır. İlginç olan dkFitR türüdür: belirtilmiş dikdörtgeni sığdırır; bu yüzden dört kenarın hepsi önemlidir. dkFitB* ailesi de aynı işleri, tüm sayfa yerine görünen içeriğin bounding box'ına göre yapar. Her tür için hangi alanların canlı olduğunu bilmek, destination'ı doğru okumakla tesadüfen sıfır olan çöp koordinatları yazdırmak arasındaki farktır

PDF reader bookmark navigation panel showing a nested outline tree
Bu gezinti panelindeki her yer imi bir action'a ve iç atlamalarda kendi fit türü ile koordinatlarını taşıyan bir destination'a çözülür

Kaputun altında uygulama, bilinmeye değer bilinçli bir hizalamaya yaslanır; çünkü eşlemenin neden güvenilir olduğunu açıklar. Dahili GetDestType yordamı sekiz fit türü için 1..8 arasında tamsayı döndürür ve sıralama tam olarak XYZ/Fit/FitH/FitV/FitR/FitB/FitBH/FitBV biçimindedir. TPDFlibDestinationKind, sıra numaraları bire bir hizalansın diye tanımlanmıştır: dkXYZ sıra numarası 1'dir, dkFitBV sıra numarası 8'dir, dkNone ise sıfırda durur. Bu yüzden dönüşüm, aralığa karşı korumalı doğrudan ordinal cast'tir; enum büyüdükçe senkron dışına çıkabilecek lookup table değildir. Küçük ayrıntı gibi görünür ama saf yolla yapılırsa biri enum sırasını değiştirdiği ilk anda off-by-one hatasına dönüşen türden şeydir

var
  Dest: TPDFlibDestinationInfo;
begin
  Dest := Lib.GetOutlineDestinationInfo(FoundID);
  if Dest.Page = 0 then
    Exit;  // destination did not resolve
  case Dest.Kind of
    dkXYZ:
      Writeln(Format('Page %d at (%.0f, %.0f), zoom %.2f',
        [Dest.Page, Dest.Left, Dest.Top, Dest.Zoom]));
    dkFitR:
      Writeln(Format('Page %d, rect L%.0f T%.0f R%.0f B%.0f',
        [Dest.Page, Dest.Left, Dest.Top, Dest.Right, Dest.Bottom]));
    dkFit, dkFitB:
      Writeln(Format('Page %d, fit whole page', [Dest.Page]));
  else
    Writeln(Format('Page %d, fit kind %d',
      [Dest.Page, Ord(Dest.Kind)]));
  end;
end;

Sıfır değerli bir Page, destination'ın çözülemediğinin işaretidir; genelde action destination taşımadığı ya da named destination bulunamadığı için olur. Herhangi bir koordinata güvenmeden önce bunu kontrol edin. GetOutlineDestinationInfo yordamının, destination'ın yaşayabildiği iki yere de baktığını da not edin: doğrudan yer iminin /Dest alanına ve gömülü GoTo action'ının içindeki /D alanına. Üreticinin hangi biçimi kullandığını bilmeniz gerekmez

Annotation action'ları ve SelectPage tuzağı

Link annotation'ları, yer imleriyle aynı biçimde action taşır ve GetAnnotActionInfo aynı TPDFlibActionInfo kaydını, aynı tür-sonra-yük düzeniyle döndürür. Ancak burada outline'larda olmayan durumlu bir tuzak vardır ve bu üçüncü tuzaktır

Annotation'lar sayfalara aittir ve PDFlibPas mevcut sayfanın annotation'larını, yalnızca o sayfayı seçtikten sonra geçerli olan durum üzerinden açığa çıkarır. Önce GetAnnotActionInfo çağırmadan SelectPage(N) çağırırsanız annotation tanıtıcısı sıfırdır; çağrı akNone döndürür ve siz yanlışlıkla sayfada action taşıyan annotation olmadığını sanırsınız. Çözüm tek satırdır ama sayfalar üzerinde döngü kurarken unutması kolaydır:

var
  P: Integer;
  Info: TPDFlibActionInfo;
begin
  for P := 1 to Lib.PageCount do
  begin
    Lib.SelectPage(P);   // mandatory before touching annotations
    // GetAnnotActionID(1) <> 0 is the reliable "has an action"
    // test. CheckPageAnnots returns a boolean-style flag, not a
    // count, so it is the weaker signal here.
    if Lib.GetAnnotActionID(1) <> 0 then
    begin
      Info := Lib.GetAnnotActionInfo(1);
      if Info.Kind = akURI then
        Writeln(Format('Page %d link -> %s', [P, Info.URI]));
    end;
  end;
end;

Bu döngüde iki şey bilinçli yapılmıştır. Birincisi, her yinelemede herhangi bir annotation erişiminden önce SelectPage(P) gelir; sayfa başına annotation durumu taşınmaz. İkincisi, varlık testi GetAnnotActionID(1) <> 0 kullanır, CheckPageAnnots değil. İkincisi varlığı boolean-benzeri bayrak olarak rapor eder; dolayısıyla sıfırdan farklı action ID, "ilk annotation var mı ve okuyabileceğim bir action taşıyor mu" sorusunu sormanın daha kesin yoludur. İşaret etmeye değer bir başka incelik daha var: annotation'lar için JavaScript action'ının betiği doğrudan /JS içinden okunur; betik orada stream olarak saklanmışsa stream çözümlenir, değilse dizge okunur. Böylece iki yaygın kodlamada da ayakta kalır

Okuma tarafı içgörüsünün yeri

Bu getter'lar bilinçli olarak dardır. Kütüphanenin var olan tamsayı tanıtıcılı action ve destination katmanlarının üzerine kurulmuş saf okumalardır; bu yüzden hiçbir yazma yoluna dokunmazlar ve üzerinde aynı anda düzenleme yaptığınız belgeler için ek risk yaratmazlar. Dosyada ne varsa onu rapor ederler; herhangi bir ilkeye göre doğrulamaz ve hiçbir şeyi yeniden yazmazlar. Hedefiniz bunun tersi, yani bu action'ları en başta taşıyan yer imleri ve link annotation'ları inşa etmekse, iş yazma tarafındadır ve Delphi'de interactive form action'ları ve JavaScript üzerine eşlikçi yazı bunları oluşturmayı adım adım anlatır. PDF'in gezinti grafiği yerine görünür ve yapısal içeriğini dışarı almak için PDFlibPas ile metin, görsel ve font çıkarma makalesine bakın

Akılda tutulması gereken dürüst sınır şudur: introspection yalnızca üreticinin gerçekten yazdığı şeyi görür. Üreticinin action'ı bozuk bıraktığı yer imi ya da hiç tanımlanmamış named target'a işaret eden destination, istisna yerine akNone ya da sıfır sayfa olarak yüzeye çıkar. Güvenilmeyen dosyaları denetleyen okuma API'si için doğru davranış budur; ama kodunuz bu sıfır sonuçları iyi biçimlenmiş giriş garantisi değil, "yok veya çözülemedi" anlamında ele almalıdır. Burada gösterilen typed action ve destination introspection, Delphi ve C++Builder için yerel PDF kütüphanesi PDFlibPas ürününün parçasıdır