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

Redaction PDF σε επίπεδο τελεστή σε Delphi με PDFiumPas

Κάποιος βάφει ένα μαύρο κουτί πάνω σε ένα όνομα, δεν ισοπεδώνει τίποτα, στέλνει το αρχείο, και ο αναθεωρητής επιλέγει το ορθογώνιο και επικολλά το όνομα σε ένα email. Το PDFiumPas απαντά σε αυτό με redaction σε επίπεδο τελεστή: το SaveAsRedacted διαγράφει μόνο τους Unicode scalars των οποίων τα πλαίσια χαρακτήρων ακουμπούν σε ορθογώνιο redaction, ξαναχτίζει τους επιζώντες από την αρχική γραμματοσειρά, μέγεθος, μήτρα, render mode και χρώμα, και κόβει ομοαξονικά μονοπάτια και εικόνες αντί να τα πετάει ολόκληρα

Γιατί ένα βαμμένο ορθογώνιο δεν είναι redaction

Μια λειτουργία σχεδίασης που προστίθεται πάνω από ένα content stream δεν κρύβει τίποτα, επειδή οι τελεστές εμφάνισης κειμένου από κάτω της εξακολουθούν να βρίσκονται στη ροή και να αντιστοιχούν σε code points. Το ISO 32000-1 §9.4 ορίζει ένα text object ως αλληλουχία τελεστών τοποθέτησης και εμφάνισης μέσα σε BT και ET· ένα γεμάτο ορθογώνιο που σχεδιάζεται μετά είναι απλώς ένας ακόμη τελεστής στην ίδια ροή. Η εξαγωγή περπατά τους τελεστές, όχι τα pixels, οπότε το καλυμμένο string επιστρέφει ανέπαφο. Η πραγματική redaction πρέπει να αφαιρεί τον τελεστέο, όχι να θολώνει την έξοδο

Η προφανής ασφαλής υλοποίηση είναι βάναυση: βρίσκει κάθε αντικείμενο σελίδας του οποίου το bounding box τέμνει ορθογώνιο redaction και διαγράφει ολόκληρο το αντικείμενο. Αυτό έκαναν οι προηγούμενες εκδόσεις του PDFiumPas, και είναι σωστό αλλά ακριβό. Ένα μόνο Tj μπορεί να μεταφέρει ολόκληρη γραμμή πίνακα, οπότε το μαύρισμα ενός αριθμού λογαριασμού πήρε μαζί την ημερομηνία, την περιγραφή και το ποσό. Μια ορθογώνια γεμίσμα που τυχαινόταν να είναι μια ζώνη πίνακα πλήρους πλάτους εξαφανίστηκε σε όλη τη σελίδα. Ένα λογότυπο τιμολογίου εξαφανίστηκε επειδή το redaction έκοψε μία γωνία του. Η έκδοση 3.101.0 μεταφέρει την απόφαση ένα επίπεδο κάτω, από το αντικείμενο σελίδας στον τελεστέο

Τι διαγράφει πραγματικά η redaction σε επίπεδο τελεστή;

Το PDFiumPas διαγράφει Unicode scalars, όχι text objects. Κατά τη διάρκεια του SaveAsRedacted το component χτίζει μια αντιστοίχιση χαρακτήρα-σε-αντικείμενο-σελίδας από τη φορτωμένη σελίδα κειμένου, και μετά για κάθε χαρακτήρα που ανήκει στο εξεταζόμενο αντικείμενο διαβάζει το πλαίσιο του χαρακτήρα και το τέμνει με κάθε ορθογώνιο redaction. Οι χαρακτήρες που ακουμπούν σε ορθογώνιο σημειώνονται για αφαίρεση· οι υπόλοιποι σημειώνονται ως επιζώντες. Αν δεν τέμνει τίποτα, το αντικείμενο αφήνεται τελείως ήσυχο. Αν τέμνουν όλοι οι χαρακτήρες, το αντικείμενο αφαιρείται ολόκληρο, ακριβώς όπως πριν. Μόνο η μικτή περίπτωση ενεργοποιεί διαχωρισμό

