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

Διόρθωση false positives πινάκων PDF από justified text

Το PDFium Component έκδοση 3.117.0 σταματά να αναφέρει justified παραγράφους ως whitespace-aligned tables απαιτώντας κάθε όριο στήλης να είναι κατακόρυφος διάδρομος χωρίς text σε καμία γραμμή που χωρίζει, προσπερνώντας λέξεις που διεκδικούνται ήδη από ruled grid, και συναρμολογώντας text κελιών με κατακόρυφη επικάλυψη αντί για απόσταση κέντρων glyph-box. Και οι τρεις αλλαγές ζουν μέσα στο ExtractTables και ExtractDocumentTables και δεν χρειάζονται καμία επιλογή

Η αναφορά που το ξεκίνησε δεν είχε λαμπρότητα. Μια σελίδα δελτίου τύπου χωρίς κανένα πίνακα γύριζε από το ExtractTables με whitespace table 5x4, confidence άνετα πάνω από το default MinConfidence του 0.5, και τα κελιά κρατούσαν τμήματα συνηθισμένου body text. Μια φόρμα εισαγωγής έκανε το ίδιο με τις παραγράφους δοκιμίων της και παρήγαγε 3x4 και 5x3. Και τα δύο έγγραφα είχαν στηθεί justified. Η προφανής απάντηση είναι να ρυθμίσεις τα κατώφλια, και το χρήσιμο μάθημα από αυτή την έκδοση είναι ότι το tuning δεν μπορεί να το διορθώσει, επειδή ο κανόνας που ρυθμιζόταν έθετε τη λάθος ερώτηση

uses
  PDFium;

// Έλεγχος regression: απαρίθμησε κάθε whitespace table σε έγγραφο ώστε σελίδα
// που ξέρεις ότι είναι μόνο πεζογραφία να επαληθευτεί καθαρή
procedure ReportWhitespaceTables(Pdf: TPdf);
var
  Options: TPdfTableExtractionOptions;
  Tables: TPdfTables;
  I: Integer;
begin
  Options := TPdfTableExtractionOptions.Default;   // MinColumnGap 12pt
  Tables := Pdf.ExtractDocumentTables(Options);
  for I := 0 to High(Tables) do
    if Tables[I].DetectionMode = ptdmWhitespace then
      Writeln(Format('page %d: %dx%d whitespace table, confidence %.2f, ' +
        'first cell "%s"',
        [Tables[I].PageNumber, Tables[I].RowCount, Tables[I].ColumnCount,
         Tables[I].Confidence, Tables[I].Cells[0].Text]));
end;

Γιατί το justified text μοιάζει με πίνακα;

Μια justified παράγραφος μοιάζει με πίνακα επειδή μια justified γραμμή είναι σειρά λέξεων χωρισμένων με κενά που το layout engine τέντωσε, και μόλις ένα τεντωμένο κενό φτάσει το MinColumnGap ο detector δεν έχει κανέναν row-local τρόπο να το ξεχωρίσει από διαχωριστικό στήλης. Η στρατηγική whitespace στο PDFium Component ομαδοποιεί word boxes σε οπτικές γραμμές, σχίζει κάθε γραμμή σε ομάδες λέξεων όπου η οριζόντια απόσταση από την προηγούμενη λέξη είναι τουλάχιστον MinColumnGap (12 πόντοι από προεπιλογή), και δέχεται πίνακα όταν τουλάχιστον δύο διαδοχικές γραμμές επαναλαμβάνουν τουλάχιστον MinColumns αγκυρώσεις ομάδων left-aligned εντός AlignmentTolerance, που είναι 3 πόντοι. Εκείνος είναι ο κανόνας που περιγράφει το overview ανίχνευσης πινάκων, και για γνήσιο ευθυγραμμισμένο πίνακα είναι ακριβώς σωστός

