Tehnički članak

Mjerenje PDF teksta za prijelom teksta (word wrap) i vizualni raspored (layout) u Delphiju

Poziv koji stavlja tekst na jednu PDF stranicu jest sasvim izravan (straightforward). Vi AddText dajete jedan string, font, veličinu, i poziciju, a glifovi (glyphs) se pojavljuju. Ono što on ne radi jest to da vam kaže koliko će taj string biti širok jednom kada bude nacrtan (drawn), i on ne prelama jedan dugi string kroz nekoliko linija. Jedan jedini poziv oslikava (paints) jednu seriju (run) teksta na jednoj poziciji. Ako je ta serija šira od stupca u koji ste vi namijenili da stane (fit), ona jednostavno prelazi preko ruba, a ništa vas u samom pozivu za crtanje na to ne upozorava. U trenutku kada poželite jedan odlomak umjesto jedne jedine labele, onaj komad koji nedostaje jest širina jednog stringa u odabranom fontu i veličini, a koja se mjeri prije nego što ga povjerite (commit) samoj stranici

Ovo je klasični problem vizualnog rasporeda (layout problem). Da biste prelomili (wrap) jedan odlomak u jedan stupac, vi morate znati, riječ po riječ, koliko će horizontalnog prostora uzeti svaka linija kandidat (candidate line), i vi to morate znati unaprijed (ahead of) prije crtanja bilo čega. Prijelom teksta (Word wrap) jest mjerna petlja (measurement loop) omotana (wrapped) oko jednog poziva za crtanje, a poveznica (binding) koja samo crta vam daje tek tu drugu polovicu. Podrška za mjerenje teksta u PDFium komponenti zatvara taj procjep (gap) s dvije funkcije, MeasureText i MeasureTextWidth, koje prijavljuju (report) renderirani raspon (extent) jednog stringa bez stavljanja ikakve oznake (mark) na bilo koju stranicu

Zašto je mjerenje pomagač klase (class helper), a ne jedna nova metoda na TPdf

Podrška za mjerenje stiže kao Delphi pomagač klase (class helper) za TPdf, živeći u svojoj vlastitoj jedinici (unit), radije nego kao nove metode nabijene (bolted into) u TPdf klasu. Pomagač klase (class helper) je značajka jezika koja vam dopušta da pričvrstite (attach) metode na jedan postojeći tip izvana, izvan njegove deklaracije. Jednom kada je jedinica u opsegu (scope), te nove metode zovu se točno onako kao da one i pripadaju toj klasi, tako da se metoda pomagač (helper method) čita kao Pdf.MeasureTextWidth(...) bez nekog odvojenog objekta za konstruiranje ili prosljeđivanje naokolo

Razlog da se to posloži (layer) na ovaj način jest odvajanje (separation). Temeljni (core) TPdf tip ostaje onakav kakav jest, bez ijednog dodanog polja i bez ijednog dirnutog postojećeg potpisa, tako da jedan projekt koji nikada ne treba vizualni raspored (layout) nikada ni ne nosi kod za mjerenje. Projekt koji ga pak treba dodaje jednu jedinicu u uses klauzulu i metode se osvijetle (light up). Sposobnost (Capability) postaje dostupna na vlastiti izbor (opt-in) na granularnosti jedne jedine jedinice (unit), što je onaj najčistiji način za proširenje (extend) tipa kojeg ne posjedujete ili kojeg ne želite ometati (disturb)

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;

Mjerenje bez dodirivanja stranice

Mjerenje mora biti slobodno od popratnih nuspojava (side effects). Ono mora prijaviti (report) širinu bez ostavljanja ičega iza sebe, zato što ga vi pozivate mnogo puta dok odlučujete o vizualnom rasporedu (layout) a stranica mora izgledati točno onako kako bi i izgledala da nikada uopće niste ni mjerili. Tehnika koja ovo čini mogućim jest da se izgradi jedan tekstualni objekt, upita ga se za njegovu veličinu, te ga se baci ća prije nego što se ikada pričvrsti (attached) na stranicu

