Technický článek

Měření textu PDF pro rozvržení a zalamování slov v Delphi

Volání, které umístí text na stránku PDF, je přímočaré. Předáte AddText řetězec, písmo, velikost a pozici a glyfy se objeví. Co však neudělá, je to, že vám neřekne, jak široký tento řetězec bude po vykreslení, a nerozdělí dlouhý řetězec na více řádků. Jedno volání vykreslí jeden běh textu na jedné pozici. Pokud je tento běh širší než sloupec, do kterého se měl vejít, jednoduše přesáhne okraj a volání vykreslování vás na to nijak neupozorní. Ve chvíli, kdy chcete místo jednoho štítku celý odstavec, chybějícím dílkem je šířka řetězce ve zvoleném písmu a velikosti, změřená před tím, než jej umístíte na stránku

Toto je klasický problém rozvržení. Abyste zalamovali odstavec do sloupce, musíte slovo od slova vědět, kolik horizontálního prostoru zabere každý kandidátský řádek, a musíte to vědět dříve, než cokoli nakreslíte. Zalamování slov je smyčka měření obalující volání vykreslování, a binding, který pouze kreslí, vám dává jen druhou polovinu. Podpora měření textu v komponentě PDFium tuto mezeru zaplňuje dvěma funkcemi, MeasureText a MeasureTextWidth, které hlásí vykreslený rozsah řetězce, aniž by na stránce zanechaly jakoukoli stopu

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

Podpora měření přichází jako Delphi class helper pro TPdf, který žije ve vlastní jednotce (unit), nikoli jako nové metody pevně zabudované do třídy TPdf. Class helper je vlastnost jazyka, která vám umožňuje připojit metody k existujícímu typu zvenčí jeho deklarace. Jakmile je jednotka v rozsahu (scope), nové metody se volají přesně tak, jako by patřily do třídy, takže metoda helperu se čte jako Pdf.MeasureTextWidth(...) bez nutnosti konstruovat nebo předávat samostatný objekt

Důvodem pro toto vrstvení je oddělení. Základní typ TPdf zůstává tak, jak je, bez přidání jakéhokoli pole a beze změny jakékoli existující signatury, takže projekt, který nikdy nepotřebuje rozvržení, s sebou nikdy nenese kód pro měření. Projekt, který ho potřebuje, přidá jednu jednotku do klauzule uses a metody se zpřístupní. Schopnost se stává volitelnou na úrovni granularity jedné jednotky, což je nejčistší způsob, jak rozšířit typ, který nevlastníte nebo do kterého nechcete zasahovat

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ěření bez zásahu do stránky

Měření musí být bez vedlejších účinků. Musí nahlásit šířku, aniž by po sobě cokoli zanechalo, protože jej voláte mnohokrát při rozhodování o rozvržení a stránka musí vypadat přesně tak, jak by vypadala, kdybyste vůbec neměřili. Technika, která toto umožňuje, spočívá ve vytvoření textového objektu, dotazu na jeho velikost a jeho zahození předtím, než je vůbec připojen ke stránce

Sekvence se skládá ze čtyř volání PDFium. FPDFPageObj_NewTextObj vytvoří textový objekt vůči dokumentu s ohledem na název písma a velikost. FPDFText_SetText nastaví řetězec, který tento objekt nese. FPDFPageObj_GetBounds přečte ohraničující rámeček (bounding box) objektu. FPDFPageObj_Destroy objekt uvolní. Zásadní je, že nic v této sekvenci nevolá API pro vkládání na stránku. Objekt je vytvořen, dotázán a zničen izolovaně, takže dokument zůstává po návratu z 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 rámečku

Toto je robustní způsob, jak to udělat, protože PDFium neposkytuje pohodlnou šířku posunu pro každý glyf (advance width), kterou byste si mohli sami sečíst. Metriky glyfů závisí na programu písma, na kódování a na tom, jak PDFium načítá řez písma (face), a neexistuje žádné veřejné volání, které by vám předalo posun každého znaku v řetězci. Na druhou stranu, ohraničující rámeček skutečného textového objektu je vypočítán stejným mechanismem, který by rozvrhl glyfy pro vykreslení, takže odráží skutečný vykreslený rozsah spíše než aproximaci. Vytvoření jednoho objektu na jedno použití a přečtení jeho hranic je to nejspolehlivější měření, jaké může knihovna poskytnout

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

Souřadnice a jednotky výsledku

