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

Αντιστοίχιση PDF text με bytes του content stream στο Delphi

Ένας λάθος χαρακτήρας σε έναν αριθμό τιμολογίου, και το μοναδικό primitive επεξεργασίας που έχεις διαθέσιμο ξαναγράφει ολόκληρο το text run. Το PDF Library for Delphi καλύπτει αυτό το κενό: το GetTextBlockCharContentLocation αντιστοιχίζει κάθε extracted θέση UTF-16 στην instruction, στο operand και στο encoded byte range του content stream που την παρήγαγε, ενώ το ReplaceTextBlockCharSourceBytes αντικαθιστά μόνο αυτό το range. Η text extraction συνήθως πετά ό,τι θα χρειαζόσουν για κάτι τέτοιο. Παίρνεις Unicode, widths και geometry, αλλά η προέλευση εξαφανίζεται, οπότε ο χαρακτήρας στη θέση 7 του block 3 είναι απλώς ένας χαρακτήρας. Ποιο stream τον παρήγαγε, ποια instruction, ποιο operand, ποιο byte μέσα σε εκείνο το operand: χάθηκαν. Κάθε στρατηγική point-edit που βασίζεται σε αυτά πρέπει να μαντέψει, συνήθως ψάχνοντας το decoded content για ένα substring και ελπίζοντας ότι εμφανίζεται ακριβώς μία φορά. Σε πραγματική σελίδα δεν συμβαίνει αυτό

Γιατί η επανεγγραφή ολόκληρου text run χαλά τη σελίδα

Επειδή το run δεν είναι μόνο text. Οι text-showing operators στο ISO 32000-1 §9.4.3 περιλαμβάνουν το TJ, του οποίου το operand είναι array που εναλλάσσει strings με numeric adjustments, και αυτοί οι αριθμοί είναι το typesetting. Μια γραμμή όπως [(AB) -120 (CD)] TJ περιέχει kern 120 χιλιοστών του em ανάμεσα στα δύο strings. Αν εκδώσεις νέο Tj με το concatenated text, το kern χάνεται, η γραμμή κάνει ανεπαίσθητο reflow και σε μια φόρμα η τιμή μετακινείται έξω από το box της. Η ίδια ένσταση ισχύει για το font: τα operand bytes είναι codes σε όποιο encoding επέλεξε το Tf, όχι Unicode, και σε composite font μπορεί να είναι two-byte CIDs χωρίς σχέση με τον χαρακτήρα που πήρες από τον extractor. Αν ξαναδημιουργήσεις το run, πρέπει να είσαι σωστός για το encoding του font, το /ToUnicode map και το glyph coverage. Το point editing παρακάμπτει όλα αυτά επειδή δεν βγαίνει ποτέ από το byte domain

Τι επιστρέφει το GetTextBlockCharContentLocation

Η method επιλύει έναν χαρακτήρα σε record εννέα πεδίων, και κάθε field είναι address αντί για value. Το ContentLayer είναι ο 1-based index μέσα στο page /Contents array ή 0 όταν ο χαρακτήρας προήλθε από nested content. Τα StreamObjectNumber και StreamGeneration προσδιορίζουν το stream που τον περιέχει. Το InstructionIndex είναι η 0-based θέση στο decoded content program, το OperandIndex είναι το text-string operand και το ArrayElementIndex είναι το στοιχείο μέσα σε ένα TJ array ή -1 για direct string operand. Τα SourceByteOffset και SourceByteLength ονομάζουν στη συνέχεια το byte range μέσα σε εκείνο το decoded string

Var
  Lib: TPDFlib;
  ListID, Block, CharPos: Integer;
  ContentLayer, StreamObjectNumber, StreamGeneration: Integer;
  InstructionIndex, OperandIndex, ArrayElementIndex: Integer;
  SourceByteOffset, SourceByteLength, Flags: Integer;
