Τεχνικό Άρθρο

Δομημένη Εξαγωγή Κειμένου PDF στο Delphi με PDFium VCL

Το PDFiumPas επιστρέφει κείμενο σελίδας ως δομή αντί για string. Η GetStructuredText παράγει μια TPdfStructuredTextPage που περιέχει blocks, το καθένα κρατώντας γραμμές, η καθεμία κρατώντας μορφοποιημένα spans, με όρια σε επίπεδο σελίδας σε κάθε επίπεδο και τους δείκτες χαρακτήρων πηγής διατηρημένους ώστε κάθε τμήμα να μπορεί να αντιστοιχιστεί πίσω στην υποκείμενη σελίδα κειμένου

Η εξαγωγή flat string από την οποία ξεκινά ο περισσότερος κώδικας εξακολουθεί να υπάρχει και να είναι σωστή για τον σκοπό της. Σταματά να είναι αρκετή τη στιγμή που χρειάζεται να ξέρετε ποιες λέξεις ήταν επικεφαλίδα, ποιες ανήκαν στην αριστερή στήλη, ή πού στη σελίδα βρίσκεται πράγματι ένα ταίριασμα

Γιατί ένα flat string είναι η λάθος έξοδος για τις περισσότερες δουλειές;

Επειδή τα ερωτήματα που κάνει ο κόσμος για εξαγμένο κείμενο σχεδόν ποτέ δεν είναι "ποιοι χαρακτήρες βρίσκονται σε αυτή τη σελίδα". Είναι "ποιος είναι ο τίτλος", "είναι αυτό πίνακας", "ανήκει αυτή η παράγραφος στην ενότητα 4", "πού να ζωγραφίσω το highlight". Ένα μοναδικό string δεν απαντά σε κανένα από αυτά, και κάθε απάντηση που ανακατασκευάζετε από αυτό είναι μια heuristic που τώρα σας ανήκει

Οι διατάξεις δύο στηλών κάνουν το σημείο συγκεκριμένο. Εξάγετε ένα άρθρο δύο στηλών ως string και, ανάλογα με το πώς έγραψε ο παραγωγός το content stream, μπορεί να πάρετε στήλη ένα και μετά στήλη δύο, ή μπορεί να πάρετε γραμμή ένα της στήλης ένα, γραμμή ένα της στήλης δύο, γραμμή δύο της στήλης ένα, και ούτω καθεξής προς τα κάτω στη σελίδα. Και τα δύο βγαίνουν από ένα συμμορφούμενο PDF. Κανένα δεν είναι λάθος σε επίπεδο μορφής, επειδή το PDF περιγράφει σημάδια σε μια σελίδα, όχι μια δομή εγγράφου. Ένα μοντέλο βασισμένο σε blocks αφήνει τον εξαγωγέα να πάρει ρητά την απόφαση σειράς και να σας πει ποια απόφαση πήρε

Σειρά περιεχομένου ή φυσική διάταξη;

Η TPdfStructuredTextOptions.ReadingOrder επιλέγει ανάμεσα στα roContentOrder και roPhysicalLayout, και η σωστή απάντηση εξαρτάται από το ποιον εμπιστεύεστε περισσότερο, τον παραγωγό ή τη γεωμετρία

Η σειρά περιεχομένου επιστρέφει κείμενο στην ακολουθία με την οποία το σχεδιάζει το content stream. Αυτό είναι γρήγορο, και για έγγραφα που παράγονται από έναν καλά συμπεριφερόμενο παραγωγό, συνήθως η προτιθέμενη σειρά ανάγνωσης. Η φυσική διάταξη αγνοεί την ακολουθία του stream και ανακατασκευάζει τη σειρά από το πού βρίσκονται πράγματι οι χαρακτήρες, ομαδοποιώντας σε γραμμές και μετά σε στήλες. Αυτό είναι που θέλετε για σελίδες που σαρώθηκαν και μετά έγιναν OCR, για έξοδο από εργαλεία που εκπέμπουν κείμενο με σειρά γραμματοσειράς αντί για σειρά ανάγνωσης, και για οτιδήποτε όπου το οπτικό αποτέλεσμα είναι το μόνο πράγμα στο οποίο μπορείτε να βασιστείτε

uses
  PDFium;

var
  Pdf: TPdf;
  Options: TPdfStructuredTextOptions;
  Page: TPdfStructuredTextPage;
  B, L: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'article.pdf';
    Pdf.LoadDocument;
    Pdf.PageNumber := 1;                     // βάσει 1

    Options := TPdfStructuredTextOptions.Default;
    Options.ReadingOrder := roPhysicalLayout;
    Options.IncludeFontInfo := True;
    Options.IncludeSemantics := True;
    Options.MaxCharacters := 200000;         // budget fail-closed

    Page := Pdf.GetStructuredText(Options);

    for B := 0 to High(Page.Blocks) do
    begin
      if Page.Blocks[B].Kind = cfHeading then
        Emit(Format('H%d: %s',
          [Page.Blocks[B].HeadingLevel, Page.Blocks[B].Text]))
      else
        for L := 0 to High(Page.Blocks[B].Lines) do
          Emit(Page.Blocks[B].Lines[L].Text);
    end;
  finally
    Pdf.Free;
  end;
