Műszaki cikk

PDF szöveg mérése az elrendezéshez és a sortöréshez Delphiben

A hívás, amely szöveget helyez el egy PDF oldalon, egyértelmű. Megad az AddText-nek egy karakterláncot (string), egy betűtípust (font), egy méretet és egy pozíciót, és a glifák megjelennek. Amit viszont nem tesz meg, az az, hogy megmondja, milyen széles lesz ez a karakterlánc a megrajzolása után, és nem töri több sorba a hosszú karakterláncokat. Egyetlen hívás egyetlen szövegrészt fest meg egy pozícióban. Ha a szövegrész szélesebb, mint az oszlop, amibe szánta, egyszerűen túlfut a szélén, és semmi a rajzoló hívásban nem figyelmezteti erre. Abban a pillanatban, hogy bekezdést szeretne egyetlen címke (label) helyett, a hiányzó darab a kiválasztott betűtípusban és méretben lévő karakterlánc szélessége, amelyet még azelőtt kell megmérni, mielőtt az oldalra helyezné

Ez a klasszikus elrendezési probléma. Ahhoz, hogy egy bekezdést egy oszlopba tördeljen, szóról szóra tudnia kell, mennyi vízszintes helyet foglal majd el minden egyes lehetséges sor, és ezt még bárminek a megrajzolása előtt tudnia kell. A sortörés (word wrap) egy mérési ciklus egy rajzoló hívás köré csomagolva, és egy olyan kötés (binding), amely csak rajzol, csupán a második felét adja meg. A PDFium komponens szövegmérési támogatása ezt a szakadékot két függvénnyel, a MeasureText-tel és a MeasureTextWidth-del hidalja át, amelyek egy karakterlánc renderelt kiterjedését jelentik anélkül, hogy bármilyen nyomot hagynának bármelyik oldalon

Miért egy osztálysegéd (class helper) a mérés, és nem egy új metódus a TPdf-en?

A mérési támogatás egy Delphi osztálysegédként (class helper) érkezik a TPdf-hez, amely a saját unitjában él, ahelyett, hogy új metódusként lenne hozzácsavarozva a TPdf osztályhoz. Az osztálysegéd egy olyan nyelvi funkció, amely lehetővé teszi, hogy metódusokat csatoljon egy meglévő típushoz a deklarációján kívülről. Amint a unit hatókörbe (scope) kerül, az új metódusok pontosan úgy hívhatók meg, mintha az osztályhoz tartoznának, így egy segédmetódus úgy olvasható, mint Pdf.MeasureTextWidth(...), anélkül, hogy külön objektumot kellene létrehozni vagy átadni

Ennek az ily módon történő rétegzésnek az oka az elkülönítés. Az alapvető TPdf típus marad, ahogy volt, anélkül, hogy bármilyen mezőt hozzáadnánk, vagy bármilyen meglévő szignatúrát érintenénk, így egy olyan projekt, amely soha nem igényel elrendezést (layout), soha nem hordozza magával a mérési kódot. Egy olyan projekt, amelynek viszont szüksége van rá, hozzáad egy unitot a uses záradékhoz, és a metódusok felvillannak. A képesség egyetlen unit granularitásánál válik opcionálissá (opt-in), ami a legtisztább módja egy olyan típus kiterjesztésének, amelyet nem birtokol, vagy nem akar megzavarni

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;

Mérés az oldal érintése nélkül

A mérésnek mellékhatásoktól mentesnek kell lennie. Úgy kell egy szélességet jelentenie, hogy semmit sem hagy maga után, mert egy elrendezés eldöntése során sokszor hívja meg, és az oldalnak pontosan úgy kell kinéznie, mintha egyáltalán nem is mért volna. Az a technika, amely ezt lehetővé teszi, az, hogy felépítünk egy szövegobjektumot, lekérdezzük a méretét, majd eldobjuk, mielőtt valaha is egy oldalhoz csatolnánk

A sorrend négy PDFium hívásból áll. Az FPDFPageObj_NewTextObj létrehoz egy szövegobjektumot a dokumentumhoz, a megadott betűtípus (font) név és méret alapján. Az FPDFText_SetText beállítja az objektum által hordozott karakterláncot (string). Az FPDFPageObj_GetBounds visszaolvassa az objektum befoglaló dobozát (bounding box). Az FPDFPageObj_Destroy felszabadítja az objektumot. Létfontosságú, hogy ebben a sorrendben semmi sem hívja meg az oldal-beillesztő API-t. Az objektum elszigetelten jön létre, kerül lekérdezésre és semmisül meg, így a dokumentum változatlan marad, amikor a függvény visszatér. Ez egy eldobható szonda, amelynek egyetlen kimenete a befoglaló dobozának négy száma

Ez a robusztus módja ennek, mivel a PDFium nem bocsát rendelkezésre kényelmes, glifánkénti továbblépési szélességet (advance width), amelyet önmagában összegezhetne. A glifa metrikák (glyph metrics) a betűtípus programtól, a kódolástól és attól függnek, hogyan tölti be a PDFium az adott betűtípust, és nincs olyan nyilvános hívás, amely megadná egy karakterlánc minden egyes karakterének továbblépését. Egy valódi szövegobjektum befoglaló dobozát (bounding box) viszont ugyanaz a mechanizmus számítja ki, amely a glifákat a rajzoláshoz elrendezné, így a tényleges renderelt kiterjedést tükrözi, nem pedig egy közelítést. Egyetlen eldobható objektum felépítése és annak határainak (bounds) beolvasása a legmegbízhatóbb mérés, amit a könyvtár adhat

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

Az eredmény koordinátái és mértékegységei