Begin
  Lib:= TPDFlib.Create;
  Try
    Lib.LoadFromFile('invoice.pdf', '');
    Lib.SelectPage(1);
    ListID:= Lib.ExtractPageTextBlocks(3);
    Try
      // Το Block και το CharPos προέρχονται από το δικό σου scan του GetTextBlockText
      If Lib.GetTextBlockCharContentLocation(ListID, Block, CharPos,
        ContentLayer, StreamObjectNumber, StreamGeneration,
        InstructionIndex, OperandIndex, ArrayElementIndex,
        SourceByteOffset, SourceByteLength, Flags)= 1 Then
      Begin
        // ContentLayer = 0 σημαίνει ότι το glyph βρίσκεται σε nested Form XObject
        // ArrayElementIndex = -1 σημαίνει απλό Tj operand και όχι TJ array
      End;
    Finally
      Lib.ReleaseTextBlocks(ListID);
    End;
  Finally
    Lib.Free;
  End;
End;

Το lookup δεν κοστίζει τίποτα τη στιγμή του query. Καθώς ο renderer αποκωδικοποιεί κάθε content layer, καταγράφει τα logical spans που διατρέχει, οπότε ένα position query είναι binary search πάνω σε ordered interval list και όχι linear scan κάθε content span για κάθε χαρακτήρα. Δεν γίνεται reparse όταν ρωτάς· ο map χτίστηκε κατά το extraction pass που ήδη πλήρωσες. Αν ήδη απαριθμείς hits με PDF text search που επιστρέφει hit coordinates, η προσθήκη content location ανά hit είναι σχεδόν δωρεάν

Επεξεργασία bytes, όχι Unicode

Το ReplaceTextBlockCharSourceBytes παίρνει AnsiString με raw replacement bytes στο active PDF font encoding. Αυτός είναι όλος ο σχεδιασμός και είναι σκόπιμος. Δεν γίνεται transcode, δεν γίνεται re-encode και δεν γίνεται υπόθεση για το font. Η library κάνει splice τα bytes σου πάνω στο ονομασμένο range του target string και ξαναεκδίδει το containing content layer. Τα adjacent strings στο ίδιο TJ array και τα numeric kerns ανάμεσά τους παραμένουν byte-identical. Πάρε τη διάταξη από πάνω: η εύρεση του B στο [(AB) -120 (CD)] TJ δίνει ArrayElementIndex 0, SourceByteOffset 1 και SourceByteLength 1. Αντικατάστησέ το με Z και το emitted content περιέχει (AZ), ακολουθούμενο ακόμη από -120 και (CD), και τα δύο ανέγγιχτα. Η regression suite το ελέγχει ακριβώς αυτό, επειδή το «διατηρήσαμε το kerning» είναι ισχυρισμός που παύει αθόρυβα να ισχύει

Function EditableHere(Flags: Integer): Boolean;
Begin
  Result:= ((Flags and PDF_TEXT_CHAR_CONTENT_LOCATION_VALID)<> 0)and
    ((Flags and (PDF_TEXT_CHAR_CONTENT_LOCATION_GENERATED or
      PDF_TEXT_CHAR_CONTENT_LOCATION_ACTUALTEXT or
      PDF_TEXT_CHAR_CONTENT_LOCATION_NESTED or
      PDF_TEXT_CHAR_CONTENT_LOCATION_CROSS_LAYER or
      PDF_TEXT_CHAR_CONTENT_LOCATION_TRANSCODED))= 0);
End;

// ...
If EditableHere(Flags) Then
Begin
  If Lib.ReplaceTextBlockCharSourceBytes(ListID, Block, CharPos, 'Z')= 1 Then
  Begin
    // Κάθε location στην παλιά list είναι πλέον stale. Κάνε re-extract
    Lib.ReleaseTextBlocks(ListID);
    ListID:= Lib.ExtractPageTextBlocks(3);
  End
  Else If Lib.LastErrorCode= PDFLIB_ERROR_TEXT_LOCATION_STALE Then
    // Το layer άλλαξε κάτω από εμάς μετά το extraction
  Else If Lib.LastErrorCode= PDFLIB_ERROR_TEXT_LOCATION_READ_ONLY Then
    // Ένα flag που δεν ελέγξαμε ή ένα flag που προστέθηκε σε νεότερη έκδοση
End;

