Artigo Técnico

Mapear texto PDF para bytes do content stream no Delphi

Basta um caractere errado em um número de nota fiscal para que a única primitiva de edição disponível reescreva todo o trecho de texto. O PDF Library for Delphi fecha essa lacuna: GetTextBlockCharContentLocation mapeia cada posição UTF-16 extraída de volta para a instrução do content stream, o operando e o intervalo de bytes codificados que a produziram, e ReplaceTextBlockCharSourceBytes sobrescreve somente esse intervalo. A extração de texto normalmente descarta tudo de que você precisaria para isso. Você recebe Unicode, larguras e geometria, mas a proveniência desaparece, então o caractere na posição 7 do bloco 3 é apenas um caractere. Qual stream o produziu, qual instrução, qual operando e qual byte dentro desse operando: tudo desapareceu. Toda estratégia de edição pontual construída sobre isso precisa adivinhar, normalmente procurando uma substring decodificada no conteúdo e torcendo para que ela ocorra exatamente uma vez. Em uma página real, isso não acontece

Por que reescrever todo um trecho de texto estraga a página?

Porque o trecho não é apenas texto. Os operadores de exibição de texto na ISO 32000-1 §9.4.3 incluem TJ, cujo operando é um array que intercala strings com ajustes numéricos, e esses números são a composição tipográfica. Uma linha disposta como [(AB) -120 (CD)] TJ carrega um kerning de 120 milésimos de em entre as duas strings. Emita um Tj novo com o texto concatenado e o kerning desaparece, a linha reflui por um fio e, em um formulário, o valor sai da sua caixa. A mesma objeção vale para a fonte: os bytes do operando são códigos na codificação selecionada por Tf, não Unicode, e em uma fonte composta podem ser CIDs de dois bytes sem relação com o caractere que você leu do extrator. Regenere o trecho e você terá de acertar a codificação da fonte, o mapa /ToUnicode e a cobertura de glifos. A edição pontual contorna tudo isso ao nunca sair do domínio dos bytes

O que GetTextBlockCharContentLocation retorna?

O método resolve um caractere em um record de nove campos, e cada campo é um endereço, não um valor. ContentLayer é o índice baseado em 1 no array /Contents da página, ou 0 quando o caractere veio de conteúdo aninhado. StreamObjectNumber e StreamGeneration identificam o stream que o contém. InstructionIndex é a posição baseada em 0 no programa de conteúdo decodificado, OperandIndex é o operando da string de texto, e ArrayElementIndex é o elemento dentro de um array TJ ou -1 para um operando de string direto. Por fim, SourceByteOffset e SourceByteLength nomeiam o intervalo de bytes dentro dessa string decodificada

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 vem da sua propria varredura de GetTextBlockText
      If Lib.GetTextBlockCharContentLocation(ListID, Block, CharPos,
        ContentLayer, StreamObjectNumber, StreamGeneration,
        InstructionIndex, OperandIndex, ArrayElementIndex,
        SourceByteOffset, SourceByteLength, Flags)= 1 Then
      Begin
        // ContentLayer = 0 significa que o glifo vive em um Form XObject aninhado
        // ArrayElementIndex = -1 significa um operando Tj simples, nao um array TJ
      End;
    Finally
      Lib.ReleaseTextBlocks(ListID);
    End;
  Finally
    Lib.Free;
  End;
End;

A consulta não custa nada no momento da pergunta. Enquanto o renderer decodifica cada camada de conteúdo, ele registra os spans lógicos percorridos, então uma consulta de posição é uma busca binária em uma lista ordenada de intervalos, em vez de uma varredura linear de cada span de conteúdo para cada caractere. Nada é reanalisado quando você pergunta; o mapa foi construído durante a passagem de extração pela qual você já pagou. Se você já enumera ocorrências com a pesquisa de texto PDF que retorna coordenadas das ocorrências, adicionar uma localização de conteúdo por ocorrência custa quase nada

