Teknik Makale

Delphi'de İptal Edilebilir Aşamalı PDF Oluşturma (PDFium)

Çoğu PDF sayfası birkaç milisaniye içinde taranır (rasterise) ve siz bunu hiç düşünmezsiniz. Sonra bir kullanıcı bir A1 mühendislik çizimini, on binlerce vektör vuruşuyla (vector strokes) dolu bir sayfayı veya saydamlık grupları (transparency groups) ve yumuşak maskelerle dolu bir posteri açar ve bunu çizen (paint) tek bir çağrı iki veya üç saniye sürer. Bu çağrı kullanıcı arayüzü (UI) iş parçacığında (thread) çalışırsa, pencere yeniden boyamayı (repaint) durdurur, başlık çubuğu grileşir ve işletim sistemi uygulamayı sonlandırmayı teklif eder. İş meşrudur. Sayfanın gerçekten o kadar zamana ihtiyacı vardır. Kusur, oluşturmanın (render), nefes almak için hiçbir yolu ve durmak için hiçbir yolu olmayan, bölünemez ve engelleyici (blocking) tek bir çağrı olmasıdır

Bu makale tam olarak bu iki sorundan biri hakkındadır: UI'yi dondurmadan uzun, tek sayfalık bir oluşturmayı (render) iptal etmek. Kullanıcı sonraki sayfaya tıkladı veya yakınlaştırdı veya belgeyi kapattı ve devam etmekte olan oluşturma, sonuna kadar çalışmak yerine ilk fırsatta sona ermesi gereken boşa harcanmış bir iştir. Zaten taranmış olanı önbelleğe (cache) alarak kaydırmayı ve yakınlaştırmayı düzeltmek (smoothing), sonunda bağlantısı verilen eşlik eden makalede kapsanan, kendi tasarımına sahip ayrı bir endişedir. Burada tek soru, aşamalı (progressive) bir oluşturmanın iptal isteğine nasıl hızlı ve temiz bir şekilde yanıt vermesinin sağlanacağıdır

PDFium'un halihazırda sunduğu aşamalı oluşturma API'si

PDFium, sorunun donma yarısını öngördü. Tek seferlik FPDF_RenderPageBitmap'in yanı sıra, bir sayfayı iş parçalarına bölen aşamalı (progressive) bir varyant sunar. Hedef bir bit eşleme (bitmap) karşı oluşturmayı kurmak için FPDF_RenderPageBitmap_Start yöntemini bir kez, ardından art arda FPDF_RenderPage_Continue yöntemini çağırırsınız. Her bir Continue sınırlı bir dilimi tarar (rasterise) ve bir durum döndürür. FPDF_RENDER_TOBECONTINUED yapılacak daha çok şey olduğu anlamına gelir, FPDF_RENDER_DONE sayfanın bittiği anlamına gelir ve FPDF_RENDER_FAILED bir hatada durduğu anlamına gelir. Döngü bittiğinde, sayfa başı aşamalı durumunu serbest bırakmak için FPDF_RenderPage_Close yöntemini çağırırsınız. Dilimler arasında kontrol kodunuza döndüğünden, mesajları pompalayabilir (pump messages), bir ilerleme göstergesini güncelleyebilir veya işin hala istenip istenmediğini kontrol edebilirsiniz

PDFium'un ne zaman geri dönüleceğine (yield) karar vermek için sağladığı mekanizma, IFSDK_PAUSE adlı bir geri çağırma (callback) yapısıdır. Onu Start'a ve her Continue'ya verirsiniz. Her parçadan sonra PDFium kendi NeedToPauseNow işlev işaretçisini (function pointer) çağırır ve bu sıfır olmayan bir değer döndürürse, geçerli Continue erken durur ve FPDF_RENDER_TOBECONTINUED ile kontrolü geri verir. Yapı ayrıca 1'e ayarlanması gereken bir version alanını ve PDFium'un asla dokunmadığı ve dokunulmadan (untouched) geçtiği serbest biçimli bir user işaretçisini (pointer) taşır. Bu dokunulmamış işaretçi, izleyen tasarımın tüm menteşesidir

Duraklatmayı iptal olarak yeniden amaçlandırmak

NeedToPauseNow'un asıl amacı zaman dilimlemedir (time-slicing). Kare (frame) bütçeniz harcandığında sıfırdan farklı dönün, oluşturmaya devam etmek için sıfır dönün ve PDFium aynı oluşturmayı sürdürmeden önce başka bir şey yapabilmeniz için duraklatır (pause). PDFium Bileşeni aynı sinyali farklı bir fiil için yeniden kullanır. "Duraklatıp devam etmenize izin vermeli miyim" yanıtı yerine, geri çağırma "bu iş iptal edildi mi" yanıtını verir. İkisi, döngünün bayrağı gördüğünde yaptığı şey nedeniyle birbirleriyle temiz bir şekilde eşleşir. Gerçek bir duraklatma daha sonraki bir Continue bekler; bir iptal ise beklemez. Çağıran döngü jetonun (token) iptal edildiğini gözlemlediğinde, oluşturma bağlamını (render context) kapatır ve Continue'yu bir daha asla çağırmaz, bu nedenle PDFium'un "bu parçayı durdur" olarak okuduğu aynı sıfır olmayan dönüş, aslında "temelli durdur" haline gelir