Redaction σε επίπεδο τελεστή με PDFiumPas σε σύγκριση με τη διαγραφή ολόκληρου αντικειμένου σε Delphi: η παλιά διαδρομή πετάει ένα ολόκληρο text object όταν καλύπτεται ένας αριθμός λογαριασμού, ενώ η διαδρομή διαχωρισμού διαγράφει μόνο τους τέμνοντες χαρακτήρες και επανεκπέμπει κάθε επιζώντα ως δικό του text object
Μόνο η μικτή περίπτωση ενεργοποιεί διαχωρισμό: καμία τομή αφήνει το αντικείμενο ήσυχο, πλήρης τομή το αφαιρεί ολόκληρο

Κάθε επιζών επανεκπέμπεται μετά ως δικό του text object χτισμένο από το αρχικό font handle, το αρχικό μέγεθος γραμματοσειράς, τη μήτρα κειμένου ανά χαρακτήρα, το αρχικό text render mode και την κατάσταση γεμίσματος και περιγράμματος του γονικού αντικειμένου συμπεριλαμβανομένων πλάτους περιγράμματος, line join, line cap και dash array. Η επαναχρησιμοποίηση του font handle αντί για επίλυση νέου είναι αυτό που κρατά τα glyphs μετρικά πανομοιότυπα, και η επαναχρησιμοποίηση της μήτρας ανά χαρακτήρα είναι αυτό που κρατά τα kerning και τα διαστήματα λέξεων στη θέση τους χωρίς επανεκτέλεση layout. Το κόστος είναι το πλήθος αντικειμένων: ένας διατηρημένος χαρακτήρας γίνεται ένα text object, γι' αυτό το TPdfRedactionOptions.MaxSplitObjects υπάρχει ως σκληρό όριο στα παραγόμενα θραύσματα

procedure RedactDocument(const SourcePdf, TargetPdf: string);
var
  Pdf: TPdf;
  Options: TPdfRedactionOptions;
  Report: TPdfRedactionReport;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := SourcePdf;   // το αρχείο φέρει ήδη τις επισημειώσεις /Redact
    Pdf.Active := True;

    Options := TPdfRedactionOptions.Default;
    Options.PreservePartialObjects := True;    // διαχωρισμός σε επίπεδο τελεστή (η προεπιλογή)
    Options.RemoveIntersectingAnnotations := True;
    Options.MaxSplitObjects := 20000;          // όριο στα παραγόμενα θραύσματα

    if not Pdf.SaveAsRedacted(TargetPdf, Options, Report) then
      raise Exception.Create(Report.ErrorMessage);   // αποτυχία με κλείσιμο, μην το στείλεις
  finally
    Pdf.Free;
  end;
end;

Τα ορθογώνια κόβονται, η περιστραμμένη γεωμετρία όχι

Τα μονοπάτια διαχωρίζονται μόνο όταν το PDFiumPas μπορεί να αποδείξει ότι το μονοπάτι είναι ομοαξονικό ορθογώνιο. Η απόδειξη είναι σκόπιμα στενή: η μήτρα του αντικειμένου πρέπει να έχει και τους δύο όρους shear κάτω από 0.0001, το μονοπάτι πρέπει να αποτελείται από τέσσερα έως έξι τμήματα που ξεκινούν με MOVETO και συνεχίζουν μόνο με LINETO, και τα μετασχηματισμένα σημεία πρέπει να πέφτουν και στις τέσσερις γωνίες των ορίων του αντικειμένου μέσα σε ανοχή 0.01. Ένα μονοπάτι που περνά τον έλεγχο μειώνεται με διαδοχική αφαίρεση ορθογωνίων, κάθε ορθογώνιο redaction σκαλίζει το σύνολο επιζώντων σε λωρίδες αριστερά, δεξιά, κάτω και πάνω, και κάθε προκύπτουσα λωρίδα ξαναχτίζεται με την αρχική λειτουργία γεμίσματος, τη σημαία περιγράμματος και την κατάσταση χρώματος. Καμπύλες, τρίγωνα, κομμένα σχήματα και οτιδήποτε περιστραμένο αποτυγχάνει στον έλεγχο και ολόκληρο το αντικείμενο αφαιρείται

