Artykuł techniczny

Pomiar tekstu PDF do układu i zawijania słów w Delphi

Wywołanie, które umieszcza tekst na stronie PDF, jest proste. Dajesz AddText ciąg znaków, czcionkę, rozmiar i pozycję, a glify się pojawiają. Czego nie robi, to powiedzenie ci, jak szeroki będzie ten ciąg po narysowaniu, i nie łamie długiego ciągu na kilka linii. Jedno wywołanie maluje jeden przebieg tekstu na jednej pozycji. Jeśli przebieg jest szerszy niż kolumna, do której miał pasować, po prostu wychodzi poza krawędź, i nic w wywołaniu rysowania nie ostrzega. W momencie, gdy chcesz akapit zamiast pojedynczej etykiety, brakującym elementem jest szerokość ciągu znaków w wybranej czcionce i rozmiarze, zmierzona przed zatwierdzeniem jej na stronie

To klasyczny problem układu. Aby zawinąć akapit do kolumny, musisz wiedzieć, słowo po słowie, ile poziomej przestrzeni zajmie każdy kandydujący wiersz, i musisz to wiedzieć przed rysowaniem czegokolwiek. Zawijanie słów to pętla pomiarowa owijająca wywołanie rysowania, a binding, który tylko rysuje, daje ci drugą połowę. Wsparcie pomiaru tekstu w komponencie PDFium zamyka tę lukę dwiema funkcjami, MeasureText i MeasureTextWidth, które raportują wyrenderowany zasięg ciągu bez zostawiania śladu na żadnej stronie

Dlaczego pomiar jest helperem klasy, a nie nową metodą TPdf

Wsparcie pomiaru przybywa jako helper klasy Delphi dla TPdf, żyjący we własnym module, a nie jako nowe metody przykręcone do klasy TPdf. Helper klasy to funkcja językowa, która pozwala dołączać metody do istniejącego typu z zewnątrz jego deklaracji. Po włączeniu modułu w zakres nowe metody są wywoływane dokładnie tak, jakby należały do klasy, więc metoda helpera czyta się jako Pdf.MeasureTextWidth(...) bez osobnego obiektu do skonstruowania lub przekazania

Powodem takiego warstwowania jest separacja. Podstawowy typ TPdf pozostaje taki, jaki jest, bez dodanego pola i bez dotkniętej istniejącej sygnatury, więc projekt, który nigdy nie potrzebuje układu, nigdy nie nosi kodu pomiaru. Projekt, który go potrzebuje, dodaje jeden moduł do klauzuli uses i metody się uaktywniają. Możliwość staje się opt-in na granularności jednego modułu, co jest najczystszym sposobem rozszerzenia typu, którego nie posiadasz lub nie chcesz zakłócać

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;

Pomiar bez dotykania strony

Pomiar musi być wolny od efektów ubocznych. Musi raportować szerokość bez pozostawiania czegokolwiek, ponieważ wywołujesz go wiele razy podczas decydowania o układzie i strona musi wyglądać dokładnie tak, jakby nigdy nie mierzono. Techniką, która to umożliwia, jest zbudowanie obiektu tekstowego, zapytanie go o rozmiar i wyrzucenie go przed jakimkolwiek dołączeniem do strony

Sekwencja to cztery wywołania PDFium. FPDFPageObj_NewTextObj tworzy obiekt tekstowy względem dokumentu, podając nazwę czcionki i rozmiar. FPDFText_SetText ustawia ciąg, który ten obiekt niesie. FPDFPageObj_GetBounds odczytuje z powrotem bounding box obiektu. FPDFPageObj_Destroy zwalnia obiekt. Kluczowo, nic w tej sekwencji nie wywołuje API wstawiania strony. Obiekt jest tworzony, odpytywany i niszczony w izolacji, więc dokument jest niezmieniony gdy funkcja zwraca. To jednorazowa sonda, której jedynym wynikiem są cztery liczby jej bounding box

To jest solidny sposób na to, ponieważ PDFium nie eksponuje wygodnej szerokości posuwu per-glif, którą mógłbyś zsumować samodzielnie. Metryki glifów zależą od programu czcionki, od kodowania i od sposobu, w jaki PDFium ładuje krój, i nie ma publicznego wywołania, które podaje posuw każdego znaku w ciągu. Bounding box prawdziwego obiektu tekstowego jest z drugiej strony obliczany przez tę samą maszynerię, która układa glify do rysowania, więc odzwierciedla rzeczywisty wyrenderowany zasięg a nie aproksymację. Zbudowanie jednego jednorazowego obiektu i odczytanie jego granic to najpewniejszy pomiar, jaki biblioteka może dać

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

Współrzędne i jednostki wyniku

