Technischer Artikel

PDF-Inhalte in Delphi zu responsivem HTML umfließen lassen

PDFium Component wandelt ein PDF mit festem Layout mithilfe von BuildReflowDocument in ein semantisches Modell um, das umfließen kann, und exportiert dieses Modell über ToHtml als eigenständiges HTML. Überschriften bleiben Überschriften, Listeneinträge bleiben Listeneinträge, und auf der Seite erkannte Tabellen kommen als echtes Tabellen-Markup mit erhaltenen Kopfzellen und Spans heraus. Nichts in der Ausgabe verweist auf ein externes Skript oder Stylesheet

Der Grund, warum man das möchte, ist, dass eine PDF-Seite eine Menge positionierter Glyphen ist, was für einen Bildschirm eines Telefons, einen Screenreader oder einen Suchindex genau falsch ist. Jeder Versuch, das Problem durch Extraktion reinen Textes zu lösen, verliert die Struktur, die das Dokument lesbar machte, und jeder Versuch, es durch Umwandlung von Seiten in Bilder zu lösen, verliert den Text vollständig. Ein Reflow-Modell behält beides: die Wörter und die Beziehungen zwischen ihnen

Woher kommt die semantische Information?

Alles beginnt bei GetStructuredText, der einzigen Quelle für Text und Semantik in der Komponente. Trägt das PDF einen Strukturbaum, getaggtes PDF wie in ISO 32000-1 Abschnitt 14.7 definiert, folgt das Modell der logischen Hierarchie, die der Erzeuger festgehalten hat. Trägt es keinen, und die meisten PDFs in freier Wildbahn tun das nicht, fällt das Modell auf die physische Layout-Reihenfolge zurück, die bereits für Zwecke der Lesereihenfolge berechnet wurde

Diese Wahl hält eine harte Grenze ein: Es wird kein zweiter PDF-Parser und keine zweite Rendering-Engine eingeführt, um Fragen zu beantworten, die die vorhandene bereits beantworten kann. Die zugrundeliegende Lesereihenfolgen-Mechanik wird in strukturierten Textblöcken und Lesereihenfolge beschrieben, und das Reflow-Modell ist eine semantische Schicht darüber, kein Ersatz dafür

Jeder Knoten hält fest, woher seine Information stammt, sodass ein Konsument eine vom Dokument deklarierte Überschrift von einer durch Layout-Heuristiken abgeleiteten unterscheiden kann. Konfidenzsensitive Pipelines sollten dieses Feld lesen, statt alle Knoten als gleich maßgeblich zu behandeln

Ein flacher Baum, und warum es kein Baum aus Objekten ist

Das Modell ist ein in Preorder abgeflachter Baum: ein Array aus Knoten, wobei jeder Knoten einen ParentIndex und eine Depth trägt, statt eines rekursiven Datensatzes oder eines Objektgraphen mit Eigentümerschaft. Seiten, Überschriften, Absätze, Listen, Listeneinträge, Abbildungen, Bildunterschriften, Tabellen, Zeilen und Zellen leben alle in diesem einen linearen Array

Daraus folgen zwei Vorteile. Konsumenten können das Array der Reihe nach durchlaufen, ohne Rekursion, was das Erzeugen von HTML, Markdown oder einer Baumansicht zu einer einfachen Schleife macht. Und das Layout bleibt über Delphi, C++Builder und Free Pascal hinweg portabel, die sich darin unterscheiden, wie sie rekursive verwaltete Typen über eine ABI-Grenze hinweg behandeln. Ein rekursiver Datensatz aus dynamischen Arrays ist genau die Art von Konstrukt, das überall kompiliert und sich in jedem Fall subtil anders verhält

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;          // inline Style-Block, keine externe Datei
    Options.MaxNodes := 200000;          // fail-closed Budget
    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;

Wie wird verhindert, dass Tabellen doppelt erscheinen?

Die Tabellenerkennung läuft, nachdem strukturierter Text für eine Seite gesammelt wurde, was eine offensichtliche Gefahr schafft: Derselbe Zellinhalt existiert sowohl in den Textblöcken als auch in der erkannten Tabelle. Beides auszugeben erzeugt HTML, in dem jeder Tabelle ihr eigener Inhalt noch einmal als lose Absätze folgt

Die Regel, die dies auflöst, ist geometrisch. Deckt eine erkannte Tabelle mehr als die Hälfte der Fläche eines Textblocks ab, ersetzt der Tabellenknoten diesen Block, statt sich ihm anzuschließen. Die Zellindizierung innerhalb einer Zeile wird durch Einordnen in Buckets aufgebaut, sodass der Aufbau des Modells linear in Zellen plus Zeilen bleibt, statt jede Zelle für jede Zeile erneut zu durchsuchen, was bei Finanzdokumenten wichtig ist, wo eine einzelne Seite Hunderte von Zellen tragen kann