Slijed su zapravo četiri PDFium poziva. FPDFPageObj_NewTextObj kreira tekstualni objekt prema dokumentu, uz zadani naziv fonta i veličinu. FPDFText_SetText postavlja string koji taj objekt nosi. FPDFPageObj_GetBounds čita natrag pravokutnik obuhvata (bounding box) objekta. FPDFPageObj_Destroy oslobađa objekt. Od ključne važnosti, ništa u tom slijedu ne poziva API za umetanje na stranicu (page-insertion API). Objekt se kreira, preispituje (queried), te uništava u izolaciji, tako da je dokument nepromijenjen (unchanged) kada se funkcija vrati. To je jedna sonda za bacanje (throwaway probe) čiji su jedini izlaz ona četiri broja njezinog pravokutnika obuhvata (bounding box)

Ovo je onaj robustan način da se to uradi zato što PDFium ne izlaže nikakvu prikladnu širinu pomaka po glifu (per-glyph advance width) koju biste vi mogli sami zbrajati (sum). Metrika glifova ovisi o programu fonta, o kodiranju (encoding), i o tome kako PDFium učitava to lice (face), a ne postoji nikakav javni poziv koji vam na ruku daje pomak svakog znaka u nekom stringu. Pravokutnik obuhvata (bounding box) jednog pravog tekstualnog objekta, s druge strane, računa se od strane iste one mašinerije koja bi i postavila vizualni raspored (lay out) tih glifova za crtanje, tako da to reflektira stvarni renderirani raspon umjesto samo neke aproksimacije. Izgradnja jednog objekta za jednokratnu upotrebu (disposable object) i čitanje njegovih granica (bounds) jest najpouzdanije mjerenje koje biblioteka može dati

// 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;

Koordinate i mjerne jedinice rezultata

Pravokutnik obuhvata vraća se natrag kao četiri ruba, lijevi, donji, desni, i gornji, a dvije dimenzije onda ispadaju pomoću oduzimanja (subtraction). Širina je desno minus lijevo, a visina je gore minus dolje. Obje su izražene u PDF korisničkim mjernim jedinicama (user units), gdje je jedna jedinica jedna sedamdesetidrugina inča (seventy-second of an inch), što je onaj isti koordinatni prostor (coordinate space) u kojem vi pozicionirate tekst na stranici. U ovoj fazi ne postoji nikakva skrivena jedinica uređaja (device unit) niti ikakav umiješani piksel. Širina od 36 znači pola inča stranice, bez obzira kakva će biti konačna rezolucija renderiranja

Vertikalna os ide onako kako ju definira PDF, pri čemu Y raste prema gore, što je razlog zašto je visina gore minus dolje, a ne obrnuto. Taj detalj je itekako važan kada pomičete kursor niz neki stupac. Izmjerite visinu linije, te je zatim oduzmete od trenutne osnovne linije (baseline) kako biste pronašli sljedeću, zato što pomicanje niz stranicu znači pomicanje prema manjem Y. Ako je vaša destinacija neki ekran (screen) radije nego papir, vi pretvarate korisničke mjerne jedinice (user units) u piksele uređaja pomoću rezolucije prikaza: vrijednost u korisničkim jedinicama pomnožena s DPI-jem te podijeljena sa 72 daje piksele, tako da se ona širina stupca koju vi postavite u točkama može suprotstaviti jednoj izmjerenoj seriji (run) prije nego što se odlučite gdje ide taj prijelom (break)

Što se događa kod degeneriranog ulaza (degenerate input)

Funkcije su napisane tako da padaju (fail) tiho. Ako nema otvorenog dokumenta, ili ako se tekstualni objekt ne može kreirati, rezultat jest jedan nulti raspon (zero extent) radije nego jedna podignuta iznimka (exception). Širina i visina se inicijaliziraju na nulu na vrhu i prepisuju (overwritten) se samo jednom kada je jedan pravokutnik obuhvata uspješno pročitan natrag (read back successfully). Jedan prazan string, jedan nedostajući dokument, jedan font koji biblioteka ne može razlučiti (resolve) u jedan objekt, svako od toga vraća nulu radije nego da baca iznimku (throwing)