A befoglaló doboz négy peremként (edge) tér vissza: bal, alsó, jobb és felső, és a két dimenzió kivonással adódik ki. A szélesség a jobb mínusz a bal, a magasság pedig a felső mínusz az alsó. Mindkettő PDF felhasználói egységekben (user units) van kifejezve, ahol egy egység a hüvelyk (inch) egy hetvenketted része, ugyanaz a koordinátatér, amelyben a szöveget az oldalon elhelyezi. Nincs rejtett eszközegység (device unit) és nincsenek pixelek bevonva ebben a szakaszban. A 36-os szélesség fél hüvelyknyi oldalt jelent, bármilyen is legyen a végső renderelési felbontás

A függőleges tengely úgy fut, ahogy azt a PDF definiálja, az Y felfelé növekszik, ezért a magasság a felső mínusz az alsó, és nem fordítva. Ez a részlet számít, amikor egy kurzort lefelé mozgat egy oszlopban. Megméri egy sor magasságát, majd kivonja azt az aktuális alapvonalból (baseline), hogy megtalálja a következőt, mivel az oldalon lefelé haladni a kisebb Y felé való mozgást jelenti. Ha a cél nem papír, hanem képernyő, akkor a felhasználói egységeket (user units) a kijelző felbontásával eszközpixelekké (device pixels) alakítja: egy felhasználói egységekben kifejezett értéket megszorozva a DPI-vel és elosztva 72-vel megkapja a pixeleket, így egy pontokban (points) beállított oszlopszélesség összevethető egy mért szövegrészzel (measured run), mielőtt eldöntené, hova kerüljön a törés (break)

Mi történik degenerált bemenet esetén

A függvényeket úgy írták meg, hogy csendben hibázzanak. Ha nincs nyitott dokumentum, vagy ha a szövegobjektum nem hozható létre, az eredmény nulla kiterjedés lesz a kivétel (exception) kiváltása helyett. A szélesség és a magasság nulla értékkel inicializálódik az elején, és csak akkor íródik felül, ha egy befoglaló dobozt (bounding box) sikeresen visszaolvastak. Egy üres karakterlánc, egy hiányzó dokumentum, egy betűtípus (font), amelyet a könyvtár nem tud objektummá feloldani, mindezek nullát adnak vissza ahelyett, hogy kivételt dobnának

Ez a választás egyszerűen tartja a mérési ciklust, mert egy több ezer szón átfutó ciklus nem a megfelelő hely a kivételkezelésre (exception handling) minden egyes iterációban. Ennek az az ára, hogy az ellenőrzés a hívóra hárul. A nulla szélesség egy jelzőérték (sentinel), nem pedig a szövegre vonatkozó tény, így annak a kódnak, amely oszt egy mért szélességgel, vagy pozitív értéket feltételez, védekeznie kell a nulla ellen, mielőtt megbízna benne. Kezelje a nullát úgy, mint „nem sikerült megmérni”, és a szerződés (contract) egyértelmű; hagyja figyelmen kívül, és egy degenerált bemenet csendben egymást fedő glifák oszlopával rendelkező elrendezéssé (layout) válik

A mérésre épülő kapzsi (greedy) sortörés

Egy szélességfüggvény birtokában a sortörés (word wrap) egy rövid, kapzsi (greedy) ciklus. A bekezdést szavakra osztja, megtart egy aktuális sort, és minden egyes szónál megméri, milyen lenne a sor, ha hozzáfűzné azt a szót. Amíg a próbasor még belefér az oszlop szélességébe, folyamatosan hozzáadja azokat; amikor túlcsordulna, kiírja (flush) az aktuális sort az AddText segítségével, és egy újat kezd azzal a szóval, amelyik nem fért bele. A felhalmozás teljes egészében a MeasureTextWidth használatával történik, és az egyetlen dolog, ami valaha is eléri az oldalt, az egy olyan sor, amelyről már megbizonyosodott, hogy elfé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;

A ciklus a próbasort (trial line) méri meg ahelyett, hogy megmérné az egyes szavakat, és összeadná őket, mivel egy sor szélessége nem az őt alkotó szavak szélességeinek összege. A szavak közötti szóközök (spaces) is hozzájárulnak ehhez, és egy mért szövegrész ezt közvetlenül megragadja. A kapzsi szabály (greedy rule), hogy illesszen be annyi szót, amennyit az oszlop megenged, és az utolsónál törjön meg, amely még elfér, ugyanaz a szabály, amely kitölti a rést egy nyers AddText és egy valódi bekezdés között. A rajzoló hívás soha nem volt nehéz. Azt megelőző mérés a nehéz, és az osztálysegéd (helper) pontosan ezt nyújtja

Ahová ez illeszkedik

A mérés az a réteg a tartalom előállítása és annak megjelenítése (renderelése) között, így természetesen párosul a nulláról induló (from-scratch) dokumentum-munkafolyamat többi részével. Ha oldalakat állít össze, és már a kezdetektől szöveget helyez el, az alapokat a PDF dokumentumok készítése az alapoktól a PDFium komponenssel Delphiben című cikkben találja, amelyben az AddText és az oldalbeállítás (page setup) teljeskörűen ismertetésre kerül. Amikor az Ön által mért betűtípus (font) éppen annyira számít, mint a karakterlánc (string), mert a metrikák (metrics) a betűképétől (face) függnek, a PDF betűtípus tulajdonságok elemzése a PDFium komponenssel Delphiben cikk megmutatja, hogyan jelenti a könyvtár a betűtípus-információkat, amelyek ezeket a befoglaló dobozokat (bounding boxes) vezérlik. Mindkettő ugyanarra a kötésre épül, a Delphihez és Lazarushoz készült PDFium Component-re, amelyben a mérési segéd (measurement helper) a blogon leírt dokumentum-, oldal- és szöveg API-k mellett található