Erkannte Struktur ist ehrlich darin, Erkennung zu sein. Eine Tabelle mit Linierung wird zuverlässiger erkannt als eine, die rein durch Weißraum ausgerichtet ist, und die Konfidenz des Knotens spiegelt das wider. Für Inhalte, bei denen eine falsche Tabelle besser ist als keine, bleibt die Erkennung aktiviert; für die Archivkonvertierung, bei der eine falsche Tabelle schlimmer ist, sollte nach Konfidenz gefiltert werden

HTML exportieren, das eigenständig bleibt

ToHtml durchläuft das bereits aufgebaute Modell und greift nie erneut auf PDFium zu, daher kostet zweifacher Export nichts zusätzlich und kann aus demselben Modell kein anderes Ergebnis liefern. Text- und Attributwerte werden einheitlich maskiert, Überschriftenebenen werden auf den Bereich h1 bis h6 begrenzt, den HTML tatsächlich definiert, und Kopfzellen, RowSpan und ColumnSpan werden unverändert übernommen

Das optionale CSS ist ein reiner Inline-Style-Block. Es gibt kein Skript, keine Webschrift und keine externe Ressource jeglicher Art, was die Ausgabe sicher macht, um sie in eine E-Mail, einen Hilfe-Viewer oder ein sandboxed Browser-Steuerelement einzubetten:

var
  Html: WideString;
  Stream: TFileStream;
  Bytes: TBytes;
begin
  Options := TPdfReflowOptions.Default;
  Options.FullDocument := True;
  Options.IncludeCss := True;
  Options.IncludePageSections := True;   // Seitengrenzen sichtbar halten
  Options.PreserveLineBreaks := False;   // den Browser Absätze umbrechen lassen

  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 ist die Option, über die es sich am meisten nachzudenken lohnt. Ein PDF-Zeilenumbruch ist eine Satzentscheidung, getroffen für eine feste Seitenbreite, daher reproduziert das Beibehalten auf einem schmalen Bildschirm genau das Problem, das Reflow lösen soll. Umbrüche für Gedichte, Code-Listings und Adressen beibehalten; für Fließtext verwerfen

Budgets, Abbruch und Seitenzustand

Zeichen, Knoten, Tabellen und Zellen haben jeweils eine Obergrenze, und jede wird vor der Zuweisung geprüft, nicht danach, sodass ein fehlerhaftes oder feindseliges Dokument sauber scheitert, statt Speicher zu verbrauchen, bis etwas anderes es tut. Das Abbruch-Token wird an Seiten-, Block-, Tabellen-, Zeilen- und Zellgrenzen geprüft, was einen abgebrochenen Scan eines tausendseitigen Dokuments reaktionsfähig hält

Ein Verhalten ist speziell für GUI-Anwendungen wichtig: Der gesamte Dokument-Scan läuft innerhalb eines Gültigkeitsbereichs, der die aktive Seite wiederherstellt, sodass Erfolg, Budget-Fehlschlag und Abbruch die aktuelle Seite des Aufrufers alle unangetastet lassen. Ein Viewer, der den Nutzer beim Betrachten von Seite 340 exportieren lässt, findet sich anschließend weiterhin auf Seite 340 wieder

Wofür Reflow gut ist, und wofür nicht

Reflow-Ausgabe ist eine hervorragende Eingabe für Suchindizierung, barrierefreie Leseansichten, mobile Anzeige und Inhaltsmigration. Es ist kein originaltreuer Konverter: absolute Positionen, exakte Schriften, Vektorgrafiken und präzise Seitengeometrie liegen konstruktionsbedingt außerhalb seines Zwecks. Wenn eine Aufgabe verlangt, dass die Seite gleich aussieht, sollte gerendert werden; wenn sie verlangt, dass die Seite anderswo lesbar ist, sollte sie umgeflossen werden

Speziell für assistive Technologien passt das Reflow-Modell zu den in Aufbau eines barrierefreien Readers beschriebenen Lesefunktionen, und Dokumente, die einen echten Strukturbaum tragen, erzeugen spürbar bessere Modelle, was ein gutes Argument dafür ist, das Tagging vorgelagert zu validieren, wie in PDF/UA-Strukturbaum-Validierung beschrieben

Reflow, strukturierter Text, Tagging-Validierung und Rendering teilen sich ein Dokumentobjekt über Delphi, C++Builder und Lazarus hinweg; die vollständige API wird auf der PDFium-Component-für-Delphi-Seite beschrieben