Artykuł techniczny

Przepływowe (reflow) przekształcanie treści PDF na responsywny HTML w Delphi

PDFium Component zamienia PDF o stałym układzie w semantyczny model, który można przepływowo przeformatować, za pomocą BuildReflowDocument, i eksportuje ten model jako samodzielny HTML przez ToHtml. Nagłówki pozostają nagłówkami, elementy list pozostają elementami list, a tabele wykryte na stronie wychodzą jako prawdziwy znacznik tabeli z zachowanymi komórkami nagłówkowymi i scaleniami. Nic w wyniku nie odwołuje się do zewnętrznego skryptu ani arkusza stylów

Powód, dla którego warto tego chcieć, jest taki, że strona PDF to zbiór pozycjonowanych glifów, co jest dokładnie złe dla ekranu telefonu, czytnika ekranu czy indeksu wyszukiwania. Każda próba rozwiązania tego przez wyodrębnienie zwykłego tekstu traci strukturę, która czyniła dokument czytelnym, a każda próba rozwiązania tego przez konwersję stron na obrazy traci tekst całkowicie. Model przepływowy zachowuje jedno i drugie: słowa oraz relacje między nimi

Skąd pochodzi informacja semantyczna?

Wszystko zaczyna się od GetStructuredText, jedynego źródła tekstu i semantyki w komponencie. Gdy PDF niesie drzewo struktury, czyli PDF znakowany zgodnie z definicją w ISO 32000-1, punkt 14.7, model podąża za logiczną hierarchią zarejestrowaną przez producenta. Gdy jej nie niesie, a większość PDF-ów spotykanych w praktyce jej nie niesie, model cofa się do fizycznej kolejności układu już obliczonej na potrzeby kolejności czytania

Ten wybór utrzymuje twardą granicę: nie wprowadza się drugiego parsera PDF ani drugiego silnika renderowania, by odpowiadać na pytania, na które już odpowiada istniejący. Mechanika kolejności czytania leżąca pod spodem jest opisana w blokach tekstu strukturalnego i kolejności czytania, a model przepływowy jest warstwą semantyczną na jej wierzchu, a nie jej zastąpieniem

Każdy węzeł rejestruje, skąd pochodzi jego informacja, dzięki czemu konsument może odróżnić nagłówek zadeklarowany przez dokument od nagłówka wywnioskowanego przez heurystyki układu. Potoki wrażliwe na pewność powinny odczytywać to pole zamiast traktować wszystkie węzły jako jednakowo wiarygodne

Płaskie drzewo i dlaczego nie jest drzewem obiektów

Model to spłaszczone drzewo w porządku prefiksowym: tablica węzłów, gdzie każdy węzeł niesie ParentIndex i Depth, zamiast rekurencyjnego rekordu albo grafu obiektów z relacją własności. Strony, nagłówki, akapity, listy, elementy list, ryciny, podpisy, tabele, wiersze i komórki — wszystko to mieszka w jednej liniowej tablicy

Stąd dwie korzyści. Konsumenci mogą przetwarzać strumieniowo tablicę w kolejności bez rekurencji, co sprowadza generowanie HTML, Markdown albo widoku drzewa do prostej pętli. A układ pozostaje przenośny między Delphi, C++Builder i Free Pascal, które różnią się sposobem obsługi rekurencyjnych typów zarządzanych na granicy ABI. Rekurencyjny rekord z tablicami dynamicznymi to dokładnie taki konstrukt, który kompiluje się wszędzie i w każdym miejscu zachowuje się subtelnie inaczej

uses
  PDFium;

var
  Pdf: TPdf;
  Options: TPdfReflowOptions;
  Doc: TPdfReflowDocument;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'report.pdf';
    Pdf.LoadDocument;

    Options := TPdfReflowOptions.Default;
    Options.FullDocument := True;
    Options.DetectTables := True;
    Options.IncludeCss := True;          // wbudowany blok stylów, bez zewnętrznego pliku
    Options.MaxNodes := 200000;          // budżet z bezpiecznym zamknięciem przy przekroczeniu
    Options.MaxCharacters := 4000000;

    Doc := Pdf.BuildReflowDocument(Options);

    for I := 0 to High(Doc.Nodes) do
      case Doc.Nodes[I].Kind of
        prnkHeading:
          Writeln(Format('%sH%d: %s', [StringOfChar(' ', Doc.Nodes[I].Depth),
            Doc.Nodes[I].HeadingLevel, Doc.Nodes[I].Text]));
        prnkParagraph:
          Writeln(Format('%sp: %s', [StringOfChar(' ', Doc.Nodes[I].Depth),
            Copy(Doc.Nodes[I].Text, 1, 60)]));
        prnkTable:
          Writeln(Format('table on page %d', [Doc.Nodes[I].PageNumber]));
      end;

    Writeln(Format('%d node(s), %d table(s), %d character(s)',
      [Length(Doc.Nodes), Doc.TableCount, Doc.CharacterCount]));
  finally
    Pdf.Free;
  end;
end;

Jak zapobiega się dwukrotnemu pojawieniu się tabel?

Wykrywanie tabel działa po zebraniu tekstu strukturalnego dla strony, co stwarza oczywiste zagrożenie: ta sama treść komórki istnieje jednocześnie w blokach tekstu i w wykrytej tabeli. Wyemitowanie obu daje HTML, w którym po każdej tabeli znów następuje jej własna zawartość jako luźne akapity

