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

Συνεχίζοντας PDF tables σε σελίδες με βάση το περιεχόμενο

Το PDFium Component έκδοση 3.117.0 συνδέει έναν πίνακα που σπάει πάνω σε όριο σελίδας όταν είτε και τα δύο τμήματα αγγίζουν τα άκρα της σελίδας είτε δεν κάθεται κανένα body text κάτω από το πρώτο τμήμα και πάνω από το δεύτερο, με τα running headers και footers να αγνοούνται. Το ExtractDocumentTables εφαρμόζει εκείνο το content-aware test ως εναλλακτική του παλαιότερου page-margin test, αρνείται ένα next-page τμήμα του οποίου η πρώτη γραμμή είναι ένα μοναδικό κελί λεζάντας πλήρους πλάτους, και κρατά μια μεμονωμένη γραμμή που χύνεται πάνω στην επόμενη σελίδα ως μέρος της αλυσίδας συνέχισης του

Το άρθρο για την ανίχνευση και εξαγωγή tables παρουσίασε τη συνέχιση ως τέσσερα αυστηρά gates και μετέτρεπε το «αγγίζει το άκρο της σελίδας» σε ένα από αυτά. Εκείνη η περιγραφή ήταν ακριβής για την έκδοση που κάλυπτε, και ήταν ταυτόχρονα λάθος για τους περισσότερους πίνακες που οι άνθρωποι ταΐζανε πράγματι στο component. Το άρθρο αυτό είναι η διόρθωση: ποια έγγραφα το margin test δεν μπορεί να χειριστεί, τι το αντικατέστησε, και οι δύο πλευρικές περιπτώσεις που το fix έσυρε μαζί του

Γιατί το page-margin test αποτυγχάνει σε exports Word;

Το page-margin test αποτυγχάνει επειδή ένας word processor σταματάει να τοποθετεί γραμμές στο κάτω περιθώριο, όχι στο άκρο του χαρτιού. Με το default ContinuationMargin των 36 πόντων, ο αρχικός κανόνας απαιτούσε το κάτω άκρο του προηγούμενου τμήματος να βρίσκεται εντός 36 πόντων από τον πάτο της σελίδας και το άνω άκρο του επόμενου τμήματος εντός 36 πόντων από την κορυφή της σελίδας. Ένα έγγραφο εξαγμένο από Word με τα default περιθώρια μίας ίντσας βάζει την τελευταία γραμμή τουλάχιστον 72 πόντους πάνω από τον πάτο της σελίδας, πιο ψηλά αν υπάρχει footer, οπότε η συνθήκη δεν ισχύει ποτέ. Κάθε μακρύς πίνακας σε τέτοιο έγγραφο γύριζε ως ανεξάρτητα τμήματα με ContinuationGroup στο μηδέν, και ο caller γύριζε στο ράψιμο με το χέρι. Το test εξακολουθεί να βγάζει νόημα για ό,τι είχε σχεδιαστεί γύρω από αυτό: reports παραγόμενα από layout engines που γεμίζουν μια σελίδα σε σταθερό content box και ξεκινούν την επόμενη σελίδα κολλητά στην κορυφή. Δεν είναι κακός κανόνας, είναι ελλιπής, γι' αυτό η έκδοση 3.117.0 τον κράτησε και πρόσθεσε δεύτερο μονοπάτι αντί να τον αντικαταστήσει

Τι τεστάρει αντ' αυτού το content-aware test;