Bounding box wraca jako cztery krawędzie, lewa, dolna, prawa i górna, a dwa wymiary wynikają przez odejmowanie. Szerokość to prawa minus lewa, a wysokość to górna minus dolna. Oba są wyrażone w jednostkach użytkownika PDF, gdzie jedna jednostka to jedna siedemdziesiąta druga cala, ta sama przestrzeń współrzędnych, w której pozycjonujesz tekst na stronie. Nie ma tutaj ukrytej jednostki urządzenia ani piksela. Szerokość 36 oznacza pół cala strony, niezależnie od ostatecznej rozdzielczości renderowania

Oś pionowa biegnie w sposób, jaki PDF definiuje, z Y rosnącym w górę, dlatego wysokość to górna minus dolna a nie odwrotnie. Ten szczegół ma znaczenie przy przesuwaniu kursora w dół kolumny. Mierzysz wysokość linii, a następnie odejmujesz ją od bieżącej linii bazowej, by znaleźć następną, bo przesuwanie w dół strony oznacza przesuwanie ku mniejszym Y. Jeśli twoim celem jest ekran a nie papier, przeliczasz jednostki użytkownika na piksele urządzenia przy rozdzielczości wyświetlacza: wartość w jednostkach użytkownika przemnożona przez DPI i podzielona przez 72 daje piksele, więc szerokość kolumny ustawioną w punktach można porównać z mierzoną linią przed zdecydowaniem, gdzie idzie przerwa

Co się dzieje przy zdegenerowanym wejściu

Funkcje są napisane tak, by zawodzić cicho. Jeśli nie ma otwartego dokumentu, lub jeśli obiekt tekstowy nie może być stworzony, wynikiem jest zerowy zasięg a nie rzucony wyjątek. Szerokość i wysokość są inicjowane na zero na górze i nadpisywane dopiero po pomyślnym odczytaniu bounding box. Pusty ciąg, brakujący dokument, czcionka, której biblioteka nie może rozwiązać do obiektu, każda z tych sytuacji zwraca zero zamiast rzucać

Ten wybór utrzymuje prostotę pętli pomiarowej, bo pętla biegnąca po tysiącach słów nie jest miejscem na obsługę wyjątków przy każdej iteracji. Kosztem jest to, że wywołujący niesie sprawdzenie. Zerowa szerokość to wartość sygnałowa, a nie fakt o tekście, więc kod, który dzieli przez zmierzoną szerokość lub zakłada wartość dodatnią, musi chronić przed zerem przed zaufaniem jej. Traktuj zero jako "nie można zmierzyć" i kontrakt jest jasny; zignoruj to, a zdegenerowane wejście po cichu staje się układem z kolumną nakładających się glifów

Zachłanne zawijanie słów zbudowane na pomiarze

Mając funkcję szerokości pod ręką, zawijanie słów to krótka zachłanna pętla. Dzielisz akapit na słowa, trzymasz bieżącą linię, a dla każdego słowa mierzysz, jaka byłaby linia po dołączeniu tego słowa. Dopóki próbna linia mieści się w szerokości kolumny, kontynuujesz dodawanie; gdy by przepełniła, usuwasz bieżącą linię z AddText i zaczynasz nową ze słowem, które nie pasowało. Akumulacja jest robiona całkowicie za pomocą MeasureTextWidth, a jedyna rzecz, która kiedykolwiek trafia na stronę, to linia, którą już potwierdziłeś, że pasuje

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;

Pętla mierzy próbną linię zamiast mierzenia każdego słowa i sumowania, ponieważ szerokość linii nie jest sumą szerokości jej słów. Spacje między słowami wnoszą wkład, a mierzony przebieg to bezpośrednio uchwyca. Zasada zachłanna, dopasuj tyle słów, ile kolumna pozwala i przerywaj przy ostatnim, które pasuje, jest tą samą regułą, która wypełnia lukę między surowym AddText a prawdziwym akapitem. Wywołanie rysowania nigdy nie było trudną częścią. Pomiar, który musi je poprzedzać, jest nim, i to dokładnie to, co helper dostarcza

Gdzie to pasuje

Pomiar to warstwa między generowaniem zawartości a renderowaniem jej, więc naturalnie paruje się z resztą przepływu pracy tworzenia dokumentu od podstaw. Jeśli tworzysz strony i umieszczasz tekst od początku, podstawy są w artykule tworzenie dokumentów PDF od podstaw z komponentem PDFium w Delphi, gdzie AddText i konfiguracja strony są w pełni omówione. Gdy czcionka, którą mierzysz, ma takie samo znaczenie jak ciąg, bo metryki zależą od kroju, analiza właściwości czcionek PDF z komponentem PDFium w Delphi pokazuje, jak biblioteka raportuje informacje o czcionce napędzające te bounding boxy. Oba opierają się na tym samym bindingu, PDFium Component dla Delphi i Lazarus, gdzie helper pomiaru jest dostarczany razem z API dokumentu, strony i tekstu opisanymi na tym blogu