Teknik Makale

Delphi'de Düzen ve Sözcük Kaydırma İçin PDF Metnini Ölçme

Bir PDF sayfasına metin koyan çağrı basittir. AddText'e bir dize (string), bir yazı tipi, bir boyut ve bir konum verirsiniz ve glifler görünür. Yapmadığı şey, çizildikten sonra o dizenin ne kadar geniş olacağını size söylememesidir ve uzun bir dizeyi birkaç satıra bölmez. Tek bir çağrı, tek bir konumda tek bir metin grubunu (run) boyar. Grup (run), sığdırmayı amaçladığınız sütundan daha genişse, basitçe kenarı aşar ve çizim çağrısındaki hiçbir şey sizi uyarmaz. Tek bir etiket yerine bir paragraf istediğiniz an, eksik parça, sayfaya işlemeden (commit) önce ölçülen, seçilen yazı tipindeki ve boyuttaki bir dizenin genişliğidir

Bu, klasik düzen (layout) sorunudur. Bir paragrafı bir sütuna kaydırmak için her bir aday satırın kelime kelime ne kadar yatay boşluk kaplayacağını bilmeniz gerekir ve bunu herhangi bir şey çizmeden önce bilmelisiniz. Sözcük kaydırma (word wrap), bir çizim çağrısının etrafına sarılmış bir ölçüm döngüsüdür ve yalnızca çizen bir bağlama (binding) size ikinci yarıyı verir. PDFium bileşenindeki metin ölçüm desteği bu boşluğu, herhangi bir sayfaya bir işaret (mark) koymadan bir dizenin oluşturulan uzantısını (rendered extent) raporlayan MeasureText ve MeasureTextWidth adlı iki işlevle kapatır

Ölçüm neden TPdf üzerinde yeni bir yöntem değil de bir sınıf yardımcısıdır (class helper)

Ölçüm desteği, TPdf sınıfına cıvatalanmış yeni yöntemler (methods) yerine, kendi birimi (unit) içinde yaşayan TPdf için bir Delphi sınıf yardımcısı (class helper) olarak gelir. Sınıf yardımcısı, mevcut bir türe bildiriminin (declaration) dışından yöntemler eklemenizi sağlayan bir dil özelliğidir. Birim (unit) kapsam (scope) içine girdiğinde, yeni yöntemler tam olarak sınıfa aitmiş gibi çağrılır, bu nedenle yardımcı bir yöntem, oluşturulacak veya etrafta geçirilecek ayrı bir nesne olmadan Pdf.MeasureTextWidth(...) olarak okunur

Bu şekilde katmanlamanın nedeni ayırmadır (separation). Çekirdek TPdf türü, hiçbir alan (field) eklenmeden ve mevcut hiçbir imzaya (signature) dokunulmadan olduğu gibi kalır, böylece hiçbir zaman düzene (layout) ihtiyaç duymayan bir proje ölçüm kodunu asla taşımaz. Buna ihtiyaç duyan bir proje, uses yan tümcesine bir birim ekler ve yöntemler aydınlanır. Yetenek, sahip olmadığınız veya rahatsız etmek istemediğiniz bir türü genişletmenin en temiz yolu olan tek bir birimin ayrıntı düzeyinde (granularity) isteğe bağlı (opt-in) hale gelir

uses
  PDFium, FPdfView, FPdfEdit,
  FPdfMeasure;   // the helper unit; brings MeasureText into scope on TPdf

// With the unit in scope the methods read as members of TPdf:
var
  W, H: Double;
begin
  Pdf.MeasureText('Subtotal', 'Helvetica', 11, W, H);
  // W and H are now the rendered width and height in PDF user units
end;

Sayfaya dokunmadan ölçüm

Ölçümün yan etkilerden (side effects) arınmış olması gerekir. Geride hiçbir şey bırakmadan bir genişlik bildirmesi gerekir, çünkü bir düzene karar verirken onu birçok kez çağırırsınız ve sayfa hiç ölçüm yapmamışsınız gibi tam olarak görünmelidir. Bunu mümkün kılan teknik, bir metin nesnesi (text object) oluşturmak, boyutunu sormak ve bir sayfaya eklenmeden önce onu çöpe atmaktır

Dizi (sequence) dört PDFium çağrısıdır. FPDFPageObj_NewTextObj, yazı tipi adı ve boyutu verildiğinde, belgeye karşı bir metin nesnesi oluşturur. FPDFText_SetText, nesnenin taşıdığı dizeyi (string) ayarlar. FPDFPageObj_GetBounds nesnenin sınırlayıcı kutusunu (bounding box) geri okur. FPDFPageObj_Destroy nesneyi serbest bırakır. Çok önemli bir şekilde, o dizideki hiçbir şey sayfa ekleme (page-insertion) API'sini çağırmaz. Nesne yalıtılmış (isolation) olarak oluşturulur, sorgulanır ve yok edilir, böylece işlev geri döndüğünde belge değişmez. Tek çıktısı sınırlayıcı kutusunun dört numarası olan tek kullanımlık (throwaway) bir probtur