Οι εικόνες ακολουθούν το ISO 32000-1 §8.9, όπου τα δείγματα της εικόνας καταλαμβάνουν το μοναδιαίο τετράγωνο που αντιστοιχίζεται μέσω της τρέχουσας μήτρας μετασχηματισμού. Το PDFiumPas αντιστρέφει αυτή την αντιστοίχιση για να γυρίσει κάθε επιζών θραύσμα χώρου σελίδας πίσω σε κανονικοποιημένες συντεταγμένες εικόνας, τα σφίγγει στο μοναδιαίο διάστημα, και μετά μετατρέπει σε δείκτες pixels στρογγυλοποιώντας προς τα μέσα: οι άκρες αριστερά και πάνω περνούν από Ceil, οι δεξιά και κάτω από Floor. Αυτή η κατεύθυνση μετράει. Η στρογγυλοποίηση προς τα έξω θα άφηνε μια μερική στήλη pixels πηγής από την πλευρά του redaction να επιζήσει στο άκρο του θραύσματος. Τα ακέραια όρια pixels μετατρέπονται μετά πίσω σε κανονικοποιημένες συντεταγμένες και χρησιμοποιούνται για την εξαγωγή της μήτρας θραύσματος, ώστε το κομμένο bitmap να προσγειώνεται ακριβώς στο όριο pixel όπου κόπηκε. Το ίδιο το κόψιμο είναι μια αντιγραφή γραμμών με επίγνωση stride στα σχήματα Gray, BGR, BGRx και BGRA. Όπως με τα μονοπάτια, μια περιστραμένη ή λοξή εικόνα, ή μία της οποίας η μήτρα έχει εκφυλισμένο όρο κλίμακας, αφαιρείται ολόκληρη

Πώς το PDFiumPas κόβει μια μερικώς redacted εικόνα σε Delphi: το επιζών θραύσμα χώρου σελίδας αντιστοιχίζεται πίσω μέσω του αντεστραμμένου CTM σε κανονικοποιημένες συντεταγμένες εικόνας, σφίγγεται στο μοναδιαίο διάστημα και στρογγυλοποιείται προς τα μέσα ώστε καμία στήλη redacted pixels να μην επιζεί
Ceil αριστερά και πάνω, Floor δεξιά και κάτω, ώστε το κόψιμο να προσγειώνεται σε ολόκληρο όριο pixel
// Μετά από επιτυχημένη κλήση SaveAsRedacted
Writeln(Format('applied %d redaction(s) on %d page(s)',
  [Report.RedactionCount, Report.RedactedPageCount]));
Writeln(Format('scanned %d object(s), removed %d',
  [Report.ScannedObjectCount, Report.RemovedObjectCount]));
Writeln(Format('split text/path/image: %d / %d / %d',
  [Report.SplitTextObjectCount, Report.SplitPathObjectCount,
   Report.SplitImageObjectCount]));
Writeln(Format('preserved %d fragment(s)', [Report.PreservedFragmentCount]));
Writeln(Format('pruned %d resource name(s), swept %d object(s)',
  [Report.ResourcePruneReport.RemovedNameCount,
   Report.ResourcePruneReport.RemovedObjectCount]));

if Report.PreservedFragmentCount = 0 then
  // τίποτα δεν μπορούσε να διαχωριστεί: κάθε τέμνον αντικείμενο πετάχτηκε ολόκληρο
  LogWholeObjectFallback(SourcePdf);

Γιατί το PDFiumPas κλείνει με αποτυχία σε μη αντιστοιχισμένους χαρακτήρες;

