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

Αναζήτηση κειμένου PDF στο Delphi με συντεταγμένες ευρέσεων: PDFlibPas

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

Το PDFlibPas είναι μια εγγενής βιβλιοθήκη PDF σε Object Pascal για το Delphi και το C++Builder, και από την έκδοση v3.78.0 απαντά ακριβώς σε αυτό το ερώτημα. Πάνω από τον υπάρχοντα εξαγωγέα μπλοκ κειμένου βρίσκονται τρία query APIs: SearchText διατρέχει ένα εύρος σελίδων και επιστρέφει κάθε εύρεση μαζί με τη σελίδα της και το ορθογώνιο ευθυγραμμισμένο με τους άξονές της, EnumPageElements απαριθμεί τα πάντα σε μία σελίδα (τα μπλοκ κειμένου και τις ενσωματωμένες εικόνες εξίσου), και GetTextInAreaEx επιστρέφει το ορθογώνιο κάθε μπλοκ μέσα σε μια περιοχή αντί να τα ισοπεδώνει σε μια λίστα συμβολοσειρών. Καμία από αυτές δεν αγγίζει τη διαδρομή εγγραφής· είναι καθαρές προσθήκες ανάγνωσης πάνω σε μηχανισμούς που η βιβλιοθήκη διέθετε ήδη

Γιατί η γεωμετρία ζει στη λίστα μπλοκ κειμένου, όχι στη χοάνη

Η φυσική τάση είναι να επαναχρησιμοποιήσεις ό,τι GetPageText τρέχει εσωτερικά. Αυτή η διαδρομή περνά από μια προσωρινή χοάνη εξαγωγής που παράγει τη συμβολοσειρά της σελίδας και έπειτα απελευθερώνεται πριν επιστρέψει η κλήση. Όταν έχεις πια το αποτέλεσμα στα χέρια σου, οι συντεταγμένες ανά μπλοκ έχουν χαθεί. Ποτέ δεν ήταν δικές σου για να τις κρατήσεις

Οι συντεταγμένες όμως επιβιώνουν σε μια διαφορετική δομή. ExtractPageTextBlocks(3) επιστρέφει έναν handle λίστας μπλοκ κειμένου, του οποίου τα στοιχεία μεταφέρουν το καθένα ένα οκταπλό διπλής ακρίβειας οριοθετικό τετράπλευρο, ένα όνομα γραμματοσειράς, ένα μέγεθος γραμματοσειράς και το κείμενο του μπλοκ. Αυτός ο handle είναι το μόνο σημείο όπου η γεωμετρία διατηρείται μετά την εξαγωγή, γι' αυτό και κάθε ένα από τα νέα query APIs βασίζεται σε αυτόν και όχι στη χοάνη. Η επαναχρησιμοποίηση της λίστας μπλοκ σημαίνει ότι η αναζήτηση, η απαρίθμηση και τα ερωτήματα περιοχής μοιράζονται μία μόνο διέλευση εξαγωγής και έναν μόνο ορισμό για το πού βρίσκεται ένα μπλοκ

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

type
  TPDFlibSearchHit = record
    Page: Integer;                       // 1-based page of the match
    Left, Top, Right, Bottom: Double;    // axis-aligned hit rectangle
    MatchText: WideString;               // the block text that contained the query
  end;

Ο πίνακας ορίων είναι εναλλασσόμενος X/Y, όχι τέσσερις γωνίες

Αυτή είναι η λεπτομέρεια που σε χτυπά πρώτη. GetTextBlockBound(ListID, Index, BoundIndex) δέχεται ένα BoundIndex από το 1 έως το 8, και αυτές οι οκτώ τιμές δεν είναι «γωνία 1, γωνία 2, γωνία 3, γωνία 4» με δύο πεδία για καθεμία ομαδοποιημένα όπως ίσως θα υπέθετες. Είναι X, Y, X, Y, X, Y, X, Y: οι περιττοί δείκτες είναι συντεταγμένες X, οι άρτιοι είναι συντεταγμένες Y, τέσσερα σημεία συνολικά. Αν τα διαβάσεις με λάθος ζεύγος, το ορθογώνιό σου δεν βγάζει νόημα

Ο λόγος που υπάρχει καθόλου τετράπλευρο, αντί για απλό ορθογώνιο, είναι η περιστροφή. Ένα μπλοκ κειμένου τοποθετημένο υπό γωνία έχει πραγματικό οριοθετικό πολύγωνο τεσσάρων σημείων, και οι οκτώ διπλές τιμές το περιγράφουν πιστά. Για τη χρήση επισήμανσης και μεταπήδησης συνήθως θέλεις ένα όρθιο πλαίσιο, οπότε η βιβλιοθήκη μειώνει το τετράπλευρο σε ορθογώνιο ευθυγραμμισμένο με τους άξονες σαρώνοντας τα τέσσερα σημεία για τα ελάχιστα και μέγιστα X και Y. Το περιστραμμένο κείμενο καταλήγει στο όρθιο πλαίσιο που το περικλείει, το οποίο είναι ακριβώς αυτό που χρειάζεται μια στρώση επισήμανσης:

