Un singur caracter greșit într-un număr de factură, iar singura primitivă de editare disponibilă rescrie întregul text run. PDF Library for Delphi închide această breșă: GetTextBlockCharContentLocation mapează fiecare poziție UTF-16 extrasă înapoi la instrucțiunea content stream, operandul și intervalul de bytes encodați care au produs-o, iar ReplaceTextBlockCharSourceBytes suprascrie doar acel interval. Extragerea textului aruncă în mod normal tot ce ți-ar trebui pentru asta. Primești Unicode, lățimi și geometrie, iar proveniența dispare, astfel încât caracterul de la poziția 7 din block-ul 3 este doar un caracter. Ce stream l-a produs, ce instrucțiune, ce operand, ce byte din acel operand: dispărute. Orice strategie de point edit construită peste această extragere trebuie să ghicească, de obicei căutând un substring în content-ul decodat și sperând că apare exact o dată. Pe o pagină reală nu apare
De ce distruge pagina rescrierea unui text run întreg?
Deoarece run-ul nu este doar text. Operatorii de text-showing din ISO 32000-1 §9.4.3 includ TJ, al cărui operand este un array ce intercalează string-uri cu ajustări numerice, iar acele numere sunt typesetting-ul. O linie așezată ca [(AB) -120 (CD)] TJ poartă un kern de 120 miimi de em între cele două string-uri. Emite un Tj nou cu textul concatenat și kern-ul dispare, linia se rearanjează puțin, iar într-un formular valoarea iese din caseta ei. Aceeași obiecție se aplică fontului: bytes din operand sunt coduri în encoding-ul selectat de Tf, nu Unicode, iar pentru un font compozit pot fi CID-uri pe doi bytes fără relație cu caracterul pe care îl citești din extractor. Regenerează run-ul și trebuie să nimerești encoding-ul fontului, maparea lui /ToUnicode și acoperirea de glife. Point editing evită toate acestea fără să iasă vreodată din domeniul bytes
Ce returnează GetTextBlockCharContentLocation?
Metoda rezolvă un caracter într-un record cu nouă câmpuri, iar fiecare câmp este o adresă, nu o valoare. ContentLayer este indexul 1-based în array-ul /Contents al paginii sau 0 când caracterul provine din content imbricat. StreamObjectNumber și StreamGeneration identifică stream-ul care îl conține. InstructionIndex este poziția 0-based în content program-ul decodat, OperandIndex este operandul stringului de text, iar ArrayElementIndex este elementul dintr-un array TJ sau -1 pentru un operand string direct. SourceByteOffset și SourceByteLength numesc apoi intervalul de bytes din stringul decodat
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 și CharPos vin din scanarea proprie a lui GetTextBlockText
If Lib.GetTextBlockCharContentLocation(ListID, Block, CharPos,
ContentLayer, StreamObjectNumber, StreamGeneration,
InstructionIndex, OperandIndex, ArrayElementIndex,
SourceByteOffset, SourceByteLength, Flags)= 1 Then
Begin
// ContentLayer = 0 înseamnă că glifa trăiește într-un Form XObject imbricat
// ArrayElementIndex = -1 înseamnă operand Tj simplu, nu array TJ
End;
Finally
Lib.ReleaseTextBlocks(ListID);
End;
Finally
Lib.Free;
End;
End;
Lookup-ul nu costă nimic la momentul interogării. În timp ce renderer-ul decodează fiecare content layer, înregistrează span-urile logice pe care le parcurge, așa că o interogare de poziție este o căutare binară într-o listă de intervale ordonată, nu o scanare liniară a fiecărui content span pentru fiecare caracter. Nimic nu este reparsat când întrebi; maparea a fost construită în timpul pass-ului de extraction pentru care ai plătit deja. Dacă enumeri deja hit-uri cu căutarea textului PDF care returnează coordonatele hit-urilor, adăugarea unei locații de content pentru fiecare hit este aproape gratuită
Editarea bytes, nu a Unicode
ReplaceTextBlockCharSourceBytes primește un AnsiString cu bytes de înlocuire raw în encoding-ul fontului PDF activ. Acesta este întregul design și este deliberat. Nimic nu face transcode, nimic nu re-encodează, nimic nu ghicește fontul. Biblioteca inserează bytes-ii tăi peste intervalul numit al stringului țintă și re-emite content layer-ul care îl conține. String-urile adiacente din același array TJ și kern-urile numerice dintre ele rămân identice byte cu byte. Ia layout-ul de mai sus: localizarea lui B în [(AB) -120 (CD)] TJ produce ArrayElementIndex 0, SourceByteOffset 1, SourceByteLength 1. Înlocuiește-l cu Z și content-ul emis conține (AZ), urmat în continuare de -120 și (CD), ambele neatinse. Regression suite verifică exact asta, deoarece afirmația „am păstrat kerning-ul” este genul de afirmație care încetează discret să mai fie adevărată
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
// Toate locațiile din lista veche sunt acum stale. Reextrage
Lib.ReleaseTextBlocks(ListID);
ListID:= Lib.ExtractPageTextBlocks(3);
End
Else If Lib.LastErrorCode= PDFLIB_ERROR_TEXT_LOCATION_STALE Then
// Layer-ul s-a schimbat sub noi de la extraction
Else If Lib.LastErrorCode= PDFLIB_ERROR_TEXT_LOCATION_READ_ONLY Then
// Un flag pe care nu l-am verificat sau un flag adăugat de o versiune ulterioară
End;
Două detalii operaționale merită reținute. Apelul schimbă temporar pagina din care a fost extrasă lista de text și restaurează pagina selectată anterior atât la succes, cât și la failure, așa că nu îți mută tăcut cursorul. Iar la succes golește snapshot-urile elementelor paginii, ceea ce invalidează orice handle pe care îl păstrai dintr-un pass de enumerare anterior
Ce caractere nu pot fi editate?
Șase categorii, iar biblioteca numește fiecare categorie în bitmask-ul Flags în loc să eșueze vag. Asta contează mai mult decât happy path-ul, deoarece în documentele reale cazurile unmappable sunt comune și fiecare are un motiv diferit
PDF_TEXT_CHAR_CONTENT_LOCATION_LIGATURE: mai multe poziții UTF-16 extrase se extind dintr-o singură glifă sursă. Un entry/ToUnicodecare mapează un cod lafiîți dă două caractere care împart același interval de bytes, așa că tratează-le ca o singură glifă sursă și editează intervalul o datăPDF_TEXT_CHAR_CONTENT_LOCATION_GENERATED: caracterul a fost sintetizat în timpul layout-ului. Spațiile de cuvinte inferate sunt cazul obișnuit și nu au deloc bytes sursă, așa căSourceByteOffsetrevine -1 iarSourceByteLengtheste 0PDF_TEXT_CHAR_CONTENT_LOCATION_ACTUALTEXT: textul citit provine dintr-o înlocuire/ActualText. Nu există o mapare inversă unică din stringul substituit către bytes sursă, deci locația este doar diagnosticăPDF_TEXT_CHAR_CONTENT_LOCATION_NESTED: glifa se află într-un Form XObject. Bytes-ii sunt adresabili, dar Form-ul poate fi desenat de mai multe pagini, așa că editarea lui prin API-ul high-level ar fi o editare pe care nu ai cerut-oPDF_TEXT_CHAR_CONTENT_LOCATION_TRANSCODED: operandul era un string hex care purta un byte order mark UTF-16BE, pe care ruta existentă de extraction îl decodează înainte de font mapping. Offset-urile în rezultatul decodat nu mai adresează bytes originali, așa că flag-ul valid este ștersPDF_TEXT_CHAR_CONTENT_LOCATION_CROSS_LAYER: operandul string și operatorul său text-showing trăiesc în două stream-uri diferite
Ultimul caz merită o propoziție separată, deoarece inginerii presupun frecvent că nu poate apărea. ISO 32000-1 §7.8.2 spune că stream-urile dintr-un array de pagină /Contents sunt concatenate, iar separarea dintre ele trebuie doar să cadă la o limită lexicală. Prin urmare BT /F1 16 Tf 220 340 Td (CrossLayer) într-un stream și Tj ET în următorul este o pagină perfect legală. Maparea păstrează poziția diagnostică, dar o marchează read-only, deoarece indexul instrucțiunii aparține unui layer diferit de bytes-ii operandului, iar folosirea unuia pentru a-l adresa pe celălalt ar corupe fișierul
Cum știe biblioteca că maparea este încă validă?
Prin fingerprints verificate imediat înainte de write. Fiecare extraction list înregistrează pagina sursă și, pentru fiecare content layer, lungimea layer-ului plus două hash-uri rolling independente: un hash FNV-1a și un hash xor de tip DJB2. Înainte ca ReplaceTextBlockCharSourceBytes să parseze ceva, recitește layer-ul țintă și compară toate cele trei valori. Orice schimbare de byte oriunde în acel layer returnează PDFLIB_ERROR_TEXT_LOCATION_STALE, iar write-ul nu se produce. Asta este conservator intenționat: verificarea este per layer, nu per instrucțiune, astfel încât o editare fără legătură în alt loc din același content stream îți invalidează și ea locația. Este compromisul corect: un offset într-un stream care s-a deplasat chiar și cu un byte nu este aproape corect, ci corupere silențioasă. Aceeași disciplină guvernează restul suprafeței de editare, inclusiv state tracker-ul content stream pentru CTM și clipping. După orice replacement reușit, aruncă lista și extrage din nou
Mapare read-only prin Direct Access
DAGetTextBlockCharContentLocation îți dă același record pentru o pagină deschisă prin ruta Direct Access, cu aceeași vocabular de flag-uri. Este doar diagnostic, prin construcție: ReplaceTextBlockCharSourceBytes operează pe documentul editabil selectat, iar Direct Access este o rută de read. Datele de locație supraviețuiesc în lista de text blocks după închiderea file handle-ului, ceea ce le face utile pentru audit offline
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);
// Locațiile rămân lizibile după DACloseFile
Finally
Lib.DAReleaseTextBlocks(DirectList);
End;
Finally
Lib.DACloseFile(FileHandle);
End;
Folosește-l pentru a răspunde la întrebări, nu pentru a schimba lucruri. Ce pagini poartă text pe care nu l-ai putea edita niciodată in-place? Cât din corpus sosește cu override-uri /ActualText? Output-ul cărui vendor împarte operatorii între content layers? Acestea sunt interogări ieftine după ce fiecare caracter are o adresă și merită rulate înainte să te angajezi într-un pipeline de corecție
Unde se oprește point editing
Point editing este un bisturiu, nu un text engine. Schimbă bytes in-place, așa că textul de înlocuire mai lat sau mai îngust decât originalul nu va reflow-ui, nu se va rewrap-ui și nu va actualiza kern-urile din jur. Înlocuirea unei cifre cu alta într-un câmp monospaced este potrivită. Rescrierea unui paragraf nu este. Și în mod categoric nu este un security tool: suprascrierea bytes-ilor glifei lasă bytes originali recuperabili din revision history a fișierului, așa că orice cu cerință de confidențialitate aparține redactării reale care elimină content-ul în loc să îl acopere. În schimbul acestor limite primești onestitate. Fiecare caracter are fie o adresă de byte asupra căreia poți acționa, fie un flag numit care explică de ce nu are, iar fingerprint check face dintr-o mapare stale o eroare hard, nu o pagină coruptă. Maparea caracter-la-content-byte și înlocuirea in-place a bytes-ilor sursă fac parte din suprafața de text extraction și content editing a PDF Library for Delphi, biblioteca PDF nativă Object Pascal pentru Delphi, C++Builder și Lazarus