Artigo Técnico

Off-by-one na PDFDocEncoding quebra import de FDF no Delphi

Importe um FDF num formulário e os valores dos campos voltam sutilmente errados: um bullet chega como dagger, uma reticência como em dash. O PDFium Component rastreou essa família inteira de sintomas até uma única linha na sua tabela de PDFDocEncoding — o bloco de caracteres especiais do Anexo D.2 da ISO 32000-1 começa em $80, não em $7F

Por que um import FDF devolve o caractere vizinho?

Porque uma tabela de codificação deslocada em uma posição continua sendo uma tabela de aparência perfeitamente válida, e todo lookup nela tem sucesso. O bloco que o Anexo D.2 atribui de $80 a $9E tinha sido transcrito uma linha antes, então o byte $80 resolvia para a entrada que pertence ao $81, o byte $81 para a entrada do $82, e assim por diante por dezenove posições de código consecutivas. Nada levantou, nada logou, e nenhum campo foi jamais rejeitado — o TPdf.ImportFDF reportava a mesma contagem de campos de sempre

O dano era uniforme e silencioso. Um byte carregando $80, que o Anexo D.2 define como BULLET (U+2022), voltava como DAGGER (U+2020). O byte $83, HORIZONTAL ELLIPSIS (U+2026), voltava como EM DASH (U+2014). O byte $8D, LEFT DOUBLE QUOTATION MARK (U+201C), voltava como RIGHT DOUBLE QUOTATION MARK (U+201D) — o caso que ninguém nota, porque uma aspa curva errada ainda parece uma aspa curva num campo de formulário. Nomes de campo sofriam o mesmo destino que valores de campo, o que é pior: um nome que não bate mais com o widget do documento silenciosamente deixa de aplicar

Diagrama do off-by-one da PDFDocEncoding por trás do dano no import de FDF no PDFium Component: o Anexo D.2 da ISO 32000-1 mapeia o byte $80 para BULLET, $83 para HORIZONTAL ELLIPSIS e $8D para aspas duplas esquerdas, enquanto a tabela transcrita uma linha antes devolvia DAGGER, EM DASH e as aspas direitas para os mesmos bytes, com todo lookup ainda tendo sucesso
Uma tabela de codificação deslocada continua sendo uma tabela de aparência válida na qual todo lookup tem sucesso, então um bullet chega como dagger e um nome de campo que não bate mais silenciosamente deixa de aplicar

Strings de texto de PDF carregam duas codificações diferentes

A ISO 32000-1 §7.9.2.2 define o tipo text string como uma de exatamente duas codificações, escolhida inspecionando os dois primeiros bytes em vez de qualquer entrada de dicionário. Uma string que abre com o byte order mark FE FF é UTF-16BE; qualquer outra coisa é PDFDocEncoding. Esse único branch decide qual de dois decoders completamente diferentes roda, e o PDFium Component o aplica dentro do parser de FDF antes de tocar um único caractere

Diagrama do branch de text string da seção 7.9.2.2 da ISO 32000-1 no PDFium Component: uma string que abre com o byte order mark FE FF decodifica como UTF-16BE com pares de surrogates validados, qualquer outra coisa vai para a tabela single-byte FdfPdfDocEncodingChar, e é por isso que writers UTF-16 e nomes ASCII puros nunca expuseram o bloco deslocado até chegar um símbolo tipográfico
Inspecionar os dois primeiros bytes em vez de qualquer entrada de dicionário decide qual decoder roda, então ferramentas que escrevem UTF-16BE por padrão nunca exercitaram a única tabela que estava errada
// String de texto FDF: UTF-16 quando o BOM está presente, PDFDocEncoding caso contrário
if (Length(Raw) >= 2) and
   (((Byte(Raw[1]) = $FE) and (Byte(Raw[2]) = $FF)) or
    ((Byte(Raw[1]) = $FF) and (Byte(Raw[2]) = $FE))) then
begin
  BigEndian := Byte(Raw[1]) = $FE;
  // ... decodificar unidades de código UTF-16, validando pares substitutos
end
else
begin
  SetLength(Result, Length(Raw));
  for I := 1 to Length(Raw) do
    Result[I] := FdfPdfDocEncodingChar(Byte(Raw[I]));
end;

É por isso que o bug se escondeu tanto tempo em testes comuns. Qualquer FDF produzido por uma ferramenta que escreve UTF-16BE por padrão nunca chega ao FdfPdfDocEncodingChar de jeito nenhum, então a tabela deslocada fica dormente. Só strings single-byte — que é o que muitos geradores de FDF emitem para nomes de campo latinos puros, porque são mais curtas — descem pelo branch onde a tabela importa. Nomes de campo ASCII puros então decodificam certo mesmo assim, já que abaixo de $80 a PDFDocEncoding e o ASCII concordam. Você precisa de uma string single-byte contendo um símbolo tipográfico antes de qualquer coisa sair visivelmente errada

A tabela corrigida, e as duas âncoras que a fixam

O PDFium Component agora explicita a fronteira no próprio FdfPdfDocEncodingChar, porque as duas pontas do bloco são exatamente o que um implementador erra. $7F é DEL e não tem significado nenhum na PDFDocEncoding; o bloco abre em $80 com BULLET e vai até $9E com zcaron; $9F não é atribuído; e o sinal de Euro fica sozinho em $A0