end;

Τι προσθέτει η επισήμανση tag που δεν μπορεί η γεωμετρία;

Πρόθεση. Με το IncludeSemantics ενεργό, τα blocks από ένα tagged PDF φέρουν ένα Kind που αντλείται από το δέντρο δομής, οπότε μια επικεφαλίδα είναι επικεφαλίδα επειδή το είπε ο παραγωγός, όχι επειδή η γραμματοσειρά της ήταν μεγαλύτερη από τον μέσο όρο. Τα είδη καλύπτουν τα σχήματα που έχουν σημασία για επαναχρησιμοποίηση: cfParagraph, cfHeading με HeadingLevel, cfListItem, cfTableCell, cfCaption, cfFigure και το μη επισημασμένο εναλλακτικό cfPlain

Το πεδίο Source καταγράφει από πού προήλθε κάθε ταξινόμηση, rosStructure για το δέντρο δομής και rosHeuristic για συναγωγή, κάτι που είναι το πεδίο προς καταγραφή όταν αποφασίζετε πόσο να εμπιστευτείτε ένα pipeline εξαγωγής σε ένα σύνολο εγγράφων. Οι φιγούρες είναι μια ειδική περίπτωση αξιοσημείωτη: για ένα block cfFigure το κείμενο προέρχεται από την εναλλακτική περιγραφή αντί από οποιεσδήποτε γλυφές, αφού μια φιγούρα δεν έχει δικούς της χαρακτήρες. Ασύζευκτο εναλλακτικό κείμενο εξακολουθεί να αναπαρίσταται αντί να απορρίπτεται, κάτι που επιτρέπει σε έναν έλεγχο προσβασιμότητας να δει ότι υπάρχει περιγραφή ακόμη κι όταν τίποτα στη σελίδα δεν τη σχεδιάζει. Το ίδιο το μοντέλο tagging καλύπτεται στο επικύρωση δέντρου δομής PDF/UA

Τα spans φέρουν τη μορφοποίηση και την προέλευση

Κάθε TPdfStructuredTextSpan κρατά το κείμενό του, τα όριά του σε επίπεδο σελίδας, FontName, FontSize, FontWeight και Angle, συν SourceStartIndex και SourceCharacterCount. Τα spans σπάνε εκεί όπου αλλάζει η μορφοποίηση, οπότε μια πρόταση με τρεις έντονες λέξεις γίνεται τρία spans, και η ανακατασκευή έμφασης σε HTML ή Markdown είναι θέμα ανάγνωσης ιδιοτήτων αντί για εικασία από ονόματα γραμματοσειράς

Τα δύο πεδία δείκτη πηγής είναι αυτά που μετατρέπουν την εξαγωγή σε χαρακτηριστικό αντί για αναφορά. Δείχνουν πίσω στην ακολουθία χαρακτήρων της σελίδας, κάτι που σημαίνει ότι ένα block που ταίριαξε σε αναζήτηση μπορεί να μετατραπεί σε γεωμετρία επιλογής σε επίπεδο χαρακτήρα ή ένα ορθογώνιο highlight χωρίς δεύτερο, διαφορετικά ταξινομημένο πέρασμα πάνω από το κείμενο· ο μηχανισμός περιγράφεται στο οπτική επιλογή γραμμής κειμένου με πλαίσια χαρακτήρων. Το πεδίο Angle έχει περισσότερη σημασία απ' όσο φαίνεται: περιστραμμένο κείμενο σε σφραγίδα ή υδατογράφημα προσγειώνεται στον ίδιο χώρο συντεταγμένων με το κείμενο σώματος, και ένα pipeline που αγνοεί τη γωνία θα συγχωνεύσει ευχαρίστως ένα διαγώνιο "DRAFT" στη μέση μιας παραγράφου

Budget, και οι δύο μετρητές ποιότητας

Το MaxCharacters είναι ένα budget fail-closed, όχι μια ρύθμιση περικοπής: μια σελίδα που το υπερβαίνει σταματά αντί να επιστρέφει σιωπηλά μέρος του περιεχομένου. Σε ένα μονοπάτι εισαγωγής μη έμπιστο αυτή είναι η συμπεριφορά που θέλετε, επειδή μια σελίδα με ένα εκατομμύριο χαρακτήρες είναι είτε ένα τέρας παραγόμενο από μηχανή είτε μια προσπάθεια να γίνει ο εξαγωγέας σας το πιο αργό κομμάτι του συστήματος