Τώρα εφάρμοσέ το σε είκοσι γραμμές justified πεζογραφίας 10 πόντων. Κάθε γραμμή τεντώνεται ως το ίδιο δεξί περιθώριο, οπότε μια γραμμή που τελειώνει με μακριά λέξη ανοίγει τα εσωτερικά της κενά, και σε παράγραφο με λίγες κοντές γραμμές μερικά από εκείνα τα κενά διασχίζουν τους 12 πόντους. Δύο διαδοχικές γραμμές χρειάζονται μόνο ένα τεντωμένο κενό η καθεμία, προσγειωμένο εντός 3 πόντων από την ίδια θέση X, για να σχηματίσουν υποψήφιο δύο γραμμών, δύο στηλών. Σε αρκετές γραμμές αυτό δεν είναι κακή τύχη· είναι πιθανότητα που πλησιάζει τη βεβαιότητα, και το 5x4 στο δελτίο τύπου ήταν απλώς η εκτέλεση όπου τέσσερα τέτοια κενά ευθυγραμμίστηκαν πάνω σε πέντε γραμμές

Διάγραμμα PDFium Component για το γιατί justified πεζογραφία σκοράρισε ως πίνακας: κάθε γραμμή τεντώνεται ως το ίδιο περιθώριο οπότε μεμονωμένα κενά διασχίζουν MinColumnGap σε διαφορετική X ανά γραμμή, και δύο διαδοχικά κενά εντός AlignmentTolerance χτίσανε τους ψευδείς υποψηφίους που το corridor test απορρίπτει πια
Ένας πραγματικός πίνακας επαναλαμβάνει τις αγκυρώσεις στηλών του σε κάθε γραμμή, ενώ μια justified παράγραφος τεντώνει διαφορετικό κενό σε κάθε γραμμή, γι' αυτό το tuning επιπέδου γραμμών από μόνο του δεν μπορούσε να χωρίσει τα δύο

Κάθε κατώφλι ανταλλάσσει μια κατηγορία εγγράφου με άλλη. Ανεβάζοντας το MinColumnGap στους 20 πόντους χάνεις τις συμπαγείς στήλες πυκνών οικονομικών reports, που είναι η ακριβής περίπτωση για την οποία το default είχε ήδη χαμηλώσει. Ανεβάζοντας το MinRows στο 3 απορρίπτεις πραγματικούς πίνακες δύο γραμμών και απλώς χαμηλώνεις τις πιθανότητες για μεγάλες παραγράφους. Σφίγγοντας το AlignmentTolerance κάτω από 3 πόντους σπάς word boxes προερχόμενα από OCR, του οποίου τα αριστερά άκρα τρέμουν περισσότερο από αυτό. Το σήμα επιπέδου γραμμών είναι πράγματι αμφίσημο, οπότε το fix πρέπει να έρθει από σήμα που οι γραμμές δεν κουβαλάνε από μόνες τους

Τι κάνει ένα όριο στήλης πραγματικό;

Πραγματικό όριο στήλης είναι μια κατακόρυφη λωρίδα της σελίδας που μένει άδεια σε κάθε γραμμή που χωρίζει. Ένας πίνακας έχει μία ανάμεσα σε κάθε ζευγάρι στηλών εκ κατασκευής, επειδή τα κελιά τοποθετήθηκαν απέναντι σε κοινές θέσεις X. Μια justified παράγραφος τεντώνει τα κενά λέξεων της σε διαφορετικές οριζόντιες θέσεις σε κάθε γραμμή, οπότε καμία λωρίδα δεν επιβιώνει από την τομή περισσότερων από μία-δυο γραμμών. Το PDFium Component τεστάρει πλέον ακριβώς αυτό: αφού οι ομάδες λέξεων του υποψηφίου έχουν ανατεθεί σε στήλες αγκυρώσεων, για κάθε ζευγάρι γειτονικών στηλών παίρνει, σε κάθε γραμμή που έχει περιεχόμενο και στα δύο κελιά, το διάστημα από το δεξιότερο άκρο των λέξεων του αριστερού κελιού έως το αριστερότερο άκρο των λέξεων του δεξιού κελιού, τέμνει εκείνα τα διαστήματα πάνω στις γραμμές, και απορρίπτει ολόκληρο τον υποψήφιο αν η τομή είναι στενότερη από MinColumnGap επί 0.5, που είναι 6 πόντοι στο default