İptal işlemi, programın başka bir bölümü oluşturmanın durmasını istediğinde IsCancelled özelliği false'tan true'ya dönen bir arabirim (interface) olan IPdfCancellationToken aracılığıyla ifade edilir. Bu Pascal arabirimi ile PDFium'un C geri çağırması (callback) arasındaki köprü tek bir işaretçidir. Jetonun arabirim başvurusu IFSDK_PAUSE.user içine yazılır ve statik bir cdecl geri çağırması onu geri okur ve sorgular. Bu, bir C kitaplığının Pascal'a geri çağırmasına izin vermenin klasik sorunudur: PDFium, Pascal nesneleri veya Self hakkında hiçbir şey bilmeyen çıplak bir işlev işaretçisi (bare function pointer) depoladığı ve çağırdığı için geri çağırmanın bir yöntem (method) değil, C çağrı kuralına sahip düz bir işlev olması gerekir

type
  TPdfProgressivePause = record
    Pause: IFSDK_PAUSE;            // PDFium reads this; .user holds the token
    Token: IPdfCancellationToken; // strong ref keeps the token alive
  end;

function ProgressivePauseCallback(pThis: PIFSDK_PAUSE): FPDF_BOOL; cdecl;
var
  Token: IPdfCancellationToken;
begin
  Result := 0;
  if (pThis = nil) or (pThis^.user = nil) then
    Exit;
  Token := IPdfCancellationToken(pThis^.user);
  if Token.IsCancelled then
    Result := 1; // non-zero: PDFium stops this chunk
end;

Geri çağırma, pThis^.user değerini arabirim türüne (interface type) geri dönüştürerek (casting) jetonu kurtarır ve IsCancelled değerini okur. İçindeki hiçbir şey tahsis (allocate), kilitleme (lock) veya engelleme (block) yapmaz, bu önemlidir çünkü PDFium onu her parçadan sonra oluşturma iş parçacığında (rendering thread) çağırır ve burada yapılan herhangi bir iş oluşturmanın kendi maliyetine eklenir. Bir nil yapısına veya nil user alanına karşı koruma, aynı işlevin kendisine hiçbir zaman gerçek bir jeton verilmemiş bir oluşturmada (render) bile kurulmasının (install) güvenli olduğu anlamına gelir

Jetonu döngü boyunca canlı tutma

Bir arabirim işaretçisini (interface pointer) ham bir Pointer üzerinden geçirip geri dönüştürmek, yaşam süresi (lifetime) hatalarının doğduğu yerdir. Delphi'de bir IInterface referans sayımlıdır (reference counted) ve sayı yalnızca derleyici arabirim tipli (interface-typed) bir değişkenin atandığını gördüğünde hareket eder. Jetonu yalnızca IFSDK_PAUSE.user içinde çıplak bir işaretçi olarak saklamak, onu referans sayacından tamamen gizler. Continue döngüsü hala çalışırken o jetona olan tek diğer başvuru kapsam (scope) dışına çıksaydı, nesne geri çağırmanın altında serbest bırakılırdı (freed) ve bir sonraki parça sallanan bir işaretçiyi (dangling pointer) kurcalardı (dereference)

İşte bu yüzden tanımlayıcı (descriptor) bir değil iki şeyi tutan bir kayıttır (record). Pause alanı, PDFium'un okuduğu yapıdır (struct). Token alanı, derleyicinin saydığı gerçek bir arabirim tipli (interface-typed) referanstır ve jetonu kayıt yaşadığı sürece bellekte sabitlemekten başka bir nedenle var olmaz. Kayıt, oluşturma rutininin yığınında (stack) yerel bir değişkendir, bu nedenle döngünün tüm süresi boyunca geçerli kalır ve yalnızca rutin çıktığında parçalanır (torn down). user içindeki çıplak işaretçi ve Token içindeki sayılan referans (counted reference) aynı nesneyi adlandırır; biri PDFium'un okuyabildiği şeydir, diğeri ise nesnenin toplanmasını (collected) engelleyen şeydir

var
  Pause: TPdfProgressivePause;
  EffectiveToken: IPdfCancellationToken;
begin
  // ... choose EffectiveToken ...

  // Strong ref first, then publish the same object to PDFium via .user.
  Pause.Token := EffectiveToken;
  Pause.Pause.version := 1;
  Pause.Pause.NeedToPauseNow := ProgressivePauseCallback;
  Pause.Pause.user := Pointer(EffectiveToken);