Επειδή ένα glyph χωρίς αναπαραγώγιμο Unicode scalar δεν μπορεί να ξαναχτιστεί ειλικρινά. Η ανακατασκευή επιζώντος σημαίνει κλήση του API ορισμού κειμένου με μια συμβολοσειρά, και αυτό απαιτεί σταθερό code point για κάθε διατηρημένο χαρακτήρα. Συμβολικές γραμματοσειρές subset με χαλασμένα ή απούσα δεδομένα ToUnicode μπορούν να δώσουν κενή αντιστοίχιση, και η επανακωδικοποίηση με μαντεψιά θα παρήγαγε έξοδο που φαίνεται σωστή στην οθόνη ενώ κουβαλά διαφορετικό χαρακτήρα από κάτω. Το PDFiumPas αρνείται: ο έλεγχος διατηρημένων χαρακτήρων προκαλεί εξαίρεση, η εξαίρεση πιάνεται μέσα στο SaveAsRedacted, το TPdfRedactionReport.Succeeded επιστρέφει False με το μήνυμα στο ErrorMessage, και η συνάρτηση επιστρέφει False. Ο ίδιος κανόνας ισχύει για τον προϋπολογισμό διαχωρισμού, που προκαλεί εξαίρεση αντί να περικόπτει σιωπηλά το σύνολο θραυσμάτων. Όταν ένα έγγραφο έχει γραμματοσειρές που δεν εμπιστεύεστε και θέλετε τη ντετερμινιστική παλιά συμπεριφορά, ορίστε Options.PreservePartialObjects := False και κάθε τέμνον αντικείμενο φεύγει ολόκληρο

Κλάδεμα πόρων σε κοινόχρηστα εύρη

Ο διαχωρισμός αντικειμένων αφήνει ορφανά πίσω του, και το κλάδεμά τους δεν είναι τόσο απλό όσο η σύγκριση του λεξικού /Resources επιπέδου σελίδας. Το ISO 32000-1 §7.8.3 επιτρέπει στο ίδιο λεξικό πόρων να αναφέρεται ταυτόχρονα από πολλές σελίδες, από Form XObjects, από patterns και από streams εμφάνισης επισημειώσεων. Η διαγραφή ονόματος γραμματοσειράς επειδή μια σελίδα σταμάτησε να το χρησιμοποιεί θα χαλάσει άλλη σελίδα που ακόμη το χρησιμοποιεί. Το PruneUnusedPdfResources δουλεύει επομένως ανά εύρος: επιλύει το /Contents είτε είναι άμεσος πίνακας, έμμεση αναφορά σε πίνακα ή μία μόνο ροή, και μετά συλλέγει τη χρήση πόρων από τους τελεστές που ονοματίζουν πράγματι πόρους — Tf για γραμματοσειρές, Do για XObjects, gs για κατάσταση γραφικών, CS, cs, SCN και scn για χρωματικούς χώρους και patterns, sh για shadings, BDC και DP για ιδιότητες marked-content, συν την εγγραφή /CS των inline images. Όταν ένα λεξικό μοιράζεται από πολλά εύρη, τα σύνολα χρησιμοποιούμενων ονομάτων ενώνονται ανά κατηγορία πριν αφαιρεθεί οτιδήποτε

Κλάδεμα πόρων στο PDFiumPas: τρία εύρη αναφέρονται σε ένα κοινό λεξικό πόρων, τα σύνολα χρησιμοποιούμενων ονομάτων τους ενώνονται ανά κατηγορία, και μόνο τα ονόματα που κανένα εύρος δεν αναφέρει αφαιρούνται πριν σαρωθούν τα απρόσιτα αντικείμενα
Ένα λεξικό μπορεί να εξυπηρετεί πολλές σελίδες, form XObjects και streams εμφάνισης, οπότε το PDFiumPas ενώνει κάθε σύνολο χρησιμοποιούμενων ονομάτων πριν πετάξει έστω ένα όνομα

Μόνο τα ονόματα που επιβεβαιώνονται ως μη αναφερόμενα σε κάθε εύρος που δείχνει στο λεξικό πετιούνται. Ένα εύρος που δεν μπορεί να αναλυθεί με σιγουριά αφήνεται ανέγγιχτο, που είναι η συντηρητική κατεύθυνση: ένα μη κλαδευμένο αρχείο είναι απλώς μεγαλύτερο, ένα λάθος κλαδευμένο είναι κατεστραμμένο. Τα επιζώντα λεξικά ξαναγράφονται ως μια αραιή επαυξημένη ενημέρωση που μεταφέρει τους ακριβείς αριθμούς γενιάς, και μια επανεγγραφή προσβασιμότητας σαρώνει μετά τα αντικείμενα που έγιναν απρόσιτα μόλις τα ονόματα εξαφανίστηκαν. Το TPdfResourcePruneReport αναφέρει ScannedScopeCount, UpdatedScopeCount, RemovedNameCount, RemovedObjectCount, τα μεγέθη σε bytes και μια σημαία Succeeded. Το SaveAsRedacted εκτελεί αυτό το βήμα αυτόματα στην απολυμανμένη έξοδο, οπότε η διαδρομή redaction το συμπεριλαμβάνει ήδη, αλλά η συνάρτηση εξάγεται σε επίπεδο ροής για pipelines που τη θέλουν μόνη της