Διάγραμμα PDFium Component για το test διαδρόμου χωρίς text πίσω από το ExtractTables: κάθε γραμμή συνεισφέρει το διάστημα από το δεξί άκρο του αριστερού κελιού της έως το αριστερό άκρο του δεξιού κελιού της, η τομή μένει φαρδύτερη από το μισό MinColumnGap σε πραγματικό πίνακα και καταρρέει στο τίποτα σε justified text
Γνήσιο όριο στήλης είναι άδειο σε κάθε γραμμή που χωρίζει, οπότε η τομή των κενών ανά γραμμή αφήνει κοινή λωρίδα για πίνακα και καθόλου λωρίδα για τεντωμένη πεζογραφία

Δύο λεπτομέρειες μετράνε. Γραμμές όπου το ένα από τα δύο κελιά είναι κενό δεν ψηφίζουν, οπότε πίνακας με κενό κελί, ή header που απλώνεται σε λιγότερες στήλες από το σώμα, εξακολουθεί να περνά. Και το πλάτος του διαδρόμου προκύπτει από το MinColumnGap αντί να εκτεθεί ως ξεχωριστή επιλογή, επειδή τα δύο περιγράφουν το ίδιο φυσικό πράγμα: το κενό που αφήνει ένας σχεδιαστής ανάμεσα στις στήλες. Η λογική είναι αρκετά μικρή για να την αναπαραγάγεις αν χτίζεις πάνω σε raw word boxes αντί για το table API, και το δείγμα παρακάτω κατοπτρίζει τον έλεγχο μέσα στο component:

uses
  Math, PDFium;

type
  TIndexList = array of Integer;
  TCellIndexes = array of TIndexList;   // Row * ColumnCount + Column

// Γυρνά False όταν οποιοδήποτε ζευγάρι γειτονικών στηλών στερείται κατακόρυφου
// διαδρόμου χωρίς text πλάτους τουλάχιστον MinColumnGap / 2 στις γραμμές του
function HasTextFreeCorridors(const Words: TPdfWordBoxes;
  const Cells: TCellIndexes; RowCount, ColumnCount: Integer;
  MinColumnGap: Double): Boolean;
var
  Col, Row, I, LeftCell, RightCell, Supported: Integer;
  CorridorLeft, CorridorRight, RowLeft, RowRight: Double;
begin
  for Col := 0 to ColumnCount - 2 do
  begin
    CorridorLeft := -MaxDouble;
    CorridorRight := MaxDouble;
    Supported := 0;
    for Row := 0 to RowCount - 1 do
    begin
      LeftCell := Row * ColumnCount + Col;
      RightCell := LeftCell + 1;
      if (Length(Cells[LeftCell]) = 0) or (Length(Cells[RightCell]) = 0) then
        Continue;                             // κενά κελιά δεν ψηφίζουν
      RowLeft := -MaxDouble;
      RowRight := MaxDouble;
      for I in Cells[LeftCell] do
        RowLeft := Max(RowLeft, Words[I].Rect.Right);
      for I in Cells[RightCell] do
        RowRight := Min(RowRight, Words[I].Rect.Left);
      CorridorLeft := Max(CorridorLeft, RowLeft);
      CorridorRight := Min(CorridorRight, RowRight);
      Inc(Supported);
    end;
    if (Supported > 0) and
       (CorridorRight - CorridorLeft < MinColumnGap * 0.5) then
      Exit(False);
  end;
  Result := True;
end;

Γιατί οι ruled tables εξάγονταν δύο φορές;

