Technický článek

Měření textu PDF pro sazbu a zalamování v Delphi

Volání, které vysází text na stránku PDF, je přímočaré. Metodě AddText dáte řetězec, font, velikost a pozici a glyfy se objeví. Co nedělá, je říct vám, jak široký ten řetězec po vykreslení bude, a nedělí dlouhý řetězec do několika řádků. Jediné volání vysází jeden běh textu na jedné pozici. Je-li ten běh širší než sloupec, do kterého měl padnout, prostě přeteče přes okraj a kreslicí volání vás na nic neupozorní. Ve chvíli, kdy chcete odstavec, a ne jediný popisek, chybějícím dílkem je šířka řetězce ve zvoleném fontu a velikosti, změřená dřív, než jej svěříte stránce

Tohle je klasický problém sazby. Abyste odstavec zalomili do sloupce, musíte slovo po slovu vědět, kolik vodorovného místa každý kandidátský řádek zabere, a musíte to vědět dřív, než cokoli nakreslíte. Zalamování slov je měřicí cyklus obalený kolem kreslicího volání a binding, který jen kreslí, vám dá druhou půlku. Podpora měření textu v komponentě PDFium tuto mezeru zavírá dvěma funkcemi, MeasureText a MeasureTextWidth, jež hlásí vykreslený rozsah řetězce, aniž by na kteroukoli stránku udělaly čárku

Proč je měření class helper, a ne nová metoda na TPdf

Podpora měření přichází jako class helper Delphi pro TPdf, bydlící ve vlastní unitě, a ne jako nové metody přišroubované do třídy TPdf. Class helper je jazyková vlastnost, která dovolí připojit metody k existujícímu typu zvenčí jeho deklarace. Jakmile je unita v dosahu, volají se nové metody přesně tak, jako by třídě patřily, takže metoda helperu se čte jako Pdf.MeasureTextWidth(...), bez zvláštního objektu, jejž by bylo nutné vytvářet a předávat

Důvodem pro toto vrstvení je oddělení. Jádrový typ TPdf zůstává, jaký byl, bez přidané položky a bez dotčené existující signatury, takže projekt, který sazbu nikdy nepotřebuje, měřicí kód s sebou nenosí. Projekt, jenž jej potřebuje, přidá jednu unitu do klauzule uses a metody se rozsvítí. Schopnost se stává volitelnou v jemnosti jediné unity, což je nejčistší způsob, jak rozšířit typ, který nevlastníte nebo nechcete rozvířit

uses
  PDFium, FPdfView, FPdfEdit,
  FPdfMeasure;   // unita helperu; zpřístupní MeasureText na TPdf

// S unitou v dosahu se metody čtou jako členy TPdf:
var
  W, H: Double;
begin
  Pdf.MeasureText('Subtotal', 'Helvetica', 11, W, H);
  // W a H jsou teď vykreslená šířka a výška v uživatelských jednotkách PDF
end;

Měření bez zásahu do stránky

Měření musí být bez vedlejších efektů. Musí ohlásit šířku, aniž by po sobě cokoli nechalo, protože je při rozhodování o sazbě voláte mnohokrát a stránka musí vypadat přesně tak, jako byste neměřili vůbec. Technika, jež to umožňuje, je postavit textový objekt, zeptat se jej na velikost a zahodit jej dřív, než se vůbec připojí ke stránce

Sled tvoří čtyři volání PDFium. FPDFPageObj_NewTextObj vytvoří textový objekt vůči dokumentu, s daným názvem a velikostí fontu. FPDFText_SetText nastaví řetězec, který objekt nese. FPDFPageObj_GetBounds přečte zpět ohraničující obdélník objektu. FPDFPageObj_Destroy objekt uvolní. Zásadní je, že nic v tom sledu nevolá API pro vložení do stránky. Objekt se vytvoří, dotáže a zničí v izolaci, takže dokument je po návratu funkce nezměněný. Je to jednorázová sonda, jejímž jediným výstupem jsou čtyři čísla jejího ohraničujícího obdélníku

Tohle je robustní způsob, protože PDFium nezpřístupňuje pohodlnou šířku posunu jednotlivého glyfu, kterou byste si mohli sami sečíst. Metriky glyfů závisí na programu fontu, na kódování a na tom, jak PDFium řez načte, a neexistuje veřejné volání, jež by vám podalo posun každého znaku v řetězci. Naproti tomu ohraničující obdélník skutečného textového objektu počítá táž mašinerie, která by glyfy rozsázela ke kreslení, takže odráží skutečný vykreslený rozsah, ne přiblížení. Postavit jeden odhoditelný objekt a přečíst jeho hranice je nejspolehlivější měření, jaké knihovna umí dát

Diagram čtyř volání PDFium za metodou MeasureText v Delphi, která sondují odhoditelný textový objekt bez zásahu do stránky
MeasureText postaví odhoditelný textový objekt, přečte jeho ohraničující obdélník a zničí jej, takže měření nechá dokument PDF nedotčený
// Podoba metody MeasureText, vyjádřená přes ověřená volání PDFium.
// Textový objekt se postaví, změří a zničí; žádná stránka v tom není.
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);   // sonda zahozena, stránka nedotčena
  end;
end;

Souřadnice a jednotky výsledku