Το content-aware test τεστάρει αν οτιδήποτε άλλο πλην του πίνακα καταλαμβάνει τον χώρο ανάμεσα στα δύο τμήματα, χρησιμοποιώντας τα word boxes κάθε σελίδας αντί για τη γεωμετρία της σελίδας. Όσο το ExtractDocumentTables διασχίζει το έγγραφο καταγράφει, ανά σελίδα, το χαμηλότερο κάτω άκρο οποιουδήποτε word του οποίου η κορυφή βρίσκεται πάνω από τη ζώνη footer και το υψηλότερο άνω άκρο οποιουδήποτε word του οποίου ο πάτος βρίσκεται κάτω από τη ζώνη header. Και οι δύο ζώνες είναι ContinuationMargin πόντους βάθος, οπότε η ίδια επιλογή κάνει πλέον διπλή δουλειά ως slack άκρου σελίδας και ως ύψος των ζωνών running header και footer. Ένα ζευγάρι τμημάτων περνά όταν το κάτω άκρο του προηγούμενου είναι στο ή κάτω από το χαμηλότερο body text της σελίδας του και το άνω άκρο του επόμενου στο ή πάνω από το υψηλότερο body text της επόμενης σελίδας, το καθένα εντός AlignmentTolerance. Σε σκέτη γλώσσα: ο πίνακας ήταν το τελευταίο πράγμα στη σελίδα N και το πρώτο στη σελίδα N+1, και ένας αριθμός σελίδας ή ένας τίτλος εγγράφου στη ζώνη περιθωρίου δεν μετράει. Εκείνος ο αποκλεισμός δεν είναι αυθαίρετος. Το ISO 32000-1 §14.8.2.2 ταξινομεί τα running headers και footers ως pagination artifacts, περιεχόμενο που υπάρχει εξαιτίας του σπάσματος σελίδας και όχι ενάντια σε αυτό, και η ίδια ιδέα που αφήνει έναν tagged reader να τα παρακάμπτει είναι αυτή που αφήνει έναν πίνακα να συνεχίζει πέρα από αυτά. Το άρθρο για το marked-content καλύπτει πώς τα tagged αρχεία δηλώνουν εκείνα τα artifacts ρητά· εδώ η ταξινόμηση συλλέγεται από τη θέση, γιατί οι περισσότεροι εξαγμένοι πίνακες δεν κουβαλάνε καθόλου tags

Γιατί η συνέχιση tables του PDFium Component χρειάζεται δύο tests: με περιθώρια Word μίας ίντσας το page-margin test απαιτεί άκρα τμημάτων μέσα σε παράθυρα 36 pt που το layout δεν φτάνει ποτέ, ενώ το content-aware test συγκρίνει word boxes και συνδέει όταν ο πίνακας είναι το τελευταίο body περιεχόμενο στη σελίδα N και το πρώτο στη σελίδα N+1, αγνοώντας τις ζώνες running header και footer
Είτε test ανοίγει την πύλη, και μόνο τότε τρέχουν οι υπόλοιποι έλεγχοι: γειτονικές σελίδες, καμία γραμμή λεζάντας πλήρους πλάτους στο επόμενο τμήμα, και όρια στηλών που ταιριάζουν εντός δύο φορών AlignmentTolerance

Τα δύο tests συνδυάζονται με OR. Ένα report από layout engine του οποίου οι πίνακες τρέχουν ως το άκρο του χαρτιού περνά το πρώτο· ένα export Word του οποίου οι πίνακες σταματούν στο περιθώριο περνά το δεύτερο· ένα έγγραφο που κάνει και τα δύο περνά δύο φορές. Μόνο αφού πετύχει ένα από αυτά τρέχουν τα υπόλοιπα gates, και τρέχουν σε σταθερή σειρά: οι αριθμοί σελίδων πρέπει να είναι γειτονικοί, το επόμενο τμήμα δεν πρέπει να ανοίγει με γραμμή λεζάντας, και τα όρια στηλών πρέπει να ταιριάζουν εντός δύο φορών AlignmentTolerance, που είναι 6 πόντοι στα defaults. Η απαρίθμηση είναι TPdfTableContinuation με τις τιμές ptcNone, ptcStart, ptcMiddle και ptcEnd. Ένα τμήμα που μαρκαρίστηκε ptcEnd και μετά συνδέεται παραπέρα με ακόμα μια σελίδα προάγεται σε ptcMiddle, οπότε ένας τρίσέλιδος πίνακας διαβάζεται start, middle, end με τη σειρά σελίδων. Οι αριθμοί ομάδων ξεκινούν από 1 και το 0 σημαίνει ασύνδετο, και το ToJson εκπέμπει την ίδια πληροφορία στα μέλη continuation και continuationGroup, που είναι η μορφή να προτιμήσεις αν το ράψιμο το κάνει ένας downstream service

uses
  PDFium;

