Technisch artikel

PDF-inhoud reflowen naar responsieve HTML in Delphi

PDFium Component zet met BuildReflowDocument een fixed-layout PDF om in een semantisch model dat kan worden gereflowd, en exporteert dat model via ToHtml als zelfstandige HTML. Koppen blijven koppen, lijstitems blijven lijstitems, en tabellen die op de pagina zijn gedetecteerd, komen eruit als echte tabelmarkup met headercellen en spans behouden. Niets in de uitvoer verwijst naar een extern script of stylesheet

De reden om dit te willen, is dat een PDF-pagina een verzameling gepositioneerde glyphs is, wat precies verkeerd is voor een telefoonscherm, een schermlezer of een zoekindex. Elke poging om dit op te lossen door platte tekst te extraheren, verliest de structuur die het document leesbaar maakte, en elke poging om het op te lossen door pagina's naar afbeeldingen te converteren, verliest de tekst volledig. Een reflow-model behoudt beide: de woorden en de relaties ertussen

Waar komt de semantische informatie vandaan?

Alles begint bij GetStructuredText, de enige bron van tekst en semantiek in het component. Wanneer de PDF een structuurboom draagt, getagde PDF zoals gedefinieerd in ISO 32000-1 clausule 14.7, volgt het model de logische hiërarchie die de producent heeft vastgelegd. Wanneer dat niet zo is, en de meeste PDF's in het wild hebben dat niet, valt het model terug op de fysieke lay-outvolgorde die al voor leesvolgordedoeleinden is berekend

Die keuze handhaaft een harde grens: er wordt geen tweede PDF-parser en geen tweede rendering-engine geïntroduceerd om vragen te beantwoorden die de bestaande al kan beantwoorden. De onderliggende leesvolgorde-machinerie wordt beschreven in gestructureerde tekstblokken en leesvolgorde, en het reflow-model is een semantische laag erbovenop in plaats van een vervanging

Elk knooppunt legt vast waar zijn informatie vandaan kwam, zodat een consument een kop die het document declareerde kan onderscheiden van een kop die de lay-outheuristiek afleidde. Betrouwbaarheidsgevoelige pijplijnen moeten dat veld lezen in plaats van alle knooppunten als even gezaghebbend te behandelen

Een platte boom, en waarom het geen boom van objecten is

Het model is een pre-order afgeplatte boom: een array van knooppunten waarbij elk knooppunt een ParentIndex en een Depth draagt, in plaats van een recursief record of een objectgraaf met eigendom. Pagina's, koppen, alinea's, lijsten, lijstitems, figuren, bijschriften, tabellen, rijen en cellen leven allemaal in die ene lineaire array

Twee voordelen volgen daaruit. Consumenten kunnen de array op volgorde streamen zonder recursie, wat het genereren van HTML, Markdown of een boomweergave een eenvoudige lus maakt. En de lay-out blijft draagbaar tussen Delphi, C++Builder en Free Pascal, die verschillen in hoe ze recursieve managed types over een ABI-grens heen behandelen. Een recursief record van dynamische arrays is precies het soort constructie dat overal compileert en zich in elk daarvan subtiel anders gedraagt

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-blok, geen extern bestand
    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;

Hoe wordt voorkomen dat tabellen twee keer verschijnen?

Tabeldetectie draait nadat gestructureerde tekst voor een pagina is verzameld, wat een voor de hand liggend gevaar creëert: dezelfde celinhoud bestaat zowel in de tekstblokken als in de gedetecteerde tabel. Beide genereren produceert HTML waarin elke tabel wordt gevolgd door zijn eigen inhoud, opnieuw als losse alinea's

De regel die dit oplost is geometrisch. Wanneer een gedetecteerde tabel meer dan de helft van de oppervlakte van een tekstblok bedekt, vervangt het tabelknooppunt dat blok in plaats van eraan toe te voegen. Celindexering binnen een rij wordt opgebouwd door te tellen in buckets, zodat het opbouwen van het model lineair blijft in cellen plus rijen in plaats van elke cel voor elke rij opnieuw te scannen, wat ertoe doet bij financiële documenten waar één pagina honderden cellen kan dragen

Gedetecteerde structuur is eerlijk over het feit dat het detectie is. Een tabel met lijnen wordt betrouwbaarder herkend dan een tabel die puur op basis van witruimte is uitgelijnd, en de betrouwbaarheid van het knooppunt weerspiegelt dat. Voor inhoud waarbij een verkeerde tabel beter is dan geen tabel, houdt u detectie aan; voor archiefconversie waarbij een verkeerde tabel erger is, filtert u op betrouwbaarheid

HTML exporteren die zelfstandig blijft

ToHtml doorloopt het model dat al is opgebouwd en raadpleegt PDFium nooit opnieuw, dus tweemaal exporteren kost niets extra en kan geen ander resultaat opleveren voor hetzelfde model. Tekst- en attribuutwaarden worden uniform geëscaped, kopniveaus worden begrensd tot het bereik h1 tot en met h6 dat HTML daadwerkelijk definieert, en headercellen, RowSpan en ColumnSpan worden ongewijzigd doorgegeven

De optionele CSS is een eenvoudig inline style-blok. Er is geen script, geen webfont en geen enkele externe resource, wat de uitvoer veilig maakt om in te sluiten in een e-mail, een helpviewer of een gesandboxde browsercontrol:

var
  Html: WideString;
  Stream: TFileStream;
  Bytes: TBytes;
begin
  Options := TPdfReflowOptions.Default;
  Options.FullDocument := True;
  Options.IncludeCss := True;
  Options.IncludePageSections := True;   // houd paginagrenzen zichtbaar
  Options.PreserveLineBreaks := False;   // laat de browser alinea's afbreken

  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 is de optie die het meest de moeite waard is om over na te denken. Een PDF-regelafbreking is een zetwerkbeslissing gemaakt voor een vaste paginabreedte, dus die op een smal scherm behouden reproduceert precies het probleem waarvoor reflow bestaat. Behoud afbrekingen voor poëzie, codelistings en adressen; laat ze vallen voor proza

Budgetten, annulering en paginastatus

Tekens, knooppunten, tabellen en cellen hebben elk een plafond, en elk wordt gecontroleerd vóór de toewijzing in plaats van erna, zodat een misvormd of vijandig document netjes faalt in plaats van geheugen te verbruiken totdat iets anders dat doet. Het annuleringstoken wordt gecontroleerd op pagina-, blok-, tabel-, rij- en celgrenzen, wat een geannuleerde scan van een document van duizend pagina's responsief houdt

Eén gedrag doet er specifiek toe voor GUI-toepassingen: de hele documentscan draait binnen een scope die de actieve pagina herstelt, zodat succes, budgetfout en annulering allemaal de huidige pagina van de aanroeper ongemoeid laten. Een viewer waarmee de gebruiker kan exporteren terwijl hij naar pagina 340 kijkt, bevindt zich daarna nog steeds op pagina 340

Waar reflow goed voor is, en wat het niet is

Reflow-uitvoer is uitstekende invoer voor zoekindexering, toegankelijke leesweergaven, mobiele weergave en contentmigratie. Het is geen getrouwheidsbehoudende converter: absolute posities, exacte lettertypen, vectorillustraties en precieze paginageometrie vallen door ontwerp buiten het doel ervan. Wanneer een taak vereist dat de pagina er hetzelfde uitziet, rendert u die; wanneer de pagina ergens anders leesbaar moet zijn, reflowt u die

Specifiek voor hulptechnologie sluit het reflow-model aan bij de leesfuncties beschreven in het bouwen van een toegankelijke lezer, en documenten die een echte structuurboom dragen, produceren merkbaar betere modellen, wat een goed argument is om tagging stroomopwaarts te valideren zoals beschreven in PDF/UA-structuurboomvalidatie

Reflow, gestructureerde tekst, taggingvalidatie en rendering delen één documentobject binnen Delphi, C++Builder en Lazarus; de volledige API wordt beschreven op de PDFium Component voor Delphi-pagina