Articolo tecnico

Mappare il testo PDF ai byte del content stream in Delphi

Basta un carattere sbagliato in un numero di fattura perché l'unico primitivo di modifica disponibile riscriva l'intero text run. PDF Library for Delphi colma questa lacuna: GetTextBlockCharContentLocation riconduce ogni posizione UTF-16 estratta all'istruzione del content stream, all'operand e all'intervallo di byte codificati che l'ha prodotta, mentre ReplaceTextBlockCharSourceBytes sovrascrive solo quell'intervallo. L'estrazione del testo normalmente elimina proprio tutto ciò che servirebbe. Si ottengono Unicode, larghezze e geometria, ma la provenienza scompare, così il carattere in posizione 7 del blocco 3 resta semplicemente un carattere. Quale stream l'ha prodotto, quale istruzione, quale operand, quale byte dentro quell'operand: tutto sparito. Ogni strategia di modifica puntuale costruita sopra deve indovinare, di solito cercando nel contenuto decodificato una sottostringa e sperando che compaia esattamente una volta. In una pagina reale non succede

Perché riscrivere un intero text run rovina la pagina?

Perché il run non è solo testo. Gli operatori text-showing in ISO 32000-1 §9.4.3 includono TJ, il cui operand è un array che interseca stringhe e aggiustamenti numerici, e quei numeri sono la composizione tipografica. Una riga disposta come [(AB) -120 (CD)] TJ contiene un kern di 120 millesimi di em tra le due stringhe. Emettete un nuovo Tj con il testo concatenato, il kern sparisce, la riga rifluisce di poco e in un modulo il valore esce dalla sua casella. La stessa obiezione vale per il font: i byte dell'operand sono codici nella codifica scelta da Tf, non Unicode, e per un composite font possono essere CID di due byte senza rapporto con il carattere restituito dall'estrattore. Rigenerare il run richiede di conoscere correttamente la codifica del font, la sua mappa /ToUnicode e la copertura dei glifi. La modifica puntuale evita tutto questo restando sempre nel dominio dei byte

Cosa restituisce GetTextBlockCharContentLocation?

Il metodo risolve un carattere in un record di nove campi, e ogni campo è un indirizzo anziché un valore. ContentLayer è l'indice 1-based nell'array /Contents della pagina, oppure 0 quando il carattere proviene da contenuto annidato. StreamObjectNumber e StreamGeneration identificano lo stream contenitore. InstructionIndex è la posizione 0-based nel programma di contenuto decodificato, OperandIndex l'operand stringa e ArrayElementIndex l'elemento dentro un array TJ, oppure -1 per un operand stringa diretto. SourceByteOffset e SourceByteLength indicano poi l'intervallo di byte dentro quella stringa decodificata

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 e CharPos provengono dalla vostra scansione di GetTextBlockText
      If Lib.GetTextBlockCharContentLocation(ListID, Block, CharPos,
        ContentLayer, StreamObjectNumber, StreamGeneration,
        InstructionIndex, OperandIndex, ArrayElementIndex,
        SourceByteOffset, SourceByteLength, Flags)= 1 Then
      Begin
        // ContentLayer = 0 significa che il glifo vive in un Form XObject annidato
        // ArrayElementIndex = -1 significa operand Tj semplice, non array TJ
      End;
    Finally
      Lib.ReleaseTextBlocks(ListID);
    End;
  Finally
    Lib.Free;
  End;
End;

La ricerca non costa nulla al momento della query. Mentre il renderer decodifica ogni content layer registra gli span logici che percorre, quindi una query di posizione è una ricerca binaria su una lista ordinata di intervalli invece di una scansione lineare di ogni span di contenuto per carattere. Quando lo chiedete non viene fatto alcun nuovo parsing: la mappa è stata costruita durante il passaggio di estrazione già pagato. Se state già enumerando gli hit con la ricerca del testo PDF che restituisce le coordinate degli hit, aggiungere una content location per hit costa quasi nulla

Modificare byte, non Unicode