Οι ruled tables εξάγονταν δύο φορές επειδή το πέρασμα whitespace έβλεπε κάθε λέξη της σελίδας, συμπεριλαμβανομένων των λέξεων που το ruled πέρασμα είχε ήδη τοποθετήσει σε grid, και ένας καθαρός ruled πίνακας είναι εκ κατασκευής ταυτόχρονα και τέλεια ευθυγραμμισμένος whitespace πίνακας. Ένας έλεγχος επικάλυψης απορρίπτει ήδη whitespace υποψήφιο του οποίου τα όρια κάλυπταν πάνω από το μισό υπάρχοντος πίνακα, αλλά υποψήφιος που συνδύαζε τις κάτω γραμμές του πίνακα με μερικές ευθυγραμμισμένες γραμμές text από κάτω του μπορούσε να πέσει κάτω από εκείνη την αναλογία και να επιβιώσει ως δεύτερος, ελαφρώς μεγαλύτερος πίνακας που αιμορραγούσε στον γείτονά του. Το ExtractTables αφαιρεί πλέον εκείνες τις λέξεις πριν τρέξει το πέρασμα whitespace. Μια λέξη πετιέται όταν το σημείο κέντρο της βρίσκεται μέσα στα όρια οποιουδήποτε πίνακα παρήγαγε το ruled πέρασμα· το κέντρο χρησιμοποιείται αντί για πλήρη περιέλευση ώστε λέξη που καβαλάει border κατά κλάσμα πόντου να ακολουθεί τον πίνακα στον οποίο οπτικά ανήκει. Η στρατηγική whitespace δουλεύει μετά μόνο πάνω στις ελεύθερες λέξεις, που σημαίνει επίσης ότι μικρός unruled πίνακας καθισμένος απευθείας κάτω από ruled ανιχνεύεται με τα δικά του προσόντα αντί να λιώσει με το grid από πάνω του

Γιατί το «Purpose of Request:» βγήκε ως «of Purpose Request:»;

Οι λέξεις βγήκαν ανακατεμένες επειδή τα word boxes που χτίζει το PDFium Component είναι ενώσεις bounding boxes των glyphs, και το «of» δεν έχει descender ενώ το «Purpose» και το «Request:» έχουν. Το FPDFText_GetCharBox επιστρέφει το σφιχτό box του μελανιού του glyph στον χώρο σελίδας, όχι box πεπλατυσμένο ως την ascent και descent της γραμματοσειράς, και το word box είναι η ένωση των boxes των χαρακτήρων του. Μια λέξη χωρίς descenders είναι επομένως κοντύτερη και το κατακόρυφο κέντρο της κάθεται ψηλότερα, κατά 2 έως 3 πόντους στη φόρμα στη συγκεκριμένη περίπτωση. Η παλιά ρουτίνα text κελιών ταξινομούσε λέξεις πρώτα κατά center Y, με ανοχή 1 πόντου για «ίδια γραμμή», και μετά κατά αριστερό άκρο· το «of» πέρασε την ανοχή, ταξινομήθηκε ως δική του γραμμή πάνω από τις άλλες, και εκπέμφθηκε πρώτο

Αυτό δεν είναι ιδιομορφία του PDFium τόσο όσο συνέπεια του πώς το PDF τοποθετεί text. Το ISO 32000-1 §9.2.2 και §9.4.4 ορίζουν την τοποθέτηση glyphs ως οριζόντια μετατόπιση κατά μήκος της baseline στον χώρο text, και οι μόνοι κατακόρυφοι μετρικοί που κουβαλάει το αρχείο είναι ανά γραμματοσειρά: οι εγγραφές Ascent, Descent, και FontBBox του font descriptor στο §9.8.1. Τίποτα στο αρχείο δεν λέει ότι δύο glyphs μοιράζονται γραμμή· αυτό πρέπει να συναχθεί από τη γεωμετρία, και τα σφιχτά glyph boxes που κάνουν την επισήμανση επιλογής να δείχνει σωστή, όπως περιγράφεται στο text line selection με PDFium char boxes, είναι η λάθος είσοδος για σύγκριση απόστασης κέντρων

Το fix στην έκδοση 3.117.0 αλλάζει την ερώτηση από «πόσο μακριά είναι τα κέντρα» σε «πόσο επικαλύπτονται τα boxes κατακόρυφα». Το text κελιού συναρμολογείται πρώτα ομαδοποιώντας τις λέξεις του κελιού σε οπτικές γραμμές, όπου λέξη εντάσσεται σε γραμμή όταν η κατακόρυφη επικάλυψή της με τα τρεχούμενα όρια της γραμμής είναι τουλάχιστον 25 τοις εκατό του μικρότερου από τα δύο ύψη, μετά ταξινομώντας κάθε γραμμή με insertion sort κατά αριστερό άκρο, και μετά ενώνοντας τις γραμμές με αλλαγή γραμμής. Το «Purpose» και το «of» επικαλύπτονται σε όλο το x-height, που είναι πολύ περισσότερο από 25 τοις εκατό του κοντύτερου box, οπότε προσγειώνονται στην ίδια γραμμή και ταξινομούνται κατά X όπως πρέπει