var
  Pdf: TPdf;
  Options: TPdfTableExtractionOptions;
  Tables: TPdfTables;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'itinerary-from-word.pdf';
    Pdf.LoadDocument;

    Options := TPdfTableExtractionOptions.Default;
    Options.DetectContinuations := True;     // default· φαίνεται για σαφήνεια
    Options.ContinuationMargin := 54;        // footer δύο γραμμών, ~50 pt βάθος

    Tables := Pdf.ExtractDocumentTables(Options);
    for I := 0 to High(Tables) do
      case Tables[I].Continuation of
        ptcStart:
          Writeln(Format('group %d starts on page %d (%d rows)',
            [Tables[I].ContinuationGroup, Tables[I].PageNumber,
             Tables[I].RowCount]));
        ptcMiddle, ptcEnd:
          Writeln(Format('group %d continues on page %d (%d rows)',
            [Tables[I].ContinuationGroup, Tables[I].PageNumber,
             Tables[I].RowCount]));
      else
        Writeln(Format('standalone table on page %d (%d rows)',
          [Tables[I].PageNumber, Tables[I].RowCount]));
      end;
  finally
    Pdf.Free;
  end;
end;

Πώς σταματά μια γραμμή λεζάντας από το να λιώσουν δύο tables;

Ένα next-page τμήμα του οποίου η πρώτη γραμμή είναι ένα κελί που απλώνεται σε όλες τις στήλες μεταχειρίζεται ως νέος πίνακας, ποτέ ως το υπόλοιπο του προηγούμενου. Αυτός ο κανόνας υπάρχει επειδή το content-aware test, από μόνο του, συνδέει πολύ πρόθυμα. Η περίπτωση που το εξέθεσε ήταν ένα φύλλο τύπου μεταγραφής: ένας πίνακας τελειώνει κοντά στον πάτο της σελίδας 1, ένας δεύτερος πίνακας με πανομοιότυπα πλάτη στηλών ξεκινά κοντά στην κορυφή της σελίδας 2, τίποτα πλην του footer δεν κάθεται ανάμεσά τους, και οι στήλες ταιριάζουν στο sign. Κάτω από το margin test τα δύο δεν συναντιόταν ποτέ γιατί κανένα δεν άγγιζε άκρο· κάτω από το content test συνδέθηκαν αμέσως, και μια φόρμα με sections έγινε ένα ασύνδετο grid. Αυτό που τα χωρίζει φαίνεται στη δομή των κελιών. Ο δεύτερος πίνακας ανοίγει με λεζάντα section όπως «RECIPIENT INFORMATION» τοποθετημένη ως ένα συγχωνευμένο κελί σε όλο το πλάτος, και μια γνήσια συνέχιση δεν το κάνει αυτό ποτέ, γιατί η λεζάντα ανήκει στον πίνακα που ξεκίνησε ήδη στην προηγούμενη σελίδα. Το TableStartsWithCaptionRow κωδικοποιεί ακριβώς αυτό: το τμήμα έχει τουλάχιστον δύο στήλες και περιέχει κελί με RowIndex = 0, ColumnIndex = 0 και ColumnSpan = ColumnCount. Ο έλεγχος τρέχει μόνο στο επόμενο τμήμα, οπότε ένας πίνακας του οποίου η δική του γραμμή λεζάντας κάθεται στην πρώτη του σελίδα δεν επηρεάζεται· η λεζάντα είναι στη σελίδα N, και μόνο το τμήμα της σελίδας N+1 εξετάζεται

Η πύλη γραμμής λεζάντας στο PDFium Component: μια γνήσια συνέχιση ανοίγει με κελιά δεδομένων και εντάσσεται στο ίδιο ContinuationGroup, ενώ ένα επόμενο τμήμα του οποίου η γραμμή μηδέν κρατά ένα συγχωνευμένο κελί με RowIndex 0, ColumnIndex 0 και ColumnSpan ίσο με ColumnCount απορρίπτεται ως συνέχιση και αναφέρεται ως νέος πίνακας
Η εξέταση αγγίζει μόνο το επόμενο τμήμα, οπότε ένας πίνακας του οποίου η δική του γραμμή λεζάντας κάθεται στην πρώτη του σελίδα δεν επηρεάζεται, και η πύλη τρέχει αφού ένα από τα δύο edge tests έχει ήδη συνδέσει το ζευγάρι

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