var
  Pdf: TPDFlib;
  Hits: array[0..255] of TPDFlibSearchHit;
  Found, I: Integer;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.LoadFromFile('contract.pdf', '');
    // Search pages 1 to 10, case-insensitive, substring match.
    Found := Pdf.SearchText('indemnity', [], '1-10', Hits);
    for I := 0 to Found - 1 do
      if I <= High(Hits) then
        WriteLn(Format('p%d: [%.1f %.1f %.1f %.1f] %s',
          [Hits[I].Page, Hits[I].Left, Hits[I].Top,
           Hits[I].Right, Hits[I].Bottom, Hits[I].MatchText]));
  finally
    Pdf.Free;
  end;
end;

Σημείωσε ότι το ορθογώνιο βρίσκεται σε σημεία του PDF user space με την αρχή στο κάτω αριστερό μέρος της σελίδας, στο ίδιο σύστημα συντεταγμένων που χρησιμοποιείς στις κλήσεις σχεδίασης και σχολιασμού. Αυτό είναι σκόπιμο: το ορθογώνιο που παίρνεις πίσω από μια εύρεση αναζήτησης είναι το ορθογώνιο που μπορείς να δώσεις απευθείας σε έναν σχολιασμό επισήμανσης ή σε μια εντολή «κύλησε εδώ», χωρίς να μετατρέψεις τίποτα

Διάκριση πεζών-κεφαλαίων, ολόκληρες λέξεις και πού διαφέρει το CJK

Η δεύτερη παράμετρος είναι ένα TPDFlibSearchOptions σύνολο που προέρχεται από soCaseSensitive και soWholeWord. Το κενό σύνολο [] είναι η συνηθισμένη περίπτωση: αναζήτηση υποσυμβολοσειράς χωρίς διάκριση πεζών-κεφαλαίων. Πρόσθεσε soCaseSensitive για να κάνεις τα Indemnity και indemnity να ξεχωρίζουν, πρόσθεσε soWholeWord για να σταματήσεις το sign από το να ταιριάζει μέσα στο signature, ή συνδύασε και τα δύο

Η αντιστοίχιση ολόκληρης λέξης χρειάζεται έναν ορισμό για το τι είναι όριο λέξης, και εδώ ο κανόνας αξίζει να ειπωθεί καθαρά, επειδή έχει σχεδιαστεί με επίκεντρο το ASCII. Ένας χαρακτήρας μετρά ως μέρος μιας λέξης όταν είναι γράμμα ASCII, ψηφίο ASCII ή κάτω παύλα: η κλάση [A-Za-z0-9_] γνωστή από τους κανόνες αναγνωριστικών. Μια αντιστοίχιση χαρακτηρίζεται ολόκληρη λέξη μόνο όταν οι χαρακτήρες ακριβώς πριν και μετά από αυτήν είναι όχι χαρακτήρες λέξης (ή η αντιστοίχιση βρίσκεται στο άκρο του μπλοκ)

Η συνέπεια για τα μη λατινικά συστήματα γραφής είναι κάτι που αξίζει να γνωρίζεις πριν διαθέσεις ένα πολύγλωσσο πλαίσιο αναζήτησης. Επειδή οι χαρακτήρες Han, τα kana και άλλα μη ASCII γράμματα βρίσκονται έξω από αυτή την κλάση, κάθε όριο δίπλα τους διαβάζεται ως άκρη μη λέξης. Στην πράξη αυτό σημαίνει ότι η αναζήτηση ολόκληρης λέξης σε κείμενο CJK συμπεριφέρεται σαν κάθε θέση να είναι έγκυρο όριο λέξης, οπότε η σημαία ουσιαστικά υποβαθμίζεται σε αναζήτηση υποσυμβολοσειράς εκεί. Αυτό είναι τεκμηριωμένος περιορισμός, όχι σφάλμα, και ταιριάζει με τη συμπεριφορά στην οποία μοντελοποιήθηκε η δυνατότητα. Αν το corpus σου είναι κυρίως CJK, η λειτουργία whole-word δεν θα σου δώσει τον διαχωρισμό που θα παρείχε ένας ειδικός tokenizer, οπότε σχεδίασε γύρω από αυτό αντί να βασίζεσαι σε αυτό