Bunu yapmanın sağlam yolu budur çünkü PDFium kendi başınıza toplayabileceğiniz (sum yourself) kullanışlı bir glif başına ilerleme genişliği (per-glyph advance width) sunmaz. Glif metrikleri yazı tipi programına, kodlamaya ve PDFium'un yüzü (face) nasıl yüklediğine bağlıdır ve size bir dizedeki her karakterin ilerlemesini (advance) teslim eden hiçbir genel (public) çağrı yoktur. Gerçek bir metin nesnesinin sınırlayıcı kutusu ise, glifleri çizim için yerleştirecek olan aynı mekanizma tarafından hesaplanır, böylece bir yaklaşımdan (approximation) ziyade oluşturulan fiili (actual) uzantıyı (extent) yansıtır. Gözden çıkarılabilir tek bir nesne oluşturmak ve sınırlarını okumak, kütüphanenin verebileceği en güvenilir ölçümdür

// The shape of MeasureText, expressed against the verified PDFium calls.
// A text object is built, measured, and destroyed; no page is involved.
procedure TPdfMeasureHelper.MeasureText(const Text, Font: WString;
  FontSize: Single; out Width, Height: Double);
var
  TextObject: FPDF_PAGEOBJECT;
  L, B, R, T: Single;
begin
  Width  := 0;
  Height := 0;
  if Self.Document = nil then
    Exit;
  TextObject := FPDFPageObj_NewTextObj(Self.Document,
    FPDF_BYTESTRING(AnsiString(Font)), FontSize);
  if TextObject = nil then
    Exit;
  try
    if FPDFText_SetText(TextObject, FPDF_WIDESTRING(WideString(Text))) = 0 then
      Exit;
    if FPDFPageObj_GetBounds(TextObject, L, B, R, T) <> 0 then
    begin
      Width  := R - L;
      Height := T - B;
    end;
  finally
    FPDFPageObj_Destroy(TextObject);   // probe discarded, page untouched
  end;
end;

Sonucun koordinatları ve birimleri

Sınırlayıcı kutu, sol, alt, sağ ve üst olmak üzere dört kenar olarak geri döner ve çıkarma işlemi ile iki boyut (dimension) ortaya çıkar. Genişlik sağ eksi soldur ve yükseklik üst eksi alttır. Her ikisi de, bir birimin inçin yetmiş ikide biri (1/72) olduğu, metni sayfada konumlandırdığınız aynı koordinat uzayı olan PDF kullanıcı birimlerinde ifade edilir. Bu aşamada gizli bir cihaz birimi ve işin içine giren piksel yoktur. 36'lık bir genişlik, nihai (eventual) oluşturma çözünürlüğü ne olursa olsun sayfanın yarım inçi anlamına gelir

Dikey eksen (vertical axis) PDF'in tanımladığı şekilde çalışır, Y yukarı doğru artar; bu nedenle yükseklik tersi (reverse) değil de üst eksi alttır. Bir imleci (cursor) bir sütun aşağı ilerlettiğinizde bu ayrıntı önemlidir. Bir satırın yüksekliğini ölçersiniz, ardından bir sonrakini bulmak için onu geçerli taban çizgisinden (baseline) çıkarırsınız, çünkü sayfada aşağı doğru hareket etmek daha küçük Y'ye doğru hareket etmek demektir. Hedefiniz kağıttan ziyade bir ekransa, kullanıcı birimlerini ekran çözünürlüğüyle cihaz piksellerine (device pixels) dönüştürürsünüz: kullanıcı birimlerindeki bir değer DPI ile çarpılıp 72'ye bölündüğünde piksel (pixels) verir, bu nedenle punto (points) cinsinden belirlediğiniz bir sütun genişliği, molanın (break) nereye gideceğine karar vermeden önce ölçülen bir grupla (run) eşleştirilebilir

Bozuk (degenerate) girdide ne olur

İşlevler sessizce (quietly) başarısız olmak üzere yazılmıştır. Herhangi bir açık belge yoksa veya metin nesnesi oluşturulamıyorsa sonuç, yükseltilmiş (raised) bir istisna (exception) yerine sıfır uzantısıdır (zero extent). Genişlik ve yükseklik üstte sıfır olarak başlatılır ve yalnızca bir sınırlayıcı kutu (bounding box) başarıyla geri okunduğunda üzerine yazılır. Boş bir dize, eksik bir belge, kütüphanenin bir nesneye dönüştüremediği (resolve) bir yazı tipi; bunların her biri fırlatmak (throwing) yerine sıfır döndürür