Τι γίνεται με μια μεμονωμένη γραμμή που χύνεται στην επόμενη σελίδα;

Ένα ruled grid που κουβαλάει μία γραμμή πάνω στην επόμενη σελίδα ανιχνεύεται πλέον και συνδέεται, με την προϋπόθεση ότι καταλήγει μέσα σε αλυσίδα συνέχισης· μόνη της απορρίπτεται. Το default MinRows του 2 υπάρχει για να μην αναφέρεται ένα αδέσποτο ζευγάρι γραμμών ως πίνακας, αλλά μια τελευταία γραμμή σπρωχμένη πάνω από το σπάσμα είναι πραγματική γραμμή που ένα σκληρό δάπεδο του 2 έριχνε σιωπηλά, και η υπόλοιπη του πίνακα φαινόταν ολοκληρωμένος όταν δεν ήταν. Η σάρωση επιπέδου εγγράφου τη χειρίζεται σε τρία βήματα. Όταν DetectContinuations και DetectRuledTables είναι και τα δύο στραμμένα, το πέρασμα ανά σελίδα τρέχει τον ruled detector με το δάπεδο γραμμών προσωρινά χαμηλωμένο στο 1, γι' αυτό το ExtractTables δέχεται πλέον MinRows του 1 για ruled grids ενώ η ανίχνευση whitespace κρατά εσωτερικό δάπεδο 2. Οι continuations μαρκάρονται πάνω στο πλήρες αποτέλεσμα. Μετά κάθε πίνακας που είναι κοντύτερος από το MinRows του caller και δεν ανήκει σε καμία αλυσίδα αφαιρείται. Το τμήμα μίας γραμμής επιβιώνει μόνο επειδή συνδέθηκε, και ένα grid μίας γραμμής στη μέση μιας κατά τα άλλα συνηθισμένης σελίδας φιλτράρεται ακριβώς όπως πριν

Πώς κρατά το PDFium Component μια ruled γραμμή που χύνεται πάνω από σπάσμα σελίδας: το ruled πέρασμα ανά σελίδα τρέχει με το δάπεδο γραμμών στο ένα όταν DetectContinuations και DetectRuledTables είναι στραμμένα, οι continuations μαρκάρονται πάνω στο πλήρες αποτέλεσμα, και μόνο τμήματα κοντύτερα από MinRows που κάθεται έξω από κάθε αλυσίδα αφαιρούνται
Η γραμμή που χύθηκε επιβιώνει επειδή η αλυσίδα της τη συνδέει, ενώ ένα αυτόνομο grid μίας γραμμής σε συνηθισμένη σελίδα φιλτράρεται ακριβώς όπως πριν, και οι tables που ανιχνεύονται με whitespace κρατούν το δάπεδο δύο γραμμών τους χωρίς τέτοια ανακούφιση
// Ξαναχτίσε κάθε αλυσίδα ως ένα CSV, ρίχνοντας τις επαναλαμβανόμενες
// γραμμές header πάνω στα continuation fragments
procedure ExportChains(const Tables: TPdfTables; const Folder: string);
var
  I, R: Integer;
  Lines: TStringList;
  Csv: TStringList;
begin
  Csv := TStringList.Create;
  Lines := TStringList.Create;
  try
    for I := 0 to High(Tables) do
    begin
      if Tables[I].Continuation in [ptcNone, ptcStart] then
        Csv.Clear;
      Lines.Text := string(Tables[I].ToCsv);
      if (Tables[I].Continuation in [ptcMiddle, ptcEnd]) and
         (Lines.Count > 1) and (Tables[I].RowCount > 1) then
        Lines.Delete(0);            // header που επαναλαμβάνει ο word processor
      for R := 0 to Lines.Count - 1 do
        Csv.Add(Lines[R]);
      if Tables[I].Continuation in [ptcNone, ptcEnd] then
        Csv.SaveToFile(Format('%s\page%d-group%d.csv',
          [Folder, Tables[I].PageNumber, Tables[I].ContinuationGroup]));
    end;
  finally
    Lines.Free;
    Csv.Free;
  end;
end;