Διάγραμμα PDFium Component για το fix ανακατεύθυνσης Purpose of Request: σφιχτά glyph boxes από FPDFText_GetCharBox δίνουν στο descender-free of υψηλότερο κέντρο που η παλιά ανοχή 1 pt center-Y ταξινομούσε ως δική του γραμμή, ενώ κανόνας κατακόρυφης επικάλυψης 25 τοις εκατό το κρατά στη baseline και επαναφέρει τη σειρά λέξεων
Το center Y μετακινείται με όποιους ascenders και descenders τυχαίνει να κουβαλάει το μελάνι, ενώ δύο boxes στην ίδια baseline επικαλύπτονται πάνω στο κοινό x-height ό,τι κι αν κάνουν τα ύψη τους

Ομαδοποίησε text γραμμές με επικάλυψη, όχι με απόσταση κέντρων

Ο κανόνας που αξίζει να πάρεις από αυτό το bug είναι γενικός: οποιοδήποτε PDF text-layout code αποφασίζει «ίδια γραμμή» συγκρίνοντας κατακόρυφα κέντρα με σταθερή ανοχή θα αποτύχει πάνω σε πραγματικές γραμματοσειρές, και η αποτυχία είναι σιωπηλή· τίποτα δεν σφάλει, οι λέξεις απλώς βγαίνουν με λάθος σειρά. Τα μικτά descenders είναι ο ηπιότερος trigger. Ένα bold label 12 πόντων δίπλα σε τιμές 10 πόντων, ένας superscript δείκτης υποσημείωσης, ένα σύμβολο νομίσματος ζωγραφισμένο από fallback γραμματοσειρά, και word boxes OCR με θόρυβο ύψους ανά λέξη μετακινούν όλα κέντρα περισσότερο από κάθε ανοχή που εξακολουθεί να χωρίζει γειτονικές γραμμές 10 πόντων σε 12 πόντων leading. Ο λόγος επικάλυψης είναι αμετάβλητος μεγέθους: δύο boxes στην ίδια baseline επικαλύπτονται πάνω στο κοινό τους x-height ό,τι κι αν κάνουν ascenders και descenders τους, και δύο boxes σε γειτονικές γραμμές δεν επικαλύπτονται καθόλου

Ο ίδιος κανόνας εφαρμόζεται εύκολα έξω από την εξαγωγή πινάκων. Το TPdf.PageWordBoxes επιστρέφει κάθε λέξη της ενεργής σελίδας με το ορθογώνιο της στον χώρο σελίδας, οπότε η ομαδοποίηση μιας σελίδας σε οπτικές γραμμές είναι κοντός loop:

uses
  Math, PDFium;

function SameVisualLine(const A, B: TPdfRectangle): Boolean;
var
  Overlap, MinHeight: Double;
begin
  Overlap := Min(A.Top, B.Top) - Max(A.Bottom, B.Bottom);
  MinHeight := Min(A.Top - A.Bottom, B.Top - B.Bottom);
  Result := (MinHeight > 0) and (Overlap >= MinHeight * 0.25);
end;

procedure GroupPageIntoLines(Pdf: TPdf; out Lines: TArray<TPdfWordBoxes>);
var
  Words: TPdfWordBoxes;
  Bounds: TArray<TPdfRectangle>;   // τρέχουσα ένωση ανά γραμμή
  I, J, Found: Integer;
