Artigo Técnico

Mapear texto PDF para bytes do content stream em Delphi

Um único caráter errado num número de fatura, e a única primitiva de edição disponível reescreve todo o text run. O PDF Library for Delphi fecha essa lacuna: GetTextBlockCharContentLocation mapeia cada posição UTF-16 extraída de volta para a instrução, o operando e o intervalo de bytes codificados do content stream que a produziu, e ReplaceTextBlockCharSourceBytes substitui apenas esse intervalo. A extração de texto normalmente elimina tudo o que seria necessário para isto. Obtém Unicode, larguras e geometria, e a proveniência desaparece, pelo que o caráter na posição 7 do bloco 3 passa a ser apenas um caráter. Que stream o produziu, que instrução, que operando, que byte dentro desse operando: desapareceu. Qualquer estratégia de edição pontual construída sobre isso tem de adivinhar, normalmente procurando o texto descodificado no conteúdo e esperando que ocorra exatamente uma vez. Numa página real, isso não acontece

Porque é que reescrever todo um text run estraga a página?

Porque o run não é apenas texto. Os operadores de apresentação de texto na ISO 32000-1 §9.4.3 incluem TJ, cujo operando é um array que intercala strings com ajustes numéricos, e são esses números que fazem a composição tipográfica. Uma linha disposta como [(AB) -120 (CD)] TJ contém 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 muda ligeiramente de disposição e, num formulário, o valor sai da sua caixa. A mesma objeção se aplica à fonte: os bytes do operando são códigos na codificação que Tf selecionou, não Unicode, e numa fonte composta podem ser CIDs de dois bytes sem relação com o caráter que extraiu. Regenere o run e terá de acertar na codificação da fonte, no seu mapa /ToUnicode e na cobertura de glifos. A edição pontual contorna tudo isso ao nunca sair do domínio dos bytes

O que devolve GetTextBlockCharContentLocation?

O método resolve um caráter para um registo 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 caráter 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 descodificado, OperandIndex é o operando da string de texto e ArrayElementIndex é o elemento dentro de um array TJ ou -1 para um operando de string direto. SourceByteOffset e SourceByteLength identificam então o intervalo de bytes dentro dessa string descodificada

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 vêm da sua própria leitura 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 num Form XObject aninhado
        // ArrayElementIndex = -1 significa um operando Tj simples, não um array TJ
      End;
    Finally
      Lib.ReleaseTextBlocks(ListID);
    End;
  Finally
    Lib.Free;
  End;
End;

A consulta não tem custo no momento da pergunta. Enquanto o renderer descodifica cada camada de conteúdo, regista os spans lógicos que percorre, pelo que uma consulta de posição é uma pesquisa binária numa lista ordenada de intervalos, em vez de uma pesquisa linear de todos os spans de conteúdo para cada caráter. Nada é reanalisado quando pergunta; o mapa foi construído durante a passagem de extração que já pagou. Se já estiver a enumerar ocorrências com a pesquisa de texto PDF que devolve coordenadas das ocorrências, acrescentar uma localização de conteúdo por ocorrência fica praticamente gratuito

Editar bytes, não Unicode

ReplaceTextBlockCharSourceBytes recebe uma AnsiString de bytes de substituição brutos na codificação de fonte PDF ativa. Esse é todo o desenho, e é deliberado. Nada faz transcoding, nada recodifica, nada tenta adivinhar a fonte. A biblioteca intercala os seus bytes sobre o intervalo indicado da string alvo e emite novamente a camada de conteúdo que a contém. As strings adjacentes no mesmo array TJ e os kerns numéricos entre elas permanecem byte a byte idênticos. Retome a disposição 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 contém (AZ), ainda seguido de -120 e (CD), ambos intactos. A suite de regressão verifica exatamente isso, porque "preservámos o kerning" é o tipo de afirmação que deixa silenciosamente de ser verdade

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 localizações da lista antiga estão agora 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 extração
  Else If Lib.LastErrorCode= PDFLIB_ERROR_TEXT_LOCATION_READ_ONLY Then
    // Um sinalizador que não verificámos, ou um sinalizador acrescentado por uma versão posterior
End;

Vale a pena interiorizar dois detalhes operacionais. A chamada muda temporariamente para a página de onde a lista de texto foi extraída e restaura a página anteriormente selecionada tanto em caso de sucesso como de falha, pelo que não move silenciosamente o seu cursor. E, em caso de sucesso, limpa os snapshots dos elementos da página, invalidando quaisquer handles que mantivesse de uma passagem de enumeração anterior

Que carateres não podem ser editados?