Taj odabir drži mjernu petlju jednostavnom, zato što petlja koja prelazi preko tisuća riječi nije mjesto za rukovanje iznimkama (exception handling) na svakoj iteraciji. Cijena toga je to da onaj tko poziva (caller) mora snositi provjeru. Nulta širina (zero width) je samo jedan sentinel, a ne neka činjenica o samom tekstu, tako da onaj kod koji dijeli sa jednom izmjerenom širinom ili onaj koji pretpostavlja pozitivnu vrijednost mora se čuvati nule prije nego što joj povjeruje. Tretirajte nulu kao "nije se moglo izmjeriti" (could not measure) i sam ugovor (contract) bit će jasan; ignorirajte je i jedan degenerirani ulaz (degenerate input) tiho će postati vizualni raspored (layout) s jednim stupcem glifova koji se preklapaju (overlapping)

Pohlepni prijelom teksta (greedy word wrap) izgrađen na mjerenju

S funkcijom za širinu u ruci, prijelom teksta (word wrap) jest jedna kratka pohlepna (greedy) petlja. Vi podijelite (split) jedan odlomak na riječi, zadržite jednu trenutnu liniju (current line), a za svaku riječ vi mjerite ono što bi ta linija bila kada biste joj pridodali (appended) tu riječ. Sve dok ta probna linija (trial line) još uvijek stane (fits) u širinu stupca vi samo nastavljate dodavati; kada bi se ona prelila (overflow) vi izlijete (flush) trenutnu liniju s AddText i započnete jednu novu s onom riječju koja nije stala (did not fit). Akumulacija (accumulation) se radi u potpunosti s MeasureTextWidth, a jedina stvar koja ikada stigne (reaches) do stranice jest jedna linija za koju ste već potvrdili (confirmed) da stane (fits)

procedure WrapParagraph(Pdf: TPdf; const Para, Font: WString;
  FontSize: Single; X, TopY, ColumnWidth, LineHeight: Double);
var
  Words: TArray<WideString>;
  Line, Trial: WideString;
  I: Integer;
  Y: Double;
begin
  Words := WideString(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(X, Y, Font, FontSize, Line);   // 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(X, Y, Font, FontSize, Line);      // flush the final line
end;

Ova petlja mjeri probnu liniju (trial line) radije nego da mjeri svaku riječ te ih zatim zbraja, zato što širina jedne linije nije suma širina njezinih riječi. Razmaci između riječi itekako doprinose, a jedna izmjerena serija (measured run) to hvata sasvim izravno. Pohlepno pravilo (greedy rule), da ugurate (fit) onoliko riječi koliko god to jedan stupac dopušta te da prelomite (break) kod one zadnje koja još uvijek stane, isto je ono pravilo koje popunjava taj procjep između jednog sirovog AddText i jednog pravog odlomka. Poziv za crtanje ionako nikada i nije bio onaj teški dio. Mjerenje pak koje tome mora prethoditi to jest, a to je upravo ono što pomagač (helper) i nudi

Gdje se to sve uklapa

Mjerenje je taj jedan sloj između generiranja sadržaja i njegovog renderiranja, pa se onda stoga sasvim prirodno uparuje s preostalim tokom rada s dokumentima od same nule (from-scratch document workflow). Ako u prvom redu sastavljate stranice i postavljate tekst, te osnove za rad nalaze se u kreiranju PDF dokumenata od nule (from scratch) uz PDFium komponentu u Delphiju, gdje su AddText i postavljanje stranice pokriveni u cijelosti. Kada je i onaj font koji vi zapravo mjerite važan isto koliko i sam string, zato što sama metrika ovisi o licu (face), analiziranje svojstava PDF fonta uz PDFium komponentu u Delphiju pokazuje nam to kako biblioteka prijavljuje font informacije koje i pokreću te pravokutnike obuhvata (bounding boxes). Oboje su izgrađeni na onoj istoj poveznici, PDFium Component za Delphi i Lazarus, gdje se i mjerni pomagač (measurement helper) isporučuje zajedno uz same API-je za dokument, stranicu, i tekst opisane diljem ovog bloga