Döngü nasıl biterse bitsin oluşturma bağlamını kapatmak

FPDF_RenderPageBitmap_Start yöntemine yapılan her çağrı, PDFium'un sayfayla ilişkilendirdiği aşamalı (progressive) durumu tahsis eder ve bu durum yalnızca FPDF_RenderPage_Close tarafından serbest bırakılır. Sürücü (drive) döngüsünden çıkmanın üç yolu vardır. Sayfa biter ve son durum FPDF_RENDER_DONE olur. Jeton tetiklenir (trips) ve döngü erken çıkarak iptali bildirir. Bir şey başarısız olur ve durum FPDF_RENDER_FAILED olur. Üçü de Close işlevini çağırmalıdır ve iptal yolunun yanlış yapılması en kolayıdır, çünkü "iptali gör, çık (break out)" doğal şekli, çıkış yolunda temizliği (cleanup) atlama eğilimindedir. Close yöntemini ulaşılmamış bırakmak sayfa başı durumunu sızdırır (leaks) ve kullanıcının art arda oluşturmayı iptal etmesine izin veren bir görüntüleyici, iptal edilen her sayfada bu sızıntıyı biriktirir

Sağlam şekil, döngüyü ve sonuç sınıflandırmasını (result classification) bir try içine ve FPDF_RenderPage_Close ifadesini eşleşen finally içine koyar. Hedef bit eşlemi de aynı blokta yok edilir. İptal işlemi, döngüyü erken bir Exit ile bırakabilir ve finally yine de çalışır, böylece aşamalı (progressive) durumu serbest bırakan (frees) tam olarak tek bir yer vardır ve bu atlanamaz

Status := FPDF_RenderPageBitmap_Start(PdfBmp, FPage, Left, Top,
  Width, Height, Ord(Rotation), EncodeRenderOptions(Options), Pause.Pause);
try
  while Status = FPDF_RENDER_TOBECONTINUED do
  begin
    if EffectiveToken.IsCancelled then
    begin
      Result := prsCancelled;
      Exit;
    end;
    Status := FPDF_RenderPage_Continue(FPage, Pause.Pause);
  end;

  if EffectiveToken.IsCancelled then
    Result := prsCancelled
  else if Status = FPDF_RENDER_DONE then
    Result := prsDone
  else
    Result := prsFailed;
finally
  // Frees the progressive state Start allocated; mandatory on every path.
  FPDF_RenderPage_Close(FPage);
  FPDFBitmap_Destroy(PdfBmp);
end;

Döngü, her Continue'dan önce jetonu kontrol etmenin yanı sıra içindeki geri çağırmaya (callback) da dayanır. Geri çağırma, mevcut parçayı kısaltır; döngü kontrolü bir sonrakinin başlamasını durdurur. Birlikte, bir iptalin yürürlüğe girmesinin ne kadar süreceğini kabaca bir parçanın süresine (duration of one chunk) bağlarlar

Üç sonuç ve bir iptalden sonra bit eşlemin ne tuttuğu

Genel (public) giriş noktası TPdf.RenderPageProgressive'dir ve prsDone, prsCancelled veya prsFailed'den biri olan bir TPdfProgressiveStatus döndürür. Değerler, Pascal deyiminde (idiom) PDFium'un FPDF_RENDER_* sabitlerini yansıtır ancak iptal durumunu bir hata yerine birinci sınıf bir sonuç olarak (first-class result) içeri katlar

İnsanları yakalayan nokta, hedef bit eşlemin prsCancelled işleminden sonra ne içerdiğidir. Boş değildir. PDFium, parça parça aynı bit eşlem içine aşamalı olarak oluşturur, bu nedenle bir iptal döngüyü durdurduğunda, bit eşlem o ana kadar çizilmiş olan (painted) her neyse onu tutar; bu kısmi bir görüntüdür (partial image): bazı bantlar (bands) bitmiş, geri kalanı hala dolgu rengini gösterir. Bu kısmi sonucun yararlı olup olmadığı, çağırana bağlıdır. Kullanıcı başka bir yere gezindiği için (navigated) bit eşlemi çöpe atmak üzere olan bir görüntüleyici, onu basitçe yok sayabilir. Düşük maliyetli bir önizleme (preview) göstermek isteyen bir görüntüleyici ise onu tutabilir. Yapmamanız gereken şey, prsCancelled ifadesinin boş veya tanımsız bir bit eşlem ima ettiğini varsaymaktır; bu bitmemiş bir oluşturmanın gerçeğe uygun bir anlık görüntüsünü (snapshot) ima eder

var
  Bmp: TBitmap;
  Token: IPdfCancellationToken;
  Status: TPdfProgressiveStatus;