ReplaceTextBlockCharSourceBytes riceve una AnsiString di byte di sostituzione raw nella codifica del font PDF attivo. È tutto il progetto, ed è deliberato. Nulla fa transcode, nulla ricodifica, nulla indovina il font. La libreria inserisce i vostri byte sull'intervallo nominato della stringa target e riemette il content layer che la contiene. Le stringhe adiacenti nello stesso array TJ e i kern numerici tra loro restano identici byte per byte. Prendete il layout precedente: localizzare la B in [(AB) -120 (CD)] TJ produce ArrayElementIndex 0, SourceByteOffset 1, SourceByteLength 1. Sostituitela con Z e il contenuto emesso contiene (AZ), ancora seguito da -120 e (CD), entrambi intatti. La suite di regressione lo verifica esattamente, perché "abbiamo preservato il kerning" è il tipo di affermazione che smette silenziosamente di essere vera

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
    // Ogni location della vecchia lista ora è obsoleta. Eseguire di nuovo l'estrazione.
    Lib.ReleaseTextBlocks(ListID);
    ListID:= Lib.ExtractPageTextBlocks(3);
  End
  Else If Lib.LastErrorCode= PDFLIB_ERROR_TEXT_LOCATION_STALE Then
    // Il layer è cambiato sotto di noi dopo l'estrazione
  Else If Lib.LastErrorCode= PDFLIB_ERROR_TEXT_LOCATION_READ_ONLY Then
    // Un flag che non abbiamo controllato, o aggiunto da una versione successiva
End;

Vale la pena interiorizzare due dettagli operativi. La chiamata passa temporaneamente alla pagina da cui è stata estratta la lista di testo e ripristina la pagina precedentemente selezionata sia in caso di successo sia in caso di errore, quindi non sposta silenziosamente il cursore. Inoltre, in caso di successo cancella gli snapshot degli elementi della pagina, invalidando tutti gli handle eventualmente conservati da un precedente passaggio di enumerazione

Quali caratteri non possono essere modificati?

Sei categorie, e la libreria nomina ciascuna nel bitmask Flags invece di fallire in modo vago. Conta più del percorso felice, perché nei documenti reali i casi non mappabili sono comuni e ciascuno ha una ragione diversa

  • PDF_TEXT_CHAR_CONTENT_LOCATION_LIGATURE: diverse posizioni UTF-16 estratte si espandono da un singolo glifo sorgente. Un elemento /ToUnicode che mappa un codice a fi vi dà due caratteri che condividono lo stesso intervallo di byte, quindi trattateli come un solo glifo sorgente e modificate l'intervallo una volta
  • PDF_TEXT_CHAR_CONTENT_LOCATION_GENERATED: il carattere è stato sintetizzato durante il layout. Gli spazi tra parole inferiti sono il caso tipico e non hanno byte sorgente, quindi SourceByteOffset torna -1 e SourceByteLength 0
  • PDF_TEXT_CHAR_CONTENT_LOCATION_ACTUALTEXT: il testo letto proviene da una sostituzione /ActualText. Non esiste una mappatura inversa univoca dalla stringa sostituita ai byte sorgente, quindi la location è solo diagnostica
  • PDF_TEXT_CHAR_CONTENT_LOCATION_NESTED: il glifo è dentro un Form XObject. I byte sono indirizzabili, ma il Form può essere disegnato da più pagine, quindi modificarlo tramite l'API di alto livello sarebbe una modifica non richiesta
  • PDF_TEXT_CHAR_CONTENT_LOCATION_TRANSCODED: l'operand era una stringa hex con un byte order mark UTF-16BE, che il percorso di estrazione esistente decodifica prima del mapping del font. Gli offset del risultato decodificato non indirizzano più i byte originali, quindi il flag valid viene azzerato
  • PDF_TEXT_CHAR_CONTENT_LOCATION_CROSS_LAYER: l'operand stringa e il suo operatore text-showing risiedono in due stream diversi