uses
  FPdfCompress;

procedure PruneResourceNames(const SourcePdf, TargetPdf: string);
var
  Source, Dest: TFileStream;
  Report: TPdfResourcePruneReport;
begin
  Source := TFileStream.Create(SourcePdf, fmOpenRead or fmShareDenyWrite);
  try
    Dest := TFileStream.Create(TargetPdf, fmCreate);
    try
      // Το AllowSignedDocument μένει False: μια επαυξημένη επανεγγραφή θα
      // ακύρωνε τα εύρη bytes που καλύπτει μια υπογραφή
      PruneUnusedPdfResources(Source, Dest, Report);
      if not Report.Succeeded then
        raise Exception.Create(Report.ErrorMessage);
      Writeln(Format('%d name(s) removed from %d scope(s), %d -> %d bytes',
        [Report.RemovedNameCount, Report.UpdatedScopeCount,
         Report.SourceByteCount, Report.OutputByteCount]));
    finally
      Dest.Free;
    end;
  finally
    Source.Free;
  end;
end;

Ενσωμάτωση σε pipeline εγγράφων

Η διαδρομή redaction δεν μεταλλάσσει ποτέ το έγγραφο που φορτώσατε. Το SaveAsRedacted συλλαμβάνει ένα απομονωμένο στιγμιότυπο, εφαρμόζει εκεί τις επισημειώσεις /Redact, αφαιρεί συνημμένα, εκτελεί το πέρασμα απολύμανσης που αφαιρεί την ενέργεια ανοίγματος, τις ενέργειες καταλόγου, τα δέντρα ονομάτων, τα συσχετισμένα αρχεία, το AcroForm και τα μεταδεδομένα, κλαδεύει πόρους, και μόνο τότε γράφει τη ροή εξόδου. Το να ξανανοίξετε αυτή την έξοδο ως ανεξάρτητο έγγραφο και να ξαναεξαγάγετε το κείμενο είναι το βήμα επαλήθευσης που αξίζει να κρατήσετε στη δική σας σουίτα δοκιμών, επειδή είναι ο μόνος έλεγχος που απαντά την αρχική ερώτηση — μπορεί ακόμη ένας αναγνώστης να πάρει τη συμβολοσειρά. Μια συνέπεια για προγραμματισμό: ο διαχωρισμός αντικαθιστά αντικείμενα σελίδας, οπότε κάθε handle FPDF_PAGEOBJECT που κρατούσατε είναι νεκρό μετά, η ίδια παγίδα διάρκειας ζωής που περιγράφεται στο stale page object handles μετά από transform

Δύο γειτονικά κομμάτια ολοκληρώνουν τη ροή εργασίας. Η απόφαση πού θα πάνε τα ορθογώνια redaction ξεκινά συνήθως από εξαγόμενη γεωμετρία, και το μοντέλο μπλοκ και σειράς ανάγνωσης στο structured text blocks και σειρά ανάγνωσης είναι καλύτερη πηγή υποψήφιων πλαισίων από ακατέργαστες αλληλουχίες χαρακτήρων. Το σερβίρισμα του αποτελέσματος σε αναθεωρητή ανήκει στους κανόνες ενίσχυσης στο χτίσιμο ασφαλούς προεπισκόπησης PDF, όπου η συμπλήρωση φορμών και η JavaScript μένουν ανενεργές από προεπιλογή. Μαζί καλύπτουν τον βρόχο που χρειάζονται οι περισσότερες ροές συμμόρφωσης: εντοπισμός, redaction σε επίπεδο τελεστή, επαλήθευση με επανάνοιγμα, ασφαλής προεπισκόπηση. Ολόκληρη η επιφάνεια API, η λήψη δοκιμής και οι όροι αδειοδότησης του component βρίσκονται στη σελίδα προϊόντος PDFium Delphi Component