Ohraničující obdélník se vrací jako čtyři hrany, levá, dolní, pravá a horní, a oba rozměry vypadnou odečtením. Šířka je pravá minus levá a výška je horní minus dolní. Obojí je vyjádřeno v uživatelských jednotkách PDF, kde jedna jednotka je jedna dvaasedmdesátina palce, tedy v témž souřadném prostoru, ve kterém text na stránku umisťujete. V této fázi tu není žádná skrytá jednotka zařízení ani pixel. Šířka 36 znamená půl palce stránky, ať je výsledné rozlišení vykreslení jakékoli

Svislá osa běží tak, jak ji definuje PDF, s Y rostoucím vzhůru, a proto je výška horní minus dolní, ne naopak. Ten detail má význam, když posouváte kurzor dolů sloupcem. Změříte výšku řádku a pak ji odečtete od aktuálního účaří, abyste našli další, protože pohyb dolů po stránce znamená pohyb k menšímu Y. Míří-li váš výstup na obrazovku, a ne na papír, převedete uživatelské jednotky na pixely zařízení pomocí rozlišení displeje: hodnota v uživatelských jednotkách vynásobená DPI a vydělená 72 dá pixely, takže šířku sloupce zadanou v bodech lze porovnat se změřeným během dřív, než rozhodnete, kam padne zlom

Co se stane při degenerovaném vstupu

Funkce jsou napsané tak, aby selhávaly tiše. Není-li otevřený žádný dokument nebo nelze-li textový objekt vytvořit, výsledkem je nulový rozsah, ne vyvolaná výjimka. Šířka a výška se na začátku inicializují na nulu a přepíší se, teprve když se ohraničující obdélník úspěšně přečte. Prázdný řetězec, chybějící dokument, font, který knihovna neumí přeložit na objekt — každý z nich vrátí nulu místo vyhození výjimky

Tato volba drží měřicí cyklus jednoduchý, protože cyklus běžící přes tisíce slov není místem pro ošetřování výjimek v každé iteraci. Cenou je, že kontrolu nese volající. Nulová šířka je sentinel, ne fakt o textu, takže kód, který změřenou šířkou dělí nebo předpokládá kladnou hodnotu, se musí proti nule pojistit, než jí uvěří. Berte nulu jako „nešlo změřit“ a smlouva je jasná; ignorujte ji a z degenerovaného vstupu tiše vznikne sazba se sloupcem překrývajících se glyfů

Hladové zalamování postavené na měření

S funkcí na šířku v ruce je zalamování slov krátký hladový cyklus. Odstavec rozdělíte na slova, držíte si aktuální řádek a pro každé slovo změříte, jaký by řádek byl, kdybyste to slovo připojili. Dokud se zkušební řádek do šířky sloupce vejde, přidáváte dál; ve chvíli, kdy by přetekl, aktuální řádek vysypete metodou AddText a začnete nový slovem, jež se nevešlo. Hromadění probíhá výhradně přes MeasureTextWidth a na stránku se kdy dostane jen řádek, u něhož jste už potvrdili, že se vejde

Diagram hladového zalamovacího cyklu v Delphi, který měří zkušební řádky metodou MeasureTextWidth a láme na posledním slově, jež se vejde
Hladový zalamovací cyklus měří každý zkušební řádek proti šířce sloupce a vysype jen řádky, u nichž je potvrzeno, že se vejdou
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];
    // Změřit kandidátský řádek dřív, než se cokoli nakreslí.
    if (Line <> '') and (Pdf.MeasureTextWidth(Trial, Font, FontSize) > ColumnWidth) then
    begin
      Pdf.AddText(Line, Font, FontSize, X, Y);   // vysypat řádek, který se vešel
      Y    := Y - LineHeight;                    // Y se směrem dolů zmenšuje
      Line := Words[I];                          // přetékající slovo začíná další řádek
    end
    else
      Line := Trial;
  end;
  if Line <> '' then
    Pdf.AddText(Line, Font, FontSize, X, Y);      // vysypat poslední řádek
end;

Cyklus měří zkušební řádek, místo aby měřil každé slovo a sčítal, protože šířka řádku není součtem šířek jeho slov. Mezery mezi slovy se podílejí a změřený běh to zachytí přímo. Hladové pravidlo, vejít tolik slov, kolik sloupec dovolí, a zalomit na posledním, jež se vejde, je totéž pravidlo, které vyplňuje mezeru mezi holým AddText a skutečným odstavcem. Kreslicí volání nikdy nebylo tou těžkou částí. Tou je měření, jež mu musí předcházet, a přesně to helper poskytuje

Kam to zapadá

Měření je vrstva mezi generováním obsahu a jeho vykreslením, takže se přirozeně páruje se zbytkem workflow dokumentu tvořeného od nuly. Pokud vůbec skládáte stránky a umisťujete text, základy najdete v článku tvorba dokumentů PDF od nuly s komponentou PDFium v Delphi, kde jsou AddText i příprava stránky pokryty v úplnosti. Když na fontu, který měříte, záleží stejně jako na řetězci, protože metriky závisí na řezu, ukazuje analýza vlastností fontů PDF s komponentou PDFium v Delphi, jak knihovna hlásí informace o fontu, jež ony ohraničující obdélníky řídí. Obojí staví na témž bindingu, na komponentě PDFium Component pro Delphi a Lazarus, kde měřicí helper přichází spolu s API pro dokument, stránku a text popsanými napříč tímto blogem