Technical Article

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

A hívás, amely szöveget helyez egy PDF oldalra, egyértelmű. Megad az AddText-nek egy karakterláncot (string), egy betűtípust, egy méretet és egy pozíciót, és a glifák (glyphs) megjelennek. Amit viszont nem tesz meg, az az, hogy nem mondja meg, milyen széles lesz az a karakterlánc, miután megrajzolták, és nem tördel (break) egy hosszú karakterláncot több sorra. Egyetlen hívás egy adott pozícióban fest fel egyetlen szövegfutamat (run of text). Ha a szövegfutam szélesebb, mint az oszlop, amelybe szánta, egyszerűen túlfut a szélén, és semmi sem figyelmezteti erre a rajzolási hívásban. Abban a pillanatban, amikor egy bekezdést (paragraph) szeretne egyetlen címke (label) helyett, a hiányzó darab a karakterlánc szélessége a kiválasztott betűtípussal és mérettel, még azelőtt lemérve, hogy rögzítené (commit) az oldalra

Ez a klasszikus elrendezési (layout) probléma. Ahhoz, hogy egy bekezdést egy oszlopba tördeljen, szóról szóra tudnia kell, mennyi vízszintes helyet foglal el minden egyes jelölt sor (candidate line), és ezt még azelőtt kell tudnia, hogy bármit is rajzolna. A sortörés (word wrap) egy mérési ciklus (measurement loop), amely egy rajzolási hívás köré van tekerve, és egy olyan kötés (binding), amely csak rajzolni tud, csak a második felét adja meg. A PDFium komponens szövegmérési (text measurement) támogatása ezt az űrt két függvénnyel tölti be: a MeasureText és a MeasureTextWidth függvényekkel, amelyek jelentést tesznek egy karakterlánc megjelenített kiterjedéséről (rendered extent) anélkül, hogy bármilyen nyomot hagynának bármelyik oldalon

Miért 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 egységében (unit) él, nem pedig új metódusokként a TPdf osztályhoz csavarozva. Az osztálysegéd egy olyan nyelvi funkció, amely lehetővé teszi metódusok hozzáadását egy meglévő típushoz a deklarációján kívülről. Amint az egység hatókörbe (scope) kerül, az új metódusokat pontosan úgy lehet meghívni, mintha az osztályhoz tartoznának, tehát egy segédmetódus úgy olvasható, mint a Pdf.MeasureTextWidth(...), anélkül, hogy külön objektumot kellene létrehozni vagy átadni

Az ok, amiért így rétegezik, az az elkülönítés (separation). A mag (core) TPdf típus úgy marad ahogy van, mező hozzáadása vagy meglévő aláírás (signature) érintése nélkül, így egy olyan projekt, amelynek soha nincs szüksége elrendezésre (layout), soha nem hordozza magával a mérési kódot. Egy olyan projekt, amelynek szüksége van rá, hozzáad egyetlen egységet a uses záradékhoz (clause), és a metódusok elérhetővé válnak. A képesség opcionális (opt-in) lesz egyetlen egység (unit) granularitásával, 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 jelentenie egy szélességet, hogy semmit sem hagy maga után, mert egy elrendezés (layout) eldöntése során sokszor hívja meg, és az oldalnak pontosan úgy kell kinéznie, mintha soha nem mért volna. A technika, ami ezt lehetővé teszi, az, hogy felépít egy szövegobjektumot, lekéri a méretét, majd eldobja, mielőtt valaha is csatolná egy oldalhoz

A sorrend négy PDFium hívás. Az FPDFPageObj_NewTextObj létrehoz egy szövegobjektumot a dokumentummal szemben, megadva a betűtípus nevét és méretét. Az FPDFText_SetText beállítja azt a karakterláncot, amelyet az objektum hordoz. Az FPDFPageObj_GetBounds visszaolvassa az objektum határolódobozát (bounding box). Az FPDFPageObj_Destroy felszabadítja az objektumot. A legfontosabb, hogy ebben a sorozatban semmi sem hívja meg az oldalbeszúró (page-insertion) API-t. Az objektum elszigetelten jön létre, kérdeződik le és semmisül meg, így a dokumentum változatlan marad, amikor a függvény visszatér. Ez egy eldobható próba (throwaway probe), amelynek egyetlen kimenete a határolódobozának négy száma

Ez a robusztus módja a mérésnek, mert a PDFium nem tesz elérhetővé egy kényelmes, glifánkénti (per-glyph) haladási szélességet (advance width), amelyet saját maga összegezhetne. A glifametrikák (glyph metrics) függnek a betűtípus-programtól (font program), a kódolástól (encoding), és attól, hogy a PDFium hogyan tölti be a betűképet (face), és nincs olyan nyilvános hívás (public call), amely átadná minden egyes karakter haladását egy karakterláncban. Egy valódi szövegobjektum határolódoboza viszont ugyanazzal a gépezettel (machinery) van kiszámítva, amely elrendezné a glifákat a rajzoláshoz, így a tényleges renderelt kiterjedést (actual rendered extent) tükrözi, nem pedig egy közelítést (approximation). Egy eldobható objektum felépítése és annak határainak beolvasása a legmegbízhatóbb mérés, amit a könyvtár adni tud