Αξίζει να εσωτερικεύσεις δύο operational λεπτομέρειες. Το call αλλάζει προσωρινά στη σελίδα από την οποία έγινε extract η text list και επαναφέρει την προηγούμενη selected page τόσο σε success όσο και σε failure, οπότε δεν μετακινεί σιωπηρά τον cursor σου. Και σε success καθαρίζει τα page element snapshots, πράγμα που invalidates όσα handles κρατούσες από προηγούμενο enumeration pass

Ποιοι χαρακτήρες δεν μπορούν να υποστούν επεξεργασία

Υπάρχουν έξι κατηγορίες, και η library ονομάζει καθεμία μέσα στο Flags bitmask αντί να αποτυγχάνει αόριστα. Αυτό μετρά περισσότερο από το happy path, επειδή στα πραγματικά documents οι unmappable περιπτώσεις είναι συχνές και καθεμία έχει διαφορετική αιτία

  • PDF_TEXT_CHAR_CONTENT_LOCATION_LIGATURE: αρκετές extracted θέσεις UTF-16 επεκτείνονται από ένα source glyph. Μια καταχώρηση /ToUnicode που αντιστοιχίζει ένα code στο fi σου δίνει δύο χαρακτήρες που μοιράζονται ένα byte range, οπότε αντιμετώπισέ τους ως ένα source glyph και επεξεργάσου το range μία φορά
  • PDF_TEXT_CHAR_CONTENT_LOCATION_GENERATED: ο χαρακτήρας συντέθηκε κατά το layout. Τα inferred word spaces είναι η συνηθισμένη περίπτωση και δεν έχουν καθόλου source bytes, οπότε το SourceByteOffset επιστρέφει -1 και το SourceByteLength 0
  • PDF_TEXT_CHAR_CONTENT_LOCATION_ACTUALTEXT: το text που διάβασες προήλθε από replacement /ActualText. Δεν υπάρχει μοναδικό reverse mapping από το substituted string στα source bytes, οπότε το location είναι μόνο διαγνωστικό
  • PDF_TEXT_CHAR_CONTENT_LOCATION_NESTED: το glyph βρίσκεται μέσα σε Form XObject. Τα bytes είναι addressable, αλλά το Form μπορεί να σχεδιάζεται από πολλές σελίδες, οπότε η επεξεργασία του μέσω high-level API θα ήταν edit που δεν ζήτησες
  • PDF_TEXT_CHAR_CONTENT_LOCATION_TRANSCODED: το operand ήταν hex string με byte order mark UTF-16BE, το οποίο η υπάρχουσα extraction path αποκωδικοποιεί πριν από το font mapping. Τα offsets στο decoded result δεν διευθύνουν πλέον τα αρχικά bytes, οπότε το valid flag καθαρίζει
  • PDF_TEXT_CHAR_CONTENT_LOCATION_CROSS_LAYER: το string operand και ο text-showing operator του βρίσκονται σε δύο διαφορετικά streams

Η τελευταία περίπτωση αξίζει δική της πρόταση, επειδή οι engineers συχνά θεωρούν ότι δεν μπορεί να συμβεί. Το ISO 32000-1 §7.8.2 λέει ότι τα streams σε page /Contents array συνενώνονται και ότι ο διαχωρισμός τους απαιτείται μόνο να βρίσκεται σε lexical boundary. Άρα το BT /F1 16 Tf 220 340 Td (CrossLayer) σε ένα stream και το Tj ET στο επόμενο είναι απολύτως νόμιμη σελίδα. Το mapping κρατά τη διαγνωστική θέση αλλά τη χαρακτηρίζει read-only, επειδή το instruction index του operator ανήκει σε διαφορετικό layer από τα bytes του operand και η χρήση του ενός για τη διεύθυνση του άλλου θα κατέστρεφε το αρχείο

Πώς ξέρει η library αν το map παραμένει έγκυρο