Quest'ultimo caso merita una frase a parte, perché gli engineer presumono regolarmente che non possa accadere. ISO 32000-1 §7.8.2 dice che gli stream nell'array /Contents di una pagina vengono concatenati, e la divisione deve cadere solo su un confine lessicale. Quindi BT /F1 16 Tf 220 340 Td (CrossLayer) in uno stream e Tj ET nel successivo descrivono una pagina perfettamente legale. La mappa conserva la posizione diagnostica ma la marca read-only, perché l'indice dell'istruzione dell'operatore appartiene a un layer diverso da quello dei byte dell'operand e usare l'uno per indirizzare l'altro corromperebbe il file

Come sa la libreria che la mappa è ancora valida?

Tramite fingerprint, controllati subito prima della scrittura. Ogni lista di estrazione registra la pagina sorgente e, per ogni content layer, la lunghezza del layer e due rolling hash indipendenti: un hash FNV-1a e un hash XOR nello stile DJB2. Prima che ReplaceTextBlockCharSourceBytes analizzi qualsiasi cosa, rilegge il layer target e confronta tutti e tre i valori. Qualsiasi modifica a un byte in qualsiasi punto di quel layer restituisce PDFLIB_ERROR_TEXT_LOCATION_STALE e la scrittura non avviene. È volutamente conservativo: il controllo è per layer, non per istruzione, quindi anche una modifica non correlata altrove nello stesso content stream invalida la location. È il compromesso corretto: un offset in uno stream che è slittato anche di un solo byte non è quasi giusto, è una corruzione silenziosa. La stessa disciplina governa il resto della superficie di editing, compreso il tracker dello stato del content stream per CTM e clipping. Dopo ogni sostituzione riuscita, eliminate la lista ed estraetela di nuovo

Mapping read-only tramite Direct Access

DAGetTextBlockCharContentLocation offre lo stesso record per una pagina aperta tramite il percorso Direct Access, con lo stesso vocabolario di flag. È solo diagnostico per costruzione: ReplaceTextBlockCharSourceBytes opera sul documento modificabile selezionato e Direct Access è un percorso di lettura. I dati di location sopravvivono nella lista dei blocchi di testo dopo la chiusura dell'handle del file, rendendoli utilizzabili per 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);
    // Le location restano leggibili dopo DACloseFile
  Finally
    Lib.DAReleaseTextBlocks(DirectList);
  End;
Finally
  Lib.DACloseFile(FileHandle);
End;

Usatela per rispondere a domande, non per cambiare le cose. Quali pagine contengono testo che non potreste mai modificare in-place? Quanta parte di questo corpus arriva con override /ActualText? Quale vendor produce output che divide gli operatori su più content layer? Sono query economiche una volta che ogni carattere ha un indirizzo, e vale la pena eseguirle prima di impegnarsi in una pipeline di correzione

Dove si ferma la modifica puntuale

La modifica puntuale è un bisturi, non un motore di testo. Cambia i byte in-place, quindi un testo di sostituzione più largo o più stretto dell'originale non rifluisce, non va a capo di nuovo e non aggiorna i kern attorno. Sostituire una cifra con un'altra in un campo monospaziato è un buon caso d'uso. Riscrivere un paragrafo no. E non è assolutamente uno strumento di sicurezza: sovrascrivere i byte dei glifi lascia i byte originali recuperabili dalla cronologia delle revisioni del file, quindi tutto ciò che richiede riservatezza appartiene a una vera redaction che rimuove il contenuto invece di coprirlo. In cambio di questi limiti si ottiene onestà. Ogni carattere ha un indirizzo byte su cui agire oppure un flag nominato che spiega perché non lo ha, e il controllo del fingerprint trasforma una mappa obsoleta in un errore netto invece che in una pagina corrotta. Il mapping carattere-byte del contenuto e la sostituzione in-place dei byte sorgente fanno parte della superficie di estrazione del testo e modifica del contenuto di PDF Library for Delphi, la libreria PDF nativa Object Pascal per Delphi, C++Builder e Lazarus