Editando bytes, não Unicode

ReplaceTextBlockCharSourceBytes recebe uma AnsiString de bytes de substituição brutos na codificação de fonte PDF ativa. Esse é o design completo, e é deliberado. Nada faz transcodificação, nada reencoda e nada tenta adivinhar a fonte. A biblioteca intercala seus bytes sobre o intervalo nomeado da string alvo e reemite a camada de conteúdo que a contém. Strings adjacentes no mesmo array TJ e os kerns numéricos entre elas permanecem idênticos byte a byte. Retome o layout acima: localizar o B em [(AB) -120 (CD)] TJ produz ArrayElementIndex 0, SourceByteOffset 1 e SourceByteLength 1. Substitua-o por Z e o conteúdo emitido conterá (AZ), ainda seguido por -120 e (CD), ambos intocados. A suíte de regressão afirma exatamente isso, porque dizer que "preservamos o kerning" é o tipo de afirmação que deixa de ser verdade silenciosamente

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
    // Todas as localizacoes da lista antiga agora estao obsoletas. Extraia novamente.
    Lib.ReleaseTextBlocks(ListID);
    ListID:= Lib.ExtractPageTextBlocks(3);
  End
  Else If Lib.LastErrorCode= PDFLIB_ERROR_TEXT_LOCATION_STALE Then
    // A camada mudou desde a extracao
  Else If Lib.LastErrorCode= PDFLIB_ERROR_TEXT_LOCATION_READ_ONLY Then
    // Uma flag que deixamos de verificar, ou uma flag adicionada por uma versao posterior
End;

Vale internalizar dois detalhes operacionais. A chamada muda temporariamente para a página da qual a lista de texto foi extraída e restaura a página selecionada anteriormente tanto em caso de sucesso quanto de falha, portanto não move seu cursor silenciosamente. E, em caso de sucesso, ela limpa os snapshots de elementos da página, invalidando quaisquer handles que você mantinha de uma passagem de enumeração anterior

Quais caracteres não podem ser editados?

São seis categorias, e a biblioteca nomeia cada uma na máscara de bits Flags, em vez de falhar de forma vaga. Isso importa mais do que o caminho feliz, porque em documentos reais os casos sem mapeamento são comuns e cada um tem um motivo diferente

  • PDF_TEXT_CHAR_CONTENT_LOCATION_LIGATURE: várias posições UTF-16 extraídas se expandem a partir de um único glifo de origem. Uma entrada /ToUnicode que mapeia um código para fi dá dois caracteres que compartilham um intervalo de bytes, então trate-os como um único glifo de origem e edite o intervalo uma vez
  • PDF_TEXT_CHAR_CONTENT_LOCATION_GENERATED: o caractere foi sintetizado durante o layout. Espaços de palavra inferidos são o caso usual, e não têm bytes de origem, portanto SourceByteOffset retorna -1 e SourceByteLength 0
  • PDF_TEXT_CHAR_CONTENT_LOCATION_ACTUALTEXT: o texto que você leu veio de uma substituição /ActualText. Não há mapeamento reverso único da string substituída para os bytes de origem, então a localização é apenas diagnóstica
  • PDF_TEXT_CHAR_CONTENT_LOCATION_NESTED: o glifo está dentro de um Form XObject. Os bytes são endereçáveis, mas o Form pode ser desenhado por várias páginas, portanto editá-lo pela API de alto nível seria uma alteração que você não solicitou
  • PDF_TEXT_CHAR_CONTENT_LOCATION_TRANSCODED: o operando era uma string hexadecimal com uma marca de ordem de bytes UTF-16BE, que o caminho de extração existente decodifica antes do mapeamento da fonte. Offsets no resultado decodificado não endereçam mais os bytes originais, então a flag de validade é limpa
  • PDF_TEXT_CHAR_CONTENT_LOCATION_CROSS_LAYER: o operando de string e o operador de exibição de texto que o usa vivem em dois streams diferentes