Seis categorias, e a biblioteca identifica cada uma na bitmask Flags em vez de falhar de forma vaga. Isto importa mais do que o caminho feliz, porque em documentos reais os casos não mapeáveis são comuns e cada um tem uma razão diferente

  • PDF_TEXT_CHAR_CONTENT_LOCATION_LIGATURE: várias posições UTF-16 extraídas expandem-se a partir de um único glifo de origem. Uma entrada /ToUnicode que mapeie um código para fi dá-lhe dois carateres a partilhar o mesmo intervalo de bytes, por isso trate-os como um único glifo de origem e edite o intervalo uma vez
  • PDF_TEXT_CHAR_CONTENT_LOCATION_GENERATED: o caráter foi sintetizado durante a disposição. Os espaços de palavras inferidos são o caso habitual e não têm bytes de origem, pelo que SourceByteOffset devolve -1 e SourceByteLength 0
  • PDF_TEXT_CHAR_CONTENT_LOCATION_ACTUALTEXT: o texto que leu veio de uma substituição /ActualText. Não existe um mapeamento inverso único da string substituída para os bytes de origem, pelo que a localização serve apenas para diagnóstico
  • 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, pelo que editá-lo através da API de alto nível seria uma alteração que não pediu
  • PDF_TEXT_CHAR_CONTENT_LOCATION_TRANSCODED: o operando era uma string hexadecimal com uma marca de ordem de bytes UTF-16BE, que o percurso de extração existente descodifica antes do mapeamento da fonte. Os offsets no resultado descodificado já não endereçam os bytes originais, pelo que o sinalizador de validade é limpo
  • PDF_TEXT_CHAR_CONTENT_LOCATION_CROSS_LAYER: o operando da string e o seu operador de apresentação de texto vivem em dois streams diferentes

O último caso merece uma frase própria, porque os engenheiros assumem rotineiramente que não pode acontecer. A ISO 32000-1 §7.8.2 diz que os streams num array /Contents de uma página são concatenados, e que a divisão entre eles só tem de ocorrer num limite léxico. Assim, BT /F1 16 Tf 220 340 Td (CrossLayer) num stream e Tj ET no seguinte formam uma página perfeitamente legal. O mapeamento conserva a posição de diagnóstico, mas marca-a como só de leitura, porque 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 ficheiro

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

Com fingerprints, verificados imediatamente antes da escrita. Cada lista de extração regista a página de origem e, para cada camada de conteúdo, o comprimento da camada e dois hashes rolling independentes: um hash FNV-1a e um hash ao estilo DJB2 com XOR. Antes de ReplaceTextBlockCharSourceBytes analisar qualquer coisa, relê a camada alvo e compara os três valores. Qualquer alteração de bytes em qualquer ponto dessa camada devolve PDFLIB_ERROR_TEXT_LOCATION_STALE e a escrita não acontece. Isto é deliberadamente conservador: a verificação é por camada, não por instrução, pelo que uma edição não relacionada noutro ponto do mesmo content stream também invalida a sua localização. É o compromisso correto: um offset num stream que mudou nem que seja um byte não é quase certo, é corrupção silenciosa. A mesma disciplina rege o resto da superfície de edição, incluindo o state tracker de CTM e clipping do content stream. Depois de qualquer substituição bem-sucedida, elimine a lista e extraia novamente

Mapeamento só de leitura através de Direct Access

DAGetTextBlockCharContentLocation fornece o registo idêntico para uma página aberta através do percurso de Direct Access, com o vocabulário de flags idêntico. É apenas diagnóstico, por construção: ReplaceTextBlockCharSourceBytes opera sobre o documento editável selecionado e o Direct Access é um percurso de leitura. Os dados de localização sobrevivem na lista de blocos de texto depois de o handle do ficheiro ser fechado, o que os torna utilizáveis 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 localizações continuam legíveis depois de DACloseFile
  Finally
    Lib.DAReleaseTextBlocks(DirectList);
  End;
Finally
  Lib.DACloseFile(FileHandle);
End;

Use-o para responder a perguntas, não para alterar coisas. Que páginas contêm texto que nunca poderia editar no local? Quanto deste corpus chega com substituições /ActualText? Que fornecedor divide operadores por camadas de conteúdo? Essas consultas são baratas quando cada caráter tem um endereço, e vale a pena executá-las antes de se comprometer com um pipeline de correção

Onde termina a edição pontual

A edição pontual é um bisturi, não um motor de texto. Altera bytes no local, pelo que o texto de substituição mais largo ou mais estreito do que o original não faz reflow, não volta a quebrar linhas e não atualiza os kerns à sua volta. Substituir um dígito por outro num campo monoespaçado é uma boa aplicação. Reescrever um parágrafo não é. E não é, de forma alguma, uma ferramenta de segurança: substituir bytes de glifos deixa os bytes originais recuperáveis a partir do histórico de revisões do ficheiro, pelo que tudo o que tenha requisitos de confidencialidade pertence à verdadeira redação que remove o conteúdo em vez de o cobrir. Em troca desses limites obtém honestidade. Cada caráter tem um endereço de bytes sobre o qual pode agir ou um sinalizador identificado que explica por que não o tem, e a verificação de fingerprints transforma um mapa obsoleto num erro rígido em vez de numa página corrompida. O mapeamento de caráter para bytes 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