Μια σημείωση υλοποίησης που εξηγεί μια κατηγορία λεπτών αστοχιών αλλού: η σύγκριση χωρίς διάκριση πεζών-κεφαλαίων χρησιμοποιεί UpperCase στο WideString, όχι AnsiUpperCase. Η παραλλαγή Ansi επιστρέφει ένα AnsiString, το οποίο δεν θα ταίριαζε με το WideString που χρησιμοποιεί το υπόλοιπο μονοπάτι, και ο συνδυασμός των δύο προκαλεί ασυμφωνίες τύπων και, χειρότερα, απώλειες στην αναδίπλωση για χαρακτήρες έξω από την ενεργή κωδική σελίδα. Unicode μέσα, Unicode έξω, σε όλη τη διαδρομή

Ένας αναλυτής εύρους σελίδων για ολόκληρη τη βιβλιοθήκη

Η τρίτη παράμετρος είναι μια συμβολοσειρά εύρους σελίδων όπως "1,3,5-9". Δεν υπάρχει τίποτα ειδικό στον τρόπο με τον οποίο αναλύεται: ο ίδιος PLParsePageRangeList που τροφοδοτεί το PrintPages και τις ρουτίνες αντιγραφής σελίδων το χειρίζεται και εδώ, έτσι ένα εύρος που τυπώνεται σωστά αναζητείται σωστά. Μια κενή συμβολοσειρά εύρους είναι το σήμα για "κάθε σελίδα", οπότε SearchText δημιουργεί μόνο του την πλήρη λίστα

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

Εσωτερικά, η αναζήτηση και η απαρίθμηση αλλάζουν και οι δύο την επιλεγμένη σελίδα καθώς επαναλαμβάνονται, οπότε καθεμία αποθηκεύει την επιλεγμένη σελίδα του καλούντος κατά την είσοδο και την αποκαθιστά μέσα σε ένα finally μπλοκ. Καλέστε το SearchText στη μέση της δημιουργίας μιας σελίδας και η επιλογή σας θα είναι ακριβώς εκεί όπου την αφήσατε όταν επιστρέψει η κλήση. Αυτή η σύμβαση αποθήκευσης και επαναφοράς είναι το είδος πράγματος που το προσέχεις μόνο όταν λείπει, και ακριβώς γι' αυτό υπάρχει

Απαρίθμηση ολόκληρης σελίδας: κείμενο και εικόνες σε μία λίστα

Η αναζήτηση απαντά στο "πού βρίσκεται αυτή η λέξη". Το άλλο μισό της διερεύνησης είναι το "τι υπάρχει καθόλου σε αυτή τη σελίδα", και αυτό είναι EnumPageElements. Επιστρέφει μία ενιαία λίστα όπου κάθε στοιχείο είναι είτε ένα μπλοκ κειμένου είτε μια ενσωματωμένη εικόνα, διακριτό από ένα Kind πεδίο:

type
  TPDFlibPageElementKind = (ekText, ekImage);

  TPDFlibPageElement = record
    Kind: TPDFlibPageElementKind;
    Page: Integer;
    Left, Top, Right, Bottom: Double;
    Text: WideString;        // ekText
    FontName: WideString;    // ekText
    FontSize: Double;        // ekText
    ImageID: Integer;        // ekImage; usable with SelectImage / GetImageID
  end;

Τα στοιχεία κειμένου προέρχονται από το ίδιο ExtractPageTextBlocks πέρασμα, οπότε καθένα φτάνει ήδη συμπληρωμένο με το ορθογώνιό του, το όνομα της γραμματοσειράς του και το μέγεθός του. Τα στοιχεία εικόνας προέρχονται από τη λίστα ενσωματωμένων εικόνων της σελίδας μέσω FindImages και GetImageID; το ImageID που φέρουν είναι η λαβή που δίνεις στο SelectImage για να εξετάσεις περαιτέρω την εικόνα. Οι δύο τύποι καταλήγουν σε έναν πίνακα, ώστε μία μόνο διέλευση μιας σελίδας να βλέπει τα πάντα πάνω της

var
  Pdf: TPDFlib;
  Elems: array[0..511] of TPDFlibPageElement;
  Total, I: Integer;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.LoadFromFile('report.pdf', '');
    Total := Pdf.EnumPageElements(1, Elems);
    for I := 0 to Total - 1 do
      if I <= High(Elems) then
        if Elems[I].Kind = ekText then
          WriteLn(Format('text  %s/%.1f  "%s"',
            [Elems[I].FontName, Elems[I].FontSize, Elems[I].Text]))
        else
          WriteLn(Format('image id=%d', [Elems[I].ImageID]));
  finally
    Pdf.Free;
  end;
end;

Υπάρχει εδώ μια σύμβαση μέτρησης που ακολουθεί το υπόλοιπο της βιβλιοθήκης και πρέπει να την τηρήσετε, αλλιώς θα διαβάσετε μη αρχικοποιημένη μνήμη. Η τιμή επιστροφής είναι το total πλήθος στοιχείων, το οποίο μπορεί να είναι μεγαλύτερο από τον πίνακα που περάσατε. Η συνάρτηση συμπληρώνει μόνο όσες θέσεις χωρούν και συνεχίζει να μετρά τις υπόλοιπες, ακριβώς όπως λειτουργεί η απαρίθμηση υπογραφών. Άρα η προστασία είναι πάντα η ίδια: περιορίστε το loop στο μικρότερο από το επιστρεφόμενο πλήθος και το High(array), μην επαναλαμβάνεστε τυφλά μέχρι το πλήθος. Τα παραπάνω παραδείγματα δείχνουν τον έλεγχο I <= High(...) γι' αυτόν ακριβώς τον λόγο. Αν η τιμή επιστροφής ξεπερνά το buffer σας, ορίστε μεγαλύτερο πίνακα και καλέστε ξανά

Αν έχετε χρησιμοποιήσει τις χαμηλότερου επιπέδου κλήσεις μπλοκ κειμένου της βιβλιοθήκης, αυτό είναι το τυποποιημένο, γεωμετρικά ενήμερο επίπεδο πάνω από αυτές· η υποκείμενη εξαγωγή είναι η ίδια που περιγράφεται στο Εξαγωγή κειμένου, εικόνας και γραμματοσειράς PDF στο Delphi με το PDFlibPas. Και όταν ο στόχος δεν είναι το "πού βρίσκεται αυτό το κείμενο" αλλά το "πώς είναι δομημένο αυτό το έγγραφο για βοηθητική τεχνολογία", η παράλληλη ιστορία από την πλευρά της ανάγνωσης είναι το δέντρο δομής tagged-PDF, το οποίο αποκαλύπτει τη λογική σειρά ανάγνωσης και όχι τη φυσική διάταξη των μπλοκ

Περιοχικές αναζητήσεις όταν ήδη ξέρεις πού να κοιτάξεις

Μερικές φορές δεν έχετε καθόλου όρο αναζήτησης, έχετε ένα ορθογώνιο. Ένα πρότυπο φόρμας τοποθετεί πάντα τον αριθμό τιμολογίου στην επάνω δεξιά γωνία ή μια σαρωμένη διάταξη δεσμεύει μια σταθερή ζώνη για έναν πίνακα. GetTextInAreaEx εξυπηρετεί αυτή την περίπτωση. Είναι το αντίστοιχο που μεταφέρει ορθογώνιο ορίων του GetTextInArea: όπου η παλαιότερη κλήση επιστρέφει μια επίπεδη λίστα συμβολοσειρών για μια περιοχή, η νέα επιστρέφει το ορθογώνιο κάθε διατηρημένου μπλοκ μαζί με το κείμενό του, ώστε να μαθαίνεις όχι μόνο τι υπάρχει μέσα στο πλαίσιο αλλά και πού ακριβώς μέσα του βρίσκεται κάθε γραμμή

var
  Pdf: TPDFlib;
  Hits: array[0..63] of TPDFlibSearchHit;
  Found, I: Integer;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.LoadFromFile('invoice.pdf', '');
    Pdf.SelectPage(1);
    // Left, Top, Width, Height in PDF points on the selected page.
    Found := Pdf.GetTextInAreaEx(360, 720, 180, 60, Hits);
    for I := 0 to Found - 1 do
      if I <= High(Hits) then
        WriteLn(Hits[I].MatchText);
  finally
    Pdf.Free;
  end;
end;

Δύο πράγματα που πρέπει να μείνουν ξεκάθαρα. GetTextInAreaEx λειτουργεί στην τρέχουσα επιλεγμένη σελίδα, οπότε κάλεσε SelectPage πρώτα· σε αντίθεση με το SearchText, δεν δέχεται εύρος. Και ένα μπλοκ διατηρείται όταν intersects το ορθογώνιο του ερωτήματος, όχι μόνο όταν περιέχεται πλήρως, οπότε μια γραμμή που περνά τα όρια εξακολουθεί να επιστρέφει. Αυτό συνήθως είναι ό,τι θέλεις για ένα πλαίσιο επιλογής που σχεδιάζεις με το χέρι, αλλά αν χρειάζεσαι αυστηρή εγκλεισμό μπορείς να φιλτράρεις μόνος σου τα ορθογώνια που επιστράφηκαν, αφού πλέον τα έχεις

Εφαρμογή στην πράξη

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

Αυτά τα query APIs διατίθενται ως μέρος του PDFlibPas Delphi PDF Library, μαζί με ολόκληρο το επίπεδο εξαγωγής μπλοκ κειμένου πάνω στο οποίο βασίζονται και το υπόλοιπο σύνολο εργαλείων επιθεώρησης της πλευράς ανάγνωσης για Delphi και C++Builder