Diagrama das âncoras de fronteira que fixam a tabela do Anexo D.2 no PDFium Component: $7F é DEL sem significado na PDFDocEncoding, $80 abre o bloco com BULLET, $9E o fecha com zcaron, $9F não é atribuído, e o sinal de Euro fica sozinho em $A0, então um deslocamento de bloco inteiro não consegue satisfazer as pontas e as lacunas ao mesmo tempo
Verificar a primeira linha, a última linha e as lacunas fixa as duas pontas de uma vez, que é exatamente o teste que um deslocamento de bloco inteiro não sobrevive
function FdfPdfDocEncodingChar(Value: Byte): WideChar;
begin
  case Value of
    // ISO 32000-1 Anexo D.2. O bloco vai de $80, não de $7F: $7F é DEL e
    // não tem significado em PDFDocEncoding, e o Euro fica em $A0, que o
    // o ramo padrão devolveria como espaço sem quebra
    $7F: Result := WideChar($FFFD);
    $80: Result := WideChar($2022);   // BULLET, primeira linha do bloco
    $81: Result := WideChar($2020);   // DAGGER
    // ... $82 through $9D
    $9E: Result := WideChar($017E);   // zcaron, última linha definida
    $9F: Result := WideChar($FFFD);   // não atribuído no Anexo D.2
    $A0: Result := WideChar($20AC);   // EURO SIGN
  else
    Result := WideChar(Value);
  end;
end;

Olhe o que o deslocamento fez com essas duas pontas, porque é aí que um off-by-one deixa de ser cosmético. O zcaron ficava em $9E na especificação, mas em $9D na tabela deslocada, então um byte de documento $9E caía depois da última linha que alguém tinha transcrito e despencava direto no fallback de identidade, devolvendo um caractere de controle C1 em vez de uma letra. E o $A0, o Euro, nunca tinha sido escrito na tabela em primeiro lugar — o branch de identidade devolvia U+00A0 NO-BREAK SPACE, um caractere que sobrevive a toda validação seguinte, imprime como nada, e nunca bate com o nome de campo que você procura

Como você testa uma tabela de codificação transcrita?

Teste a primeira linha, a última linha e as lacunas — não uma amostra do meio. Essa é a parte do fix que vale levar para qualquer outra tabela que você copie de uma especificação, porque um spot check no meio do bloco é precisamente o teste que um deslocamento de bloco inteiro sobrevive. Verificar que $8D dá uma aspa dupla esquerda não prova nada quando $8C também dá algo plausível; verificar que $80 dá BULLET e $9E dá zcaron fixa as duas pontas, e um bloco deslocado não satisfaz as duas ao mesmo tempo

procedure CheckPdfDocEncodingAnchors;
begin
  // Ambas as extremidades do bloco especial do Anexo D.2: deslocar o bloco inteiro quebra estas
  Assert(FdfPdfDocEncodingChar($80) = WideChar($2022), 'first row: BULLET');
  Assert(FdfPdfDocEncodingChar($9E) = WideChar($017E), 'last row: zcaron');
  // Posições de código que a especificação deixa indefinidas não devem decodificar como letra
  Assert(FdfPdfDocEncodingChar($7F) = WideChar($FFFD), '$7F is DEL, unmapped');
  Assert(FdfPdfDocEncodingChar($9F) = WideChar($FFFD), '$9F is unassigned');
  // A entrada isolada acima do bloco, fácil de omitir por completo
  Assert(FdfPdfDocEncodingChar($A0) = WideChar($20AC), 'EURO SIGN');
end;

As posições indefinidas merecem asserts próprios em vez de serem puladas como desinteressantes. Um off-by-one tem que pôr algo em algum lugar, e onde ele põe é quase sempre uma posição de código que a especificação deixou vazia — $7F na frente, $9F atrás. Um teste que só verifica posições definidas pode passar com a tabela errada nas duas direções; um teste que fixa os buracos pega o deslocamento na primeira execução

O que isso custa num fluxo de formulário com round-trip

Um import FDF que reescreve silenciosamente um caractere por símbolo é pior do que um que falha, porque a falha aparece a jusante e parece outra coisa. Nomes de campo são a aresta afiada aqui. O TPdf.ImportFDF compara nomes de entrada contra os widgets do documento aberto, e um nome que decodificou um caractere errado simplesmente não bate — o campo fica vazio, o valor de retorno ainda conta o campo como parseado, e o relatório que sai do pipeline fica faltando um valor sem nada no log explicando por quê. Quem constrói um loop de export-and-reimport do tipo descrito em exportando e importando dados de formulário XFDF e anotações com PDFium Component deveria fazer round-trip de um nome de campo contendo um símbolo tipográfico, não só ASCII puro, antes de confiar no loop

Também vale ser honesto sobre os limites do fix. A tabela agora bate com o Anexo D.2 para todos os 256 valores de byte, o que faz strings single-byte de FDF decodificarem certo, mas a própria PDFDocEncoding cobre um repertório latino e nada mais — qualquer coisa fora dele tem que chegar como UTF-16BE com BOM, que é exatamente o que a §7.9.2.2 pretende. E uma string UTF-16 ainda tem os próprios perigos separados no lado Delphi, já que um code point além de U+FFFF precisa de um par de surrogates em vez de um WideChar, o problema coberto em por que emoji e caracteres CJK quebram o WideChar em Delphi. O ponto maior generaliza para além de tabelas de codificação: um parser de PDF pode rejeitar algo que deveria aceitar, o que você descobre na hora, ou aceitar algo e silenciosamente errar, o que você descobre em produção. As armadilhas de name token descritas em parseando dicionários de PDF com segurança ficam na segunda categoria, e uma tabela deslocada em uma linha também — o caminho de código tem sucesso, o valor de retorno parece razoável, e só um teste escrito contra a especificação em vez de contra a implementação diz o contrário

O ImportFDF, o ExportFDF e o ImportXFDF do TPdf, junto com os parsers de FDF e XFDF por trás deles, fazem parte do PDFium Component padrão para Delphi e C++Builder