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

Επιλογή Γραμμής Κειμένου PDF με Char Boxes PDFium σε Delphi

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

Το σύμπτωμα που σας στέλνει να ψάχνετε αυτό είναι συγκεκριμένο και δυσάρεστο. Ένας χρήστης κάνει triple-click σε μια παράγραφο σε μια αναφορά δύο στηλών και παίρνει τη μισή σελίδα. Ή κάνει triple-click σε ένα κελί πίνακα και η επιλογή καταπίνει ολόκληρη τη γραμμή συν τον αριθμό σελίδας στο footer. Ο viewer δεν είναι σπασμένος· ρωτά μια ερώτηση που το αρχείο δεν μπορεί να απαντήσει. Δεν υπάρχει γραμμή σε ένα PDF να επιλέξετε, και οποιαδήποτε υλοποίηση που προσποιείται το αντίθετο μαντεύει. Αυτό το άρθρο αφορά το να κάνετε το μάντεμα σκόπιμο και συνεπές. Αν αυτό που χρειάζεστε στην πραγματικότητα είναι να τραβήξετε κείμενο από ένα έγγραφο, δείτε την εξαγωγή κειμένου από έγγραφα PDF με PDFium· αν σχεδιάζετε κείμενο και χρειάζεστε πλάτη, δείτε τη μέτρηση κειμένου και αναδίπλωση λέξεων. Εδώ το θέμα είναι στενότερο: απόφαση για το πού ξεκινά και τελειώνει μια οπτική γραμμή, και επιλογή ακριβώς αυτού

Γιατί μια σελίδα κειμένου PDF δεν έχει αντικείμενα γραμμής;

Επειδή ένα ρεύμα περιεχομένου PDF περιγράφει σχέδιο, όχι δομή. Το ISO 32000-1 §9.4 ορίζει ένα αντικείμενο κειμένου ως ζεύγος BT / ET που περιέχει τελεστές τοποθέτησης και εμφάνισης. Οι τελεστές τοποθέτησης του §9.4.2 (Td, TD, Tm, T*) μετακινούν έναν πίνακα κειμένου γύρω από τη σελίδα, και οι τελεστές εμφάνισης του §9.4.3 (Tj, TJ, ', ") ζωγραφίζουν glyphs όπου κι αν δείχνει εκείνη τη στιγμή ο πίνακας. Τίποτα σε αυτό το μοντέλο δεν λέει «αυτή η ακολουθία glyphs είναι μια γραμμή». Μια γραμμή είναι αυτό που βλέπει ένας άνθρωπος αφού τελειώσει το ζωγράφισμα

Οι παραγωγοί το κάνουν χειρότερο με τρόπους που δεν μπορείτε να ελέγξετε. Μια παράγραφος με πλήρη στοίχιση μπορεί να εκπέμπεται ως ένας πίνακας TJ ανά γραμμή, ή ως ένα Tj ανά λέξη με ρητό Tm πριν από κάθε μία, ή ως μία μόνο λειτουργία εμφάνισης με προσαρμογές kerning να μεταφέρουν την απόσταση. Μια διάταξη δύο στηλών μπορεί να εκπέμψει την αριστερή στήλη από πάνω προς τα κάτω και μετά τη δεξιά στήλη, ή μπορεί να τις παρεμβάλει αν ο παραγωγός περπάτησε τη δική του εσωτερική λίστα αντικειμένων με διαφορετική σειρά. Η ακολουθία χαρακτήρων που σας δίνει το PDFium ακολουθεί το ρεύμα περιεχομένου, και το ρεύμα περιεχομένου ακολουθεί ό,τι κι αν ένιωσε η παράγουσα εφαρμογή να κάνει. Οπότε οι δύο συναρτήσεις που πραγματικά παίρνετε είναι η FPDFText_CountChars, που αναφέρει πόσους χαρακτήρες κρατά η σελίδα, και η FPDFText_GetCharBox, που επιστρέφει το κουτί οριοθέτησης ενός χαρακτήρα στον χώρο σελίδας. Αυτό είναι όλο το ακατέργαστο λεξιλόγιο. Οτιδήποτε πάνω από αυτό, λέξεις, γραμμές, παράγραφοι, στήλες, είναι συμπερασμός που εκτελείτε πάνω σε γεωμετρία

Γιατί η ανίχνευση CR και LF είναι λάθος δοκιμή;

Επειδή οι χαρακτήρες έναντι των οποίων θα δοκιμάζατε δεν είναι αξιόπιστα παρόντες, και όταν είναι παρόντες δεν είναι αξιόπιστα δικοί σας. Το PDFium εισάγει συνθετικούς χαρακτήρες στη σελίδα κειμένου για να κάνει το εξαγόμενο κείμενο αναγνώσιμο: ένα διάστημα όπου δύο τμήματα διαχωρίζονται οπτικά, ένα CR ή LF όπου το επόμενο τμήμα ξεκινά σε νέα γραμμή βάσης. Η FPDFText_IsGenerated υπάρχει ακριβώς ώστε να μπορείτε να τους διακρίνετε από χαρακτήρες που βγήκαν από το αρχείο, και το PDFium Component το εκθέτει ως την ιδιότητα CharacterGenerated

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

Ομαδοποίηση κουτιών χαρακτήρων κατά κάθετο κέντρο

Το αξιόπιστο σήμα είναι η γεωμετρία. Πάρτε τον χαρακτήρα που πάτησε ο χρήστης ως σπόρο, υπολογίστε το κάθετο κέντρο του κουτιού του, και περπατήστε προς τα έξω και στις δύο κατευθύνσεις ενώ τα γειτονικά κουτιά κρατούν τα κάθετα κέντρα τους μέσα σε ανοχή. Το PDFium Component χρησιμοποιεί το μισό του ύψους κουτιού σπόρου ως αυτή την ανοχή, με ένα κατώτατο όριο 0,5 μονάδων σελίδας ώστε εκφυλισμένα κουτιά, μια τελεία, ένα λεπτό διάστημα, ένα glyph με κουτί σχεδόν μηδενικού ύψους, να μη συμπτύσσουν την ανοχή στο τίποτα και να κόβουν τη γραμμή μετά από έναν χαρακτήρα

function TPdfView.LineRangeAt(TxtPage: FPDF_TEXTPAGE; CharIndex: Integer;
  out StartIndex, Count: Integer): Boolean;
var
  Lo, Hi, Total: Integer;
  SeedBox, Box: TPdfRectangle;
  SeedYMid, BoxYMid, HalfH: Double;
begin
  Result := False;
  StartIndex := -1;
  Count := 0;
  Total := FPDFText_CountChars(TxtPage);
  if (CharIndex < 0) or (CharIndex >= Total) then
    Exit;

  if FPDFText_GetCharBox(TxtPage, CharIndex, SeedBox.Left, SeedBox.Right,
    SeedBox.Bottom, SeedBox.Top) = 0 then
    Exit;
  SeedYMid := (SeedBox.Top + SeedBox.Bottom) / 2;
  HalfH := Abs(SeedBox.Top - SeedBox.Bottom) / 2;
  if HalfH < 0.5 then          // floor for degenerate boxes
    HalfH := 0.5;

  Lo := CharIndex;
  Hi := CharIndex;
  while Lo > 0 do
  begin
    if FPDFText_GetCharBox(TxtPage, Lo - 1, Box.Left, Box.Right,
      Box.Bottom, Box.Top) = 0 then
      Break;
    BoxYMid := (Box.Top + Box.Bottom) / 2;
    if Abs(BoxYMid - SeedYMid) > HalfH then
      Break;
    Dec(Lo);
  end;
  while Hi < Total - 1 do
  begin
    if FPDFText_GetCharBox(TxtPage, Hi + 1, Box.Left, Box.Right,
      Box.Bottom, Box.Top) = 0 then
      Break;
    BoxYMid := (Box.Top + Box.Bottom) / 2;
    if Abs(BoxYMid - SeedYMid) > HalfH then
      Break;
    Inc(Hi);
  end;
  StartIndex := Lo;
  Count := Hi - Lo + 1;
  Result := True;
end;

Τρεις λεπτομέρειες σε αυτόν τον βρόχο κερδίζουν τη θέση τους. Η ανοχή παράγεται από τον σπόρο αντί από σταθερά, οπότε ένας τίτλος 24pt παίρνει μια ευρεία ζώνη και κείμενο υποσημείωσης 7pt παίρνει στενή, και κανένα δεν κλέβει χαρακτήρες από το άλλο. Η σύγκριση χρησιμοποιεί κάθετα κέντρα αντί για γραμμές βάσης ή κορυφές κουτιών, που κρατά έναν εκθέτη, ένα ενσωματωμένο τμήμα διαφορετικού μεγέθους, ή μια πρόταση με μικτή γραμματοσειρά στην ίδια γραμμή με τους γείτονές της. Και μια αποτυχημένη FPDFText_GetCharBox τερματίζει τη σάρωση αντί να παραλείπεται, επειδή ένας χαρακτήρας χωρίς ανακτήσιμη γεωμετρία δεν σας δίνει καμία απόδειξη με κανέναν τρόπο, και η συνέχιση πέρα από αυτόν θα επέτρεπε στη διαδρομή να πηδήξει πάνω από ένα γνήσιο όριο με βάση έναν χαρακτήρα παρακάτω

Γιατί κάθε διαδρομή επιλογής πρέπει να μοιράζεται έναν βοηθό;

Επειδή τρεις διαδρομές κώδικα που καθεμιά υλοποιεί «τη γραμμή» θα αποκλίνουν, και θα αποκλίνουν ήσυχα. Στο PDFium Component, η επέκταση triple-click, το Shift+Home, το Shift+End, και η δημόσια μέθοδος SelectLineAt όλα επιλύουν τα όριά τους μέσω της ίδιας κλήσης LineRangeAt. Το triple-click τη σπέρνει από την άγκυρα επιλογής· τα πλήκτρα shift την σπέρνουν από τον δείκτη επιλογής και μετακινούν μόνο εκείνο το άκρο· η SelectLineAt τη σπέρνει από έναν δείκτη χαρακτήρα παρεχόμενο από τον καλούντα και δίνει το αποτέλεσμα στην SelectTextRange, τον ίδιο επικυρωτή εύρους που χρησιμοποιεί η διαδρομή ποντικιού. Διπλασιάστε τη λογική αντ' αυτού και η αποτυχία δεν είναι κατάρρευση, είναι αργή απόκλιση. Κάποιος ρυθμίζει την ανοχή triple-click για να διορθώσει μια αναφορά με στενή απόσταση γραμμών, και τώρα το Shift+End σταματά έναν χαρακτήρα νωρίτερα από όπου σταματά το triple-click στην ίδια παράγραφο. Ένας χρήστης επιλέγει μια γραμμή με το ποντίκι, την επεκτείνει με το πληκτρολόγιο, και βλέπει την επιλογή να συρρικνώνεται. Επειδή η SelectLineAt τροφοδοτεί τη συνηθισμένη pipeline επιλογής, η προγραμματιστική επιλογή παραμένει επίσης ανεξάρτητη από το αν η είσοδος ποντικιού είναι ενεργή, και εξακολουθεί να παίρνει επικύρωση εύρους, επανασχεδίαση, και την ειδοποίηση OnSelectionChange δωρεάν

// Select the visual line under a client-space point, then read it back
procedure TForm1.SelectLineUnderCursor(X, Y: Integer);
var
  CharIndex: Integer;
begin
  CharIndex := PdfView1.CharacterIndexAtPos(X, Y, 6.0, 6.0);
  if CharIndex < 0 then
    Exit;
  if PdfView1.SelectLineAt(PdfView1.CurrentPage, CharIndex) then
    Memo1.Lines.Add(PdfView1.SelectedText);
end;

Σημειώστε τα ορίσματα ανοχής στην CharacterIndexAtPos. Το hit testing έχει τη δική του ανοχή, εκφρασμένη σε μονάδες σελίδας, και είναι ξεχωριστό ζήτημα από την ανοχή γραμμής. Ένα κλικ που προσγειώνεται στο leading ανάμεσα σε δύο γραμμές επιλύεται στον όποιο χαρακτήρα είναι πλησιέστερος μέσα σε εκείνο το κουτί· η σάρωση γραμμής μετά τρέχει από όποιον χαρακτήρα αποδείχθηκε ότι είναι αυτός. Η τροφοδότηση μιας υπερβολικά γενναιόδωρης ανοχής hit στον σπόρο είναι ένας από τους ευκολότερους τρόπους να επιλέξετε μια γραμμή στην οποία ο χρήστης δεν έδειχνε

Δύο χώροι δεικτών: δείκτης χαρακτήρα και δείκτης κειμένου

Μόλις έχετε ένα εύρος, αντισταθείτε στην παρόρμηση να το χρησιμοποιήσετε ως offset string. Η FPDFText_GetText επιστρέφει το κείμενο σελίδας ως buffer UTF-16, αλλά οι δείκτες της δεν είναι ο ίδιος χώρος δεικτών με τους δείκτες χαρακτήρων που χρησιμοποιούν οι FPDFText_GetCharBox και FPDFText_CountChars. Οι παραγόμενοι χαρακτήρες που συζητήθηκαν νωρίτερα κάθονται στο buffer κειμένου ενώ καταλαμβάνουν θέσεις χαρακτήρων χωρίς χρησιμοποιήσιμη γεωμετρία, και οι δύο αριθμήσεις αποκλίνουν κατά μήκος της σελίδας. Οι γέφυρες είναι οι FPDFText_GetTextIndexFromCharIndex και FPDFText_GetCharIndexFromTextIndex, τυλιγμένες από το PDFium Component ως CharacterIndexToTextIndex και TextIndexToCharacterIndex

var
  TextStart, TextEnd: Integer;
begin
  // char-index range from LineRangeAt -> offsets into the page text buffer
  TextStart := Pdf.CharacterIndexToTextIndex(StartIndex);
  TextEnd   := Pdf.CharacterIndexToTextIndex(StartIndex + Count - 1);
  if (TextStart >= 0) and (TextEnd >= TextStart) then
    Caption := Pdf.Text(TextStart, TextEnd - TextStart + 1);
end;

Η κατεύθυνση που δαγκώνει πιο σκληρά είναι η αντίστροφη. Μια αναζήτηση υλοποιημένη πάνω στο εξαγόμενο string σας δίνει δείκτες κειμένου, και η μετάδοσή τους απευθείας σε ένα API κουτιού ή επιλογής απευθύνεται σιωπηλά σε λάθος χαρακτήρες, με ένα σφάλμα που μεγαλώνει όσο πιο κάτω πηγαίνετε στη σελίδα. Μετατρέψτε με την TextIndexToCharacterIndex πριν αγγίξει τον αριθμό οτιδήποτε γεωμετρικό. Τα ζεύγη surrogate προσθέτουν ένα δεύτερο, ανεξάρτητο πρόβλημα offset πάνω από αυτό, που καλύπτεται στο άρθρο για emoji, CJK, και ζεύγη surrogate

Πού λυγίζει η ευρετική

Να είστε ειλικρινείς με τον εαυτό σας για τα όρια, επειδή είναι πραγματικά και προσβάσιμα. Το περιστραμμένο κείμενο είναι η πιο ξεκάθαρη περίπτωση: ένα κουτί χαρακτήρα είναι ένα ορθογώνιο ευθυγραμμισμένο με τους άξονες στον χώρο σελίδας, οπότε για κείμενο περιστραμμένο 90 μοίρες τα κουτιά μιας οπτικής γραμμής έχουν κάθετα κέντρα διάσπαρτα σε όλη τη σελίδα, και η σάρωση σταματά σχεδόν αμέσως. Αυτό που παίρνετε είναι μια σύντομη επιλογή αντί για λάθος, που είναι η καλύτερη κατάσταση αποτυχίας, αλλά εξακολουθεί να είναι αποτυχία. Οι κάθετες λειτουργίες γραφής συμπεριφέρονται με τον ίδιο τρόπο για τον ίδιο λόγο. Διατάξεις δύο στηλών λειτουργούν όταν οι στήλες είναι κάθετα μετατοπισμένες η μία από την άλλη και σπάνε όταν δεν είναι. Αν και οι δύο στήλες μοιράζονται ένα πλέγμα γραμμής βάσης, χαρακτήρες από τη δεξιά στήλη κάθονται μέσα στην ανοχή της γραμμής της αριστερής στήλης, και η σάρωση θα τρέξει κατευθείαν πάνω από το κενό, επειδή σε καθαρή γεωμετρία δεν υπάρχει τίποτα εκεί να σταματήσει. Η ανίχνευση αυτού χρειάζεται μια δοκιμή οριζόντιου κενού πάνω από την κάθετη ομαδοποίηση, και η επιλογή του κατωφλίου κενού είναι δική της κρίση σχετικά με ποια έγγραφα είστε διατεθειμένοι να κάνετε λάθος. Τα μικτά μεγέθη γραμματοσειράς είναι η περίπτωση που η ανοχή σχετική με τον σπόρο χειρίζεται καλά: ένα ενσωματωμένο τμήμα κώδικα 8pt μέσα σε κείμενο σώματος 11pt κρατά το κέντρο του μέσα στη ζώνη, και ένας τίτλος 24pt στην επόμενη γραμμή βάσης δεν τραβά τη γραμμή σώματος μέσα του

Η σημασιολογία επιλογής γραμμής που περιγράφεται εδώ διατίθεται στο PDFium Component για Delphi και C++Builder, μαζί με τα API hit testing, εύρους επιλογής, και δείκτη κειμένου που χρησιμοποιούνται στα παραδείγματα· η σελίδα προϊόντος φέρει την πλήρη αναφορά για τη σελίδα κειμένου και το μοντέλο επιλογής