begin
  Words := Pdf.PageWordBoxes;
  Lines := nil;
  Bounds := nil;
  for I := 0 to High(Words) do
  begin
    Found := -1;
    for J := High(Lines) downto 0 do
      if SameVisualLine(Bounds[J], Words[I].Rect) then
      begin
        Found := J;
        Break;
      end;
    if Found < 0 then
    begin
      SetLength(Lines, Length(Lines) + 1);
      SetLength(Bounds, Length(Bounds) + 1);
      Found := High(Lines);
      Bounds[Found] := Words[I].Rect;
    end;
    SetLength(Lines[Found], Length(Lines[Found]) + 1);
    Lines[Found][High(Lines[Found])] := Words[I];
    Bounds[Found].Left := Min(Bounds[Found].Left, Words[I].Rect.Left);
    Bounds[Found].Right := Max(Bounds[Found].Right, Words[I].Rect.Right);
    Bounds[Found].Top := Max(Bounds[Found].Top, Words[I].Rect.Top);
    Bounds[Found].Bottom := Min(Bounds[Found].Bottom, Words[I].Rect.Bottom);
  end;
  // ταξινόμησε κάθε γραμμή κατά Rect.Left πριν τη διαβάσεις· το PageWordBoxes γυρνά
  // λέξεις σε σειρά content-stream, που δεν είναι εγγυημένο ότι είναι οπτική
end;

Τι αλλάζει για υπάρχοντες callers, και πού είναι τα όρια

Το νόημα εκείνου του snippet είναι το κατηγόρημα, όχι το loop· για οτιδήποτε πέρα από γρήγορο dump, ξεκίνα από το structured text model, που κουβαλάει ήδη blocks, γραμμές, και πηγή reading-order, όπως καλύπτεται στο structured PDF text extraction με reading order. Οι υπάρχοντες table callers παίρνουν και τις τρεις διορθώσεις χωρίς να αγγίξουν τις επιλογές τους. Το κατώφλι διαδρόμου είναι καρφωμένο στο μισό του MinColumnGap, η στρατηγική whitespace κρατά το δάπεδο δύο γραμμών της ακόμα κι όταν το MinRows θέτεται στο 1 (που η ruled στρατηγική δέχεται πια), και το φιλτράρισμα λέξεων ruled-first είναι απεριόριστο όποτε και οι δύο στρατηγικές είναι ενεργές. Στο σύνολο 13 εγγράφων που χρησιμοποιήθηκε για την έκδοση, το πέρασμα whitespace είχε γυρίσει προηγουμένως 34 τμήματα και false positives δίπλα σε 9 ruled tables· μετά την έκδοση δεν γυρίζει κανένα, και ο αριθμός ruled tables ανέβηκε σε 41, αν και το μεγαλύτερο μέρος της ανόδου προέρχεται από την ίδια έκδοση που δίδαξε τον ruled detector να διαβάζει borders σχεδιασμένα ως γεμάτα ορθογώνια, που είναι ξεχωριστή ιστορία

Τα τίμια όρια: το corridor test χρειάζεται τουλάχιστον μία γραμμή με περιεχόμενο και από τις δύο πλευρές ενός ορίου για να απορρίψει οτιδήποτε, οπότε υποψήφιος δύο γραμμών του οποίου τα δύο τεντωμένα κενά τυχαίνουν εντός 6 πόντων μεταξύ τους εξακολουθεί να περνά. Εκείνο είναι στενή συγκυρία αντί για την σχεδόν-βεβαιότητα που ήταν πριν, αλλά πεζογραφικά βαριά έγγραφα χωρίς κανένα γνήσιο πίνακα δύο γραμμών μπορούν να το κλείσουν θέτοντας MinRows στο 3. Το left-aligned ανομοιόμορφο text δεν ήταν ποτέ το πρόβλημα και δεν επηρεάζεται. Και το PDF εξακολουθεί να μην έχει table object· το ISO 32000-1 §14.8.4.3 ορίζει element δομής Table, αλλά μόνο Tagged PDF το κουβαλάει, οπότε για όλα τα άλλα το grid παραμένει συμπέρασμα από τη γεωμετρία, και η τιμή confidence πάνω σε κάθε TPdfTable υπάρχει επειδή το συμπέρασμα αξίζει σκορ

Η εξαγωγή tables, το structured text, και τα word boxes διαβάζουν όλα από το ίδιο page model σε Delphi, C++Builder και Lazarus· το πλήρες API, συμπεριλαμβανομένου του TPdfTableExtractionOptions και του demo TableExtractionLab που κυκλοφορεί δίπλα του, περιγράφεται στη σελίδα PDFium Component for Delphi