// 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 határolódoboz (bounding box) négy élként jön vissza: bal, alsó, jobb és felső (left, bottom, right, top), a két dimenzió pedig kivonással (subtraction) adódik. 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 egy hüvelyk egy hetvenketted része (1/72 inch), vagyis ugyanaz a koordináta-tér, amelyben a szöveget az oldalon pozícionálja. Ebben a szakaszban nincs rejtett eszközközpontú egység (device unit) és nincs benne pixel sem. A 36-os szélesség fél hüvelyknyi oldalt jelent, függetlenül a végső renderelési felbontástól

A függőleges tengely a PDF által meghatározott módon fut, az Y felfelé növekszik, ezért a magasság a felső mínusz alsó érték, nem pedig fordítva. Ez a részlet akkor számít, amikor egy kurzort lefelé mozgat egy oszlopban. Megméri egy sor magasságát, majd levonja azt az aktuális alapvonalból (baseline), hogy megtalálja a következőt, mert az oldalon lefelé haladni kisebb Y felé mozgást jelent. Ha a célpont egy képernyő a papír helyett, akkor a felhasználói egységeket (user units) eszközpixelekre (device pixels) alakítja át a kijelző felbontásával: a felhasználói egységben megadott érték szorozva a DPI-vel és osztva 72-vel megadja a pixeleket, így egy pontokban (points) megadott oszlopszélesség hozzáigazítható egy mért szövegfutamhoz, mielőtt eldöntené, hol legyen a sortörés

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

A függvények úgy vannak megírva, hogy csendben hiúsuljanak meg (fail quietly). Ha nincs megnyitva dokumentum, vagy ha a szövegobjektumot nem lehet létrehozni, az eredmény egy nulla (zero) kiterjedés lesz, nem pedig egy kivétel (exception). A szélesség és a magasság az elején nullára van inicializálva, és csak akkor íródik felül, ha a határolódobozt sikeresen visszaolvasták. Egy üres karakterlánc, egy hiányzó dokumentum, egy betűtípus, amelyet a könyvtár nem tud objektummá feloldani, ezek mindegyike nullát ad vissza ahelyett, hogy kivételt dobna

Ez a választás egyszerűen tartja a mérési ciklust, mert egy több ezer szón végigfutó ciklus nem a megfelelő hely a kivételkezelésre (exception handling) minden egyes iterációnál. Ennek az az ára, hogy a hívónak (caller) kell elvégeznie az ellenőrzést. A nulla szélesség egy őrszem (sentinel), nem pedig a szöveg ténye, így annak a kódnak, amely oszt a 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 lehetett megmérni", és a szerződés világos lesz; hagyja figyelmen kívül, és a degenerált bemenet csendben egy egymást átfedő (overlapping) glifákból álló oszlop elrendezésévé válik

Mohó (greedy) sortörés a mérésre építve

Egy szélességmérő függvénnyel a kézben a sortörés (word wrap) egy rövid, mohó (greedy) ciklus. A bekezdést szavakra osztja, megtart egy aktuális sort (current line), és minden egyes szónál megméri, milyen lenne a sor, ha hozzáadná azt a szót. Amíg a próbasor (trial line) belefér az oszlopszélességbe, folytatja a hozzáadást; amikor túlcsordulna (overflow), akkor az AddText-tel kiüríti (flush) az aktuális sort, és újat kezd azzal a szóval, amelyik nem fért be. Az akkumuláció teljes egészében a MeasureTextWidth segítségével történik, és a lapra csak olyan sor kerül fel valaha is, amelynek a beférését már megerősítette

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;

A ciklus a próbasort (trial line) méri meg, ahelyett, hogy minden egyes szót megmérne és összegezne, mivel egy sor szélessége nem a szavainak szélességeinek összege. A szavak közötti szóközök (spaces) is hozzájárulnak, és egy mért futam (measured run) ezt közvetlenül megörökíti. A mohó szabály (greedy rule) – vagyis annyi szót kell beilleszteni, amennyit az oszlop megenged, és az utolsó beleférőnél kell eltörni – ugyanaz a szabály, amely kitölti a űrt egy nyers AddText és egy igazi bekezdés (paragraph) között. Soha nem a rajzolási hívás volt a nehéz rész. A mérés, aminek meg kell előznie azt, az az, és pontosan ezt nyújtja a helper (osztálysegéd)

Hová illeszkedik ez

A mérés az a réteg (layer) a tartalom generálása és a renderelés (megjelenítés) között, így természetesen párosul a nulláról építkező dokumentum-munkafolyamat többi részével. Ha oldalakat állít össze és eleve szöveget helyez el, az alapozást a PDF-dokumentumok létrehozása a nulláról a PDFium komponenssel Delphiben című cikk tárgyalja, ahol az AddText és az oldalbeállítás teljes mértékben szerepel. Amikor a mért betűtípus (font) ugyanannyit számít, mint a karakterlánc (string), mert a metrikák a betűképtől (face) függnek, a PDF betűtípus-tulajdonságok elemzése a PDFium komponenssel Delphiben bemutatja, hogyan szolgáltatja a könyvtár azokat a betűtípus-információkat, amelyek ezeket a határolódobozokat (bounding boxes) vezérlik. Mindkettő ugyanarra a kötésre épül, a Delphihez és Lazarushoz készült PDFium Componentre, amelyben a mérési osztálysegéd (measurement helper) a blogon leírt dokumentum, oldal és szöveg API-k mellett érkezik