Bu seçim, bir ölçüm döngüsünü basit tutar, çünkü binlerce kelime üzerinden çalışan bir döngü, her yinelemede (iteration) istisna işleme (exception handling) yeri değildir. Maliyeti (cost), kontrolü (check) çağıranın taşımasıdır. Sıfır genişliği, metin hakkında bir gerçek değil bir nöbetçidir (sentinel); bu nedenle ölçülen bir genişliğe bölen (divides) veya pozitif bir değer varsayan kod, ona güvenmeden önce sıfıra karşı koruma sağlamalıdır (guard against zero). Sıfırı "ölçülemedi (could not measure)" olarak ele aldığınızda sözleşme (contract) açıktır; bunu yok sayarsanız bozuk bir girdi sessizce (quietly) örtüşen (overlapping) gliflerden (glyphs) oluşan bir sütunla (column) bir düzen (layout) haline gelir

Ölçüm üzerine inşa edilmiş açgözlü (greedy) bir sözcük kaydırma (word wrap)

Elinizde bir genişlik işlevi (width function) olduğunda, sözcük kaydırma kısa ve açgözlü (greedy) bir döngüdür. Paragrafı kelimelere böler, geçerli bir satır tutar ve her kelime için o kelimeyi eklerseniz satırın ne olacağını ölçersiniz. Deneme satırı (trial line) yine de sütun genişliğine sığarken eklemeye devam edersiniz; taştığında geçerli satırı AddText ile boşaltır (flush) ve sığmayan kelime ile yenisine başlarsınız. Biriktirme işlemi tamamen MeasureTextWidth ile yapılır ve sayfaya ulaşan tek şey, halihazırda sığdığını onayladığınız (confirmed) bir satırdır

procedure WrapParagraph(Pdf: TPdf; const Para, Font: WString;
  FontSize: Single; X, TopY, ColumnWidth, LineHeight: Double);
var
  Words: TArray<string>;
  Line, Trial: WideString;
  I: Integer;
  Y: Double;
begin
  Words := string(Para).Split([' ']);
  Line  := '';
  Y     := TopY;
  for I := 0 to High(Words) do
  begin
    if Line = '' then
      Trial := Words[I]
    else
      Trial := Line + ' ' + Words[I];
    // Measure the candidate line before drawing anything.
    if (Line <> '') and (Pdf.MeasureTextWidth(Trial, Font, FontSize) > ColumnWidth) then
    begin
      Pdf.AddText(Line, Font, FontSize, X, Y);   // flush the line that fit
      Y    := Y - LineHeight;                    // Y decreases going down
      Line := Words[I];                          // overflowing word starts next line
    end
    else
      Line := Trial;
  end;
  if Line <> '' then
    Pdf.AddText(Line, Font, FontSize, X, Y);      // flush the final line
end;

Döngü, her bir kelimeyi ölçüp toplamak yerine deneme satırını (trial line) ölçer, çünkü bir satırın genişliği kelimelerinin genişliklerinin toplamı (sum) değildir. Kelimeler arasındaki boşluklar (spaces) katkıda bulunur ve ölçülen bir grup (run) bunu doğrudan yakalar. Sütunun izin verdiği kadar kelime sığdırma ve sığan son kelimede bölme şeklindeki açgözlü kural, ham bir AddText ile gerçek bir paragraf arasındaki boşluğu dolduran aynı kuraldır. Çizim çağrısı (drawing call) hiçbir zaman zor kısım değildi. Ondan önce gelmesi (precede) gereken ölçümdür ve yardımcı (helper) da tam olarak bunu sağlar

Bu nereye uyuyor

Ölçüm, içeriğin oluşturulması ile oluşturulması (rendering) arasındaki katmandır, bu nedenle sıfırdan bir belge iş akışının geri kalanıyla doğal olarak eşleşir. Sayfaları birleştiriyorsanız ve en başta metin yerleştiriyorsanız, temel (groundwork), AddText ve sayfa kurulumunun (page setup) tam olarak kapsandığı Delphi'de PDFium bileşeniyle sıfırdan PDF belgeleri oluşturma makalesindedir. Ölçmekte olduğunuz yazı tipi, (metrikler yüze/face bağlı olduğundan) dize (string) kadar önemli olduğunda, Delphi'de PDFium bileşeniyle PDF yazı tipi özelliklerini analiz etme, kütüphanenin bu sınırlayıcı kutuları (bounding boxes) yönlendiren yazı tipi bilgilerini nasıl bildirdiğini gösterir. Her ikisi de, ölçüm yardımcısının (measurement helper) bu blog genelinde açıklanan belge, sayfa ve metin API'lerinin yanı sıra gönderildiği (ships) Delphi ve Lazarus için PDFium Bileşeni olan aynı bağlama (binding) üzerine inşa edilmiştir