Δύο λεπτομέρειες σε εκείνη τη ρουτίνα είναι σκόπιμες. Το χύτευμα μίας γραμμής δεν απογυμνώνεται ποτέ, γιατί ο φύλακας πάνω στο RowCount το κρατά, και ένας word processor που επαναλαμβάνει τη γραμμή header σε κάθε σελίδα παράγει τμήμα του οποίου η πρώτη γραμμή είναι πάλι το header, οπότε το ρίξιμο της γραμμής μηδέν σε middle και end τμήματα είναι σωστό για εκείνη την περίπτωση και λάθος για generator που δεν επαναλαμβάνει headers. Τσεκάρισε ένα έγγραφο πριν αφήσεις τη ρουτίνα ελεύθερη πάνω σε φάκελο

Πού σταματούν ακόμα οι κανόνες

Το content-aware test είναι τόσο καλό όσο το text layer που διαβάζει. Σε μια σκαναρισμένη σελίδα χωρίς καθόλου text, τα καταγεγραμμένα άκρα body-text γυρνάνε στα όρια της σελίδας, η συνθήκη «τίποτα ανάμεσα» ικανοποιείται κενά, και μόνο οι πύλες λεζάντας και στηλών μένουν· ένα ruled grid σε τέτοια σελίδα εξακολουθεί να βρίσκεται ως κενός σκελετός, οπότε η αλυσίδα μπορεί να συνδεθεί σωστά, αλλά τίποτα για το γύρω text δεν επαληθεύτηκε πράγματι. Πρόσθεσε text layer πρώτα αν αυτό μετράει. Footers αποδιδόμενα ως εικόνες αντί για text είναι αόρατα στη λογική των ζωνών και αβλαβή για τον ίδιο λόγο

Οι ζώνες είναι ένας αριθμός. Ένα footer βαθύτερο από το ContinuationMargin αφήνει τις κάτω γραμμές του μέσα στη ζώνη body, που κάνει το προηγούμενο τμήμα να φαίνεται ακολουθούμενο από text και μπλοκάρει τη σύνδεση· ανέβασε την επιλογή στο πραγματικό βάθος ζώνης, όπως κάνει το πρώτο παράδειγμα. Ανέβασέ την πολύ ψηλά και μια κοντή κατακλείδα κοντά στον πάτο της σελίδας γλιστράει μέσα στη ζώνη και αγνοείται, που συνδέει έναν πίνακα με ό,τι ακολουθεί. Ο κανόνας λεζάντας έχει μια κατοπτριστική αποτυχία: ένας generator που γράφει συγχωνευμένο banner «continued» ως πρώτη γραμμή κάθε continuation τμήματος θα βλέπει εκείνα τα τμήματα απορριπτόμενα ως νέοι πίνακες, και η μόνη θεραπεία σήμερα είναι να ράψεις με το ContinuationGroup μόνος σου μετά από καμία χαλάρωση, γιατί ο κανόνας δεν έχει διακόπτη

Οι tables που ανιχνεύονται με whitespace δεν παίρνουν καμία από την ανακούφιση μίας γραμμής. Η στρατηγική whitespace χρειάζεται δύο ευθυγραμμισμένες γραμμές για να δει καθόλου πίνακα, οπότε ένας unruled πίνακας που χύνει μία γραμμή εξακολουθεί να αναφέρεται κοντός κατά εκείνη τη γραμμή. Όταν το χτυπήσεις, τα word boxes πίσω από τα structured text blocks και το reading order σου δίνουν τις raw θέσεις για να το ανακτήσεις. Στο σύνολο δειγμάτων που κίνησε αυτή τη δουλειά, δεκατρία exports word processor και browser, τα πέντε έγγραφα με γνήσιους multi-page πίνακες συνδέθηκαν όλα σε μοναδικές αλυσίδες και η φόρμα μεταγραφής που προηγουμένως λιώνει έμεινε χωριστή, που είναι ο πήχης με τον οποίο μετρήθηκε η έκδοση, όχι μια υπόσχεση για κάθε layout

Το μαρκάρισμα continuations, ο κανόνας λεζάντας και το πέρασμα μίας γραμμής ζουν όλα στο μονοπάτι επιπέδου εγγράφου που μοιράζονται τα builds Delphi, C++Builder και Lazarus· το πλήρες API εξαγωγής tables περιγράφεται στη σελίδα PDFium Component for Delphi