Με fingerprints που ελέγχονται αμέσως πριν από το write. Κάθε extraction list καταγράφει τη source page και, για κάθε content layer, το μήκος του layer και δύο ανεξάρτητα rolling hashes: ένα FNV-1a hash και ένα DJB2-style xor hash. Πριν το ReplaceTextBlockCharSourceBytes κάνει οτιδήποτε parse, ξαναδιαβάζει το target layer και συγκρίνει και τις τρεις τιμές. Οποιαδήποτε αλλαγή byte οπουδήποτε σε εκείνο το layer επιστρέφει PDFLIB_ERROR_TEXT_LOCATION_STALE και το write δεν γίνεται. Αυτό είναι σκόπιμα conservative: ο έλεγχος γίνεται ανά layer και όχι ανά instruction, άρα ένα άσχετο edit αλλού στο ίδιο content stream invalidates επίσης το location σου. Αυτό είναι το σωστό trade-off: ένα offset σε stream που μετακινήθηκε έστω και κατά ένα byte δεν είναι σχεδόν σωστό, αλλά silent corruption. Η ίδια πειθαρχία διέπει και την υπόλοιπη επιφάνεια επεξεργασίας, μαζί με τον content-stream state tracker για CTM και clipping. Μετά από κάθε επιτυχημένη αντικατάσταση, πέτα τη list και κάνε ξανά extract

Read-only mapping μέσω Direct Access

Το DAGetTextBlockCharContentLocation σου δίνει το ίδιο record για σελίδα που ανοίχτηκε μέσω Direct Access path, με το ίδιο vocabulary flags. Είναι diagnostic-only από κατασκευή: το ReplaceTextBlockCharSourceBytes λειτουργεί πάνω στο selected editable document, ενώ το Direct Access είναι read path. Τα location data επιβιώνουν μέσα στη text block list μετά το κλείσιμο του file handle, κάτι που τα κάνει χρήσιμα για offline auditing

FileHandle:= Lib.DAOpenFileReadOnly('audit.pdf', '');
Try
  PageRef:= Lib.DAFindPage(FileHandle, 1);
  DirectList:= Lib.DAExtractPageTextBlocks(FileHandle, PageRef, 3);
  Try
    Lib.DAGetTextBlockCharContentLocation(DirectList, Block, 1,
      ContentLayer, StreamObjectNumber, StreamGeneration,
      InstructionIndex, OperandIndex, ArrayElementIndex,
      SourceByteOffset, SourceByteLength, Flags);
    // Τα locations παραμένουν readable μετά το DACloseFile
  Finally
    Lib.DAReleaseTextBlocks(DirectList);
  End;
Finally
  Lib.DACloseFile(FileHandle);
End;

Χρησιμοποίησέ το για να απαντήσεις ερωτήσεις και όχι για να αλλάξεις πράγματα. Ποιες σελίδες περιέχουν text που δεν θα μπορούσες ποτέ να επεξεργαστείς in place; Πόσο από αυτό το corpus έρχεται με /ActualText overrides; Ποιανού vendor το output χωρίζει operators σε διαφορετικά content layers; Αυτά είναι φθηνά queries όταν κάθε χαρακτήρας έχει address και αξίζει να τρέξουν πριν δεσμευτείς σε correction pipeline

Πού σταματά το point editing

Το point editing είναι νυστέρι και όχι text engine. Αλλάζει bytes in place, άρα replacement text που είναι πλατύτερο ή στενότερο από το αρχικό δεν θα κάνει reflow, δεν θα κάνει rewrap και δεν θα ενημερώσει τα kerns γύρω του. Η αντικατάσταση ενός digit με άλλο σε monospaced field είναι καλή περίπτωση. Το retyping μιας παραγράφου δεν είναι. Και σίγουρα δεν είναι security tool: η επανεγγραφή glyph bytes αφήνει τα αρχικά bytes ανακτήσιμα από το revision history του αρχείου, οπότε οτιδήποτε έχει απαίτηση εμπιστευτικότητας ανήκει στο true redaction που αφαιρεί content αντί να το καλύπτει. Σε αντάλλαγμα για αυτούς τους περιορισμούς παίρνεις ειλικρίνεια. Κάθε χαρακτήρας έχει είτε byte address πάνω στον οποίο μπορείς να δράσεις είτε named flag που λέει γιατί δεν έχει, και ο fingerprint check κάνει το stale map hard error αντί για corrupted page. Το character-to-content-byte mapping και η in-place source byte replacement διατίθενται μαζί με την text extraction και την content editing surface του PDF Library for Delphi, της native Object Pascal PDF library για Delphi, C++Builder και Lazarus