begin
  Bmp := TBitmap.Create;
  try
    // Token starts un-cancelled; flip Token.IsCancelled from elsewhere
    // (a UI action, a navigation event) to abort the render in flight.
    Status := Pdf.RenderPageProgressive(Bmp, 0, 0, PageW, PageH, Token);
    case Status of
      prsDone:      Image1.Picture.Assign(Bmp);  // fully rendered
      prsCancelled: ;                            // partial bitmap, usually discarded
      prsFailed:    ShowMessage('Render failed');
    end;
  finally
    Bmp.Free;
  end;
end;

Nil jetonu ve dalsız bir geri çağırma yolu

İptal işlemi isteğe bağlıdır (opt-in). Yalnızca mesaj pompalamanın (message-pumping) yararı için, iptal (aborting) niyeti olmadan aşamalı (progressive) oluşturma isteyen bir çağıran (caller), jeton için nil geçebilmelidir. Bunu desteklemenin naif yolu, geri çağırma ve döngü boyunca "bir jeton sağlanmışsa" denetimlerini dağıtmaktır; bu, her parçada bir dal (branch) ve hem gerçek bir jetonu hem de onun yokluğunu ele alması (handle) gereken bir geri çağırma anlamına gelir

Uygulama, çağıran hiçbir şey geçirmediğinde (passes nothing) bir tekliyi (singleton) değiştirerek bunu önler. Bir nil jetonu, IsCancelled değeri her zaman false olan bir arabirim (interface) olan PdfNoCancellationToken ile değiştirilir (swapped). Bu noktadan itibaren, geri çağırma ve döngünün her durumda sorgulayacağı bir jetonu vardır, bu nedenle ne bir nil kontrolüne ne de özel bir yola ihtiyacı vardır. Asla iptal etme (never-cancel) jetonu her zaman false yanıtını verir, geri çağırma her zaman sıfır döndürür ve oluşturma (render), iptal edilemez olanın yapacağı gibi tam olarak çalışarak tamamlanır (runs to completion). İsteğe bağlı davranış, jetonun yokluğu yerine hiçbir zaman tetiklenmeyen (never fires) bir jeton olarak modellenir ve bu da sıcak yolu (hot path) tek tip (uniform) tutar

// nil -> never-cancel singleton, so the callback path is identical
// whether or not the caller opted into cancellation.
if AToken <> nil then
  EffectiveToken := AToken
else
  EffectiveToken := PdfNoCancellationToken;

Ortaya çıkan şekil küçüktür ve yeniden ifade edilmeye değerdir (worth restating), çünkü yeniden kullanılabilir kısımdır. Bir geri çağırmayı (callback) destekleyen bir C kitaplığı, o geri çağırmaya durumu (state) aktarmanız (pass) için size tam olarak bir kanal verir: opak kullanıcı işaretçisi (opaque user pointer). Sayılı bir Pascal arabirimi başvurusunu (counted Pascal interface reference) o işaretçinin arkasına koyun, yapının (struct) yanında ikinci bir gerçek başvuruyu canlı tutun, böylece nesne çağrının ortasında toplanmasın (collected mid-call) ve arabirimi (interface) statik bir cdecl işlevinin içinde (inside a static cdecl function) geri okuyun (read back out). Tüm sürücü (drive) döngüsünü bir try ile sarın (wrap) ve yerel bağlamı (native context) finally içinde serbest bırakın (free). Aynı şablon, Pascal kodunun C bir işaretçi tutarken yaşam süresi (lifetime) kontrolünü sürdürmesi (stay in control) gereken herhangi bir aşamalı (progressive) veya geri çağırma yönlendirmeli (callback-driven) PDFium işlemine taşınır

İptal işlemi (Cancellation), yanıt veren bir görüntüleyicinin (responsive viewer) yalnızca bir yarısıdır. Diğer yarısı, önceden çizdiğiniz sayfaları yeniden oluşturmamak (not re-rendering) ve önbelleğe alınmış bit eşlemler (cached bitmaps) sunarak yakınlaştırma (zoom) ve kaydırmanın (scroll) akıcı kalmasını sağlamaktır; bu konu oluşturma önbelleğe alma ve yakınlaştırma performansı hakkındaki makalemizde ele alınmıştır. İptal edilebilir oluşturmanın (cancellable render) gezinme (navigation), seçim ve aramanın (search) yanında eksiksiz bir görüntüleyiciye (complete viewer) nasıl uyduğu hakkında daha fazla bilgi için PDFium Bileşeni ile özellik açısından zengin bir PDF görüntüleyici oluşturmaya bakın. Burada açıklanan aşamalı oluşturma, bu blogdaki diğer yerlerde (elsewhere on this blog) ele alınan yükleme, oluşturma ve form API'lerinin yanı sıra Delphi ve Lazarus için PDFium Bileşeninin bir parçası olarak gelir