Reguła, która to rozwiązuje, jest geometryczna. Gdy wykryta tabela pokrywa więcej niż połowę obszaru bloku tekstu, węzeł tabeli zastępuje ten blok zamiast się z nim łączyć. Indeksowanie komórek wewnątrz wiersza jest budowane przez zliczanie do koszyków, więc budowanie modelu pozostaje liniowe względem liczby komórek plus wierszy, zamiast ponownie skanować każdą komórkę dla każdego wiersza, co ma znaczenie w dokumentach finansowych, gdzie jedna strona może nieść setki komórek

Wykryta struktura jest uczciwa co do tego, że jest wykryciem. Tabela z liniami siatki jest rozpoznawana bardziej niezawodnie niż taka wyrównana wyłącznie białymi znakami, a pewność węzła to odzwierciedla. Dla treści, gdzie błędna tabela jest lepsza niż brak tabeli, zostaw wykrywanie włączone; dla konwersji archiwalnej, gdzie błędna tabela jest gorsza, bramkuj po pewności

Eksport HTML, który pozostaje samodzielny

ToHtml przechodzi po już zbudowanym modelu i nigdy nie wraca do PDFium, więc dwukrotny eksport nic dodatkowo nie kosztuje i nie może dać innego wyniku z tego samego modelu. Wartości tekstowe i atrybutów są ujednolicone przez escapowanie, poziomy nagłówków są ograniczane do zakresu od h1 do h6, który HTML faktycznie definiuje, a komórki nagłówkowe, RowSpan i ColumnSpan przechodzą tak, jak zostały zapisane

Opcjonalny CSS to zwykły wbudowany blok stylów. Nie ma skryptu, nie ma fontu internetowego ani żadnego zewnętrznego zasobu jakiegokolwiek rodzaju, co sprawia, że wynik można bezpiecznie osadzić w wiadomości e-mail, przeglądarce pomocy czy piaskownicowej kontrolce przeglądarki:

var
  Html: WideString;
  Stream: TFileStream;
  Bytes: TBytes;
begin
  Options := TPdfReflowOptions.Default;
  Options.FullDocument := True;
  Options.IncludeCss := True;
  Options.IncludePageSections := True;   // zachowaj widoczne granice stron
  Options.PreserveLineBreaks := False;   // pozwól przeglądarce zawijać akapity

  Html := Pdf.BuildReflowDocument(Options).ToHtml;

  Bytes := TEncoding.UTF8.GetBytes(string(Html));
  Stream := TFileStream.Create('report.html', fmCreate);
  try
    if Length(Bytes) > 0 then
      Stream.WriteBuffer(Bytes[0], Length(Bytes));
  finally
    Stream.Free;
  end;
end;

PreserveLineBreaks to opcja najbardziej warta przemyślenia. Złamanie wiersza w PDF to decyzja składu podjęta dla stałej szerokości strony, więc zachowanie go na wąskim ekranie odtwarza dokładnie ten problem, który reflow ma rozwiązywać. Zachowuj złamania dla poezji, listingów kodu i adresów; usuwaj je dla prozy

Budżety, anulowanie i stan strony

Znaki, węzły, tabele i komórki mają każde swój pułap i każdy jest sprawdzany przed alokacją, a nie po niej, więc zniekształcony albo wrogi dokument zawodzi czysto zamiast zużywać pamięć, aż zrobi to coś innego. Token anulowania jest sprawdzany na granicach stron, bloków, tabel, wierszy i komórek, co utrzymuje responsywność anulowanego skanowania tysiącstronicowego dokumentu

Jedno zachowanie ma znaczenie specjalnie dla aplikacji GUI: całe skanowanie dokumentu działa wewnątrz zakresu, który przywraca aktywną stronę, więc sukces, awaria budżetu i anulowanie pozostawiają bieżącą stronę wywołującego nietkniętą. Przeglądarka pozwalająca użytkownikowi eksportować podczas przeglądania strony 340 po zakończeniu wciąż znajduje się na stronie 340

Do czego reflow się nadaje, a do czego nie

Wynik reflow to doskonałe wejście dla indeksowania wyszukiwania, dostępnych widoków czytania, wyświetlania mobilnego i migracji treści. To nie jest konwerter zachowujący wierność: pozycje bezwzględne, dokładne fonty, grafika wektorowa i precyzyjna geometria strony leżą poza jego celem z założenia. Gdy zadanie wymaga, by strona wyglądała tak samo, renderuj ją; gdy wymaga, by strona była czytelna gdzie indziej, przepuść ją przez reflow

Specjalnie dla technologii wspomagających model reflow łączy się z funkcjami czytania opisanymi w budowaniu dostępnego czytnika, a dokumenty niosące prawdziwe drzewo struktury dają zauważalnie lepsze modele, co jest dobrym argumentem za walidacją znakowania wcześniej w potoku, jak opisano w walidacji drzewa struktury PDF/UA

Reflow, tekst strukturalny, walidacja znakowania i renderowanie współdzielą jeden obiekt dokumentu w Delphi, C++Builder i Lazarus; pełne API jest opisane na stronie PDFium Component dla Delphi