Esse último caso merece uma frase própria, porque engenheiros presumem rotineiramente que ele não pode acontecer. A ISO 32000-1 §7.8.2 diz que os streams em um array /Contents de página são concatenados, e a divisão entre eles só precisa ocorrer em um limite léxico. Assim, BT /F1 16 Tf 220 340 Td (CrossLayer) em um stream e Tj ET no seguinte formam uma página perfeitamente legal. O mapeamento preserva a posição diagnóstica, mas a marca como somente leitura, pois o índice da instrução pertence a uma camada diferente daquela dos bytes do operando, e usar um para endereçar o outro corromperia o arquivo

Como a biblioteca sabe que o mapa ainda é válido?

Fingerprints, verificados imediatamente antes da escrita. Cada lista de extração registra a página de origem e, para cada camada de conteúdo, o comprimento da camada e dois hashes incrementais independentes: um hash FNV-1a e um hash no estilo DJB2 com XOR. Antes de ReplaceTextBlockCharSourceBytes analisar qualquer coisa, ele relê a camada alvo e compara os três valores. Qualquer mudança em qualquer byte dessa camada retorna PDFLIB_ERROR_TEXT_LOCATION_STALE, e a escrita não acontece. Isso é deliberadamente conservador: a verificação é por camada, não por instrução, então uma alteração sem relação em outro ponto do mesmo content stream também invalida sua localização. É a troca correta: um offset em um stream deslocado mesmo que por um byte não é um quase acerto, mas uma corrupção silenciosa. A mesma disciplina rege o restante da superfície de edição, incluindo o rastreador de estado do content stream para CTM e clipping. Depois de qualquer substituição bem-sucedida, descarte a lista e extraia novamente

Mapeamento somente leitura por Direct Access

DAGetTextBlockCharContentLocation fornece o mesmo record para uma página aberta pelo caminho de Direct Access, com o mesmo vocabulário de flags. Ele é apenas diagnóstico por construção: ReplaceTextBlockCharSourceBytes opera sobre o documento editável selecionado, e Direct Access é um caminho de leitura. Os dados de localização sobrevivem na lista de blocos de texto depois que o handle do arquivo é fechado, o que os torna úteis para auditoria 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);
    // As localizacoes continuam legiveis depois de DACloseFile
  Finally
    Lib.DAReleaseTextBlocks(DirectList);
  End;
Finally
  Lib.DACloseFile(FileHandle);
End;

Use-o para responder perguntas, não para mudar coisas. Quais páginas carregam texto que você nunca poderia editar no local? Quanto deste corpus chega com substituições /ActualText? Qual fornecedor divide operadores entre camadas de conteúdo? Essas consultas são baratas quando cada caractere tem um endereço, e vale executá-las antes de assumir um pipeline de correção

Onde a edição pontual termina

Edição pontual é um bisturi, não um mecanismo de texto. Ela altera bytes no local, então um texto de substituição mais largo ou mais estreito que o original não reflui, não quebra linha novamente e não atualiza os kerns ao redor. Substituir um dígito por outro em um campo monoespaçado é uma boa aplicação. Redigitar um parágrafo não é. E isso definitivamente não é uma ferramenta de segurança: sobrescrever bytes de glifo deixa os bytes originais recuperáveis no histórico de revisões do arquivo, portanto qualquer coisa com requisito de confidencialidade pertence à redação real que remove o conteúdo em vez de cobri-lo. Em troca desses limites, você obtém honestidade. Cada caractere tem um endereço de bytes sobre o qual é possível agir ou uma flag nomeada que explica por que não tem, e a verificação por fingerprint transforma um mapa obsoleto em erro explícito, em vez de página corrompida. O mapeamento de caractere para byte de conteúdo e a substituição de bytes de origem no local fazem parte da superfície de extração de texto e edição de conteúdo do PDF Library for Delphi, a biblioteca PDF nativa em Object Pascal para Delphi, C++Builder e Lazarus