Δύο μετρητές στην επιστρεφόμενη σελίδα περιγράφουν απευθείας την ποιότητα εξαγωγής. Το UnmappedCharacterCount μετρά χαρακτήρες χωρίς χρήσιμη αντιστοίχιση Unicode, κλασικό σύμπτωμα μιας ενσωματωμένης subset γραμματοσειράς χωρίς CMap /ToUnicode· τέτοιο κείμενο αποδίδεται τέλεια και εξάγεται ως τίποτα χρήσιμο. Το GeometryFailureCount μετρά χαρακτήρες των οποίων το bounding box δεν μπόρεσε να προσδιοριστεί, κάτι που υποβαθμίζει τη σειροποίηση φυσικής διάταξης. Καταγράψτε και τα δύο. Ένα σύνολο εγγράφων όπου αυτοί οι αριθμοί είναι σταθερά κοντά στο μηδέν μπορεί να ευρετηριαστεί με εμπιστοσύνη, και ένα όπου δεν είναι σας λέει ότι κάποιοι παραγωγοί στο pipeline σας χρειάζονται προσοχή πριν οποιοδήποτε κατάντη αποτέλεσμα γίνει αξιόπιστο

var
  Page: TPdfStructuredTextPage;
  B, S, L: Integer;
  Emphasised: Boolean;
begin
  Page := Pdf.GetStructuredText(Options);

  if Page.UnmappedCharacterCount > 0 then
    Log(Format('page %d: %d characters without a Unicode mapping',
      [Page.PageNumber, Page.UnmappedCharacterCount]));
  if Page.GeometryFailureCount > 0 then
    Log(Format('page %d: %d characters without geometry',
      [Page.PageNumber, Page.GeometryFailureCount]));

  for B := 0 to High(Page.Blocks) do
    for L := 0 to High(Page.Blocks[B].Lines) do
      for S := 0 to High(Page.Blocks[B].Lines[L].Spans) do
      begin
        Emphasised := Page.Blocks[B].Lines[L].Spans[S].FontWeight >= 600;
        AppendRun(Page.Blocks[B].Lines[L].Spans[S].Text, Emphasised,
          Page.Blocks[B].Lines[L].Spans[S].SourceStartIndex);
      end;
end;

Απόδοση σε πραγματικές σελίδες

Η εξαγωγή φυσικής διάταξης είναι η ακριβότερη λειτουργία, και η υλοποίηση είναι χτισμένη για σελίδες που είναι πράγματι μεγάλες: η ταξινόμηση χαρακτήρων τρέχει σε O(n log n) αντί με επαναλαμβανόμενη σάρωση, τα buffers γραμμών και spans μεγαλώνουν γεωμετρικά αντί να ανακατανέμονται ανά χαρακτήρα, το κείμενο Unicode χτίζεται σε buffers αντί με συνένωση strings, και οι αναζητήσεις γραμματοσειράς για γειτονικά αντικείμενα κειμένου κρατούνται σε cache. Αυτός ο συνδυασμός είναι που κρατά μια πυκνή σελίδα 5.000 χαρακτήρων προβλέψιμη αντί για τετραγωνική

Για μια δουλειά βαριά σε αριθμό σελίδων αξίζει ακόμη να επιλέγετε τη φθηνότερη λειτουργία όπου μπορείτε. Χρησιμοποιήστε το roContentOrder με ενεργοποιημένη τη σημασιολογία για tagged έγγραφα που εμπιστεύεστε, και κρατήστε το roPhysicalLayout για σαρωμένο και παλαιό υλικό όπου η γεωμετρία είναι το μόνο σήμα. Αν το μόνο που χρειάζεστε είναι ένα απλό string, το απλούστερο API που περιγράφεται στο εξαγωγή κειμένου από έγγραφα PDF παραμένει η γρηγορότερη διαδρομή, και όταν χρειάζεται να αντιστοιχίσετε κείμενο πίσω σε αναγνωριστικά marked-content, το ανάγνωση και εγγραφή marked content BDC και MCID καλύπτει αυτό το επίπεδο

Το μοντέλο blocks αντιστοιχίζεται επίσης καθαρά σε ό,τι θέλουν τα pipelines ανάκτησης: μια επικεφαλίδα με τις παραγράφους της είναι ένα κομμάτι με τίτλο, και τα όρια αφήνουν μια παραπομπή να δείξει σε μια θέση πάνω σε μια σελίδα αντί σε ένα έγγραφο. Το PDFiumPas είναι ένα component Delphi και Lazarus γύρω από τη μηχανή PDFium, τεκμηριωμένο με παραδείγματα στη σελίδα του PDFium Delphi component