Ohraničující rámeček se vrací jako čtyři hrany, levá (left), dolní (bottom), pravá (right) a horní (top), a oba rozměry vyplynou z odčítání. Šířka je pravá minus levá a výška je horní minus dolní. Obojí je vyjádřeno v uživatelských jednotkách PDF (PDF user units), kde jedna jednotka je jedna dvaasedmdesátina palce, tedy ve stejném souřadnicovém prostoru, ve kterém umisťujete text na stránku. V této fázi není zapojena žádná skrytá jednotka zařízení ani žádný pixel. Šířka 36 znamená půl palce stránky, ať už je konečné rozlišení vykreslování jakékoli

Vertikální osa probíhá tak, jak ji definuje PDF, přičemž Y se směrem nahoru zvyšuje, a proto je výška horní hrana minus dolní, nikoli naopak. Na tomto detailu záleží, když posouváte kurzor dolů sloupcem. Změříte výšku řádku, poté ji odečtete od aktuální účaří (baseline), abyste našli další, protože pohyb dolů po stránce znamená pohyb směrem k menšímu Y. Pokud je vaším cílem obrazovka spíše než 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ává pixely, takže šířku sloupce, kterou nastavíte v bodech, lze porovnat s naměřeným během dříve, než se rozhodnete, kam umístit zalomení

Co se stane při degenerovaném vstupu

Funkce jsou napsány tak, aby selhaly tiše. Pokud není otevřen žádný dokument nebo pokud nelze vytvořit textový objekt, výsledkem je nulový rozsah místo vyvolané výjimky. Šířka a výška jsou na začátku inicializovány na nulu a přepsány až po úspěšném přečtení ohraničujícího rámečku. Prázdný řetězec, chybějící dokument, písmo, které knihovna nedokáže přeložit na objekt, z toho všeho se vrátí nula, místo aby se vyhodila výjimka

Tato volba udržuje smyčku měření jednoduchou, protože smyčka běžící přes tisíce slov není místem pro zpracování výjimek v každé iteraci. Daní za to je, že kontrolu nese volající. Nulová šířka je strážce (sentinel), nikoli fakt o textu, takže kód, který dělí naměřenou šířkou nebo předpokládá kladnou hodnotu, se musí chránit proti nule dříve, než jí bude důvěřovat. Považujte nulu za "nepodařilo se změřit" a kontrakt je jasný; ignorujte to a degenerovaný vstup se tiše stane rozvržením se sloupcem překrývajících se glyfů

Hltavé (greedy) zalamování slov postavené na měření

S funkcí pro šířku v ruce je zalamování slov krátká hltavá smyčka. Rozdělíte odstavec na slova, udržujete aktuální řádek a u každého slova měříte, jak by řádek vypadal, kdybyste k němu toto slovo připojili. Dokud se zkušební řádek stále vejde do šířky sloupce, přidáváte další; když by přetekl, vypláchnete (flush) aktuální řádek pomocí AddText a začnete nový se slovem, které se nevešlo. Akumulace se provádí výhradně pomocí MeasureTextWidth a na stránku se dostane pouze řádek, u kterého jste již potvrdili, že se vejde

Smyčka měří zkušební řádek spíše než aby měřila každé slovo a sčítala je, protože šířka řádku není součtem šířek jeho slov. Mezery mezi slovy k tomu přispívají a naměřený běh to přímo zachycuje. Hltavé pravidlo (vměstnat tolik slov, kolik sloupec dovolí, a zalomit u posledního, které se vejde) je stejné pravidlo, které vyplňuje mezeru mezi surovým AddText a skutečným odstavcem. Volání vykreslování nebylo nikdy tou těžší částí. Tou je měření, které mu musí předcházet, a přesně to poskytuje tento helper

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;

Měření je vrstva mezi generováním obsahu a jeho vykreslováním, takže se přirozeně pojí se zbytkem pracovního postupu tvorby dokumentů od nuly. Pokud v první řadě sestavujete stránky a umisťujete text, základy najdete v článku vytváření PDF dokumentů od nuly pomocí komponenty PDFium v Delphi, kde jsou plně pokryty AddText a nastavení stránky. Když na písmu, které měříte, záleží stejně jako na řetězci, protože metriky závisí na řezu písma, článek analýza vlastností písma PDF pomocí komponenty PDFium v Delphi ukazuje, jak knihovna vykazuje informace o písmu, které řídí tyto ohraničující rámečky. Obojí staví na stejném bindingu, PDFium Component pro Delphi a Lazarus, kde se helper pro měření dodává spolu s API pro dokument, stránku a text popsanými v celém tomto blogu