Artigo Técnico

Off-by-One no PDFDocEncoding parte o import de FDF em Delphi

Importe um FDF para um formulário e os valores de campo voltam subtilmente errados: um bullet chega como dagger, uma ellipsis como em dash. O PDFium Component rastreou toda essa família de sintomas a uma única linha na sua tabela PDFDocEncoding — o bloco de caracteres especiais no Anexo D.2 da ISO 32000-1 começa em $80, não em $7F

Porque é que um import de FDF devolve o caractere vizinho?

Porque uma tabela de codificação deslocada por uma posição continua a ser uma tabela com ar perfeitamente válido, e todas as lookups nela têm sucesso. O bloco que o Anexo D.2 atribui a $80 até $9E tinha sido transcrito uma linha cedo demais, pelo que o byte $80 resolvia para a entrada que pertence a $81, o byte $81 para a entrada de $82, e assim por diante por dezanove posições de código consecutivas. Nada levantou, nada fez log, e nenhum campo foi alguma vez rejeitado — o TPdf.ImportFDF reportava a mesma contagem de campos de sempre

O dano era uniforme e silencioso. Um byte com $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 um curly quote errado continua a parecer um curly quote num campo de formulário. Os nomes de campo sofreram o mesmo destino que os valores de campo, o que é pior: um nome que deixa de bater certo com o widget do documento falha silenciosamente ao aplicar-se

Diagrama do off-by-one do 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 uma left double quotation mark, enquanto a tabela transcrita uma linha cedo devolveu DAGGER, EM DASH e a right quotation mark para os mesmos bytes, com todas as lookups a terem sucesso
Uma tabela de codificação deslocada continua a ser uma tabela com ar válido em que todas as lookups têm sucesso, pelo que um bullet chega como dagger e um nome de campo que deixa de bater certo falha silenciosamente ao aplicar-se

As 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 primeiros dois bytes em vez de qualquer entrada de dicionário. Uma string que abre com o byte order mark FE FF é UTF-16BE; tudo o resto é PDFDocEncoding. Esse único ramo decide qual de dois decoders completamente diferentes corre, e o PDFium Component aplica-o dentro do parser de FDF antes de tocar num único caractere

Diagrama do ramo de text string da secção 7.9.2.2 da ISO 32000-1 no PDFium Component: uma string que abre com o byte order mark FE FF descodifica como UTF-16BE com pares de surrogates validados, tudo o resto vai para a tabela de um byte FdfPdfDocEncodingChar, que é a razão pela qual writers UTF-16 e nomes ASCII puros nunca expuseram o bloco deslocado até chegar um símbolo tipográfico
Inspecionar os primeiros dois bytes em vez de qualquer entrada de dicionário decide qual o decoder que corre, pelo que ferramentas que escrevem UTF-16BE por padrão nunca exercitaram a única tabela que estava errada
// Text string de 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;
  // ... descodificar unidades de código UTF-16, validando pares de surrogates
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 nos testes vulgares. Qualquer FDF produzido por uma ferramenta que escreve UTF-16BE por padrão nunca chega a FdfPdfDocEncodingChar, pelo que a tabela deslocada fica adormecida. Só strings de um byte — que é o que muitos geradores de FDF emitem para nomes de campo latinos simples, porque são mais curtos — descem pelo ramo onde a tabela importa. Nomes de campo ASCII puros descodificam corretamente na mesma, já que abaixo de $80 o PDFDocEncoding e o ASCII concordam. Precisa de uma string de um byte com um símbolo tipográfico antes de algo ficar visivelmente errado

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 em PDFDocEncoding; o bloco abre em $80 com BULLET e corre até $9E com zcaron; $9F não está 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 em PDFDocEncoding, $80 abre o bloco com BULLET, $9E fecha-o com zcaron, $9F não está atribuído, e o sinal de Euro fica sozinho em $A0, pelo que um deslocamento de bloco inteiro não pode satisfazer as pontas e os vazios ao mesmo tempo
Verificar a primeira linha, a última linha e os vazios fixa as duas pontas ao mesmo tempo, que é precisamente o teste que um deslocamento de bloco inteiro não consegue sobreviver
function FdfPdfDocEncodingChar(Value: Byte): WideChar;
begin
  case Value of
    // ISO 32000-1 Anexo D.2. O bloco corre de $80, não de $7F: $7F é DEL e
    // não tem significado em PDFDocEncoding, e o Euro fica em $A0, que o
    // ramo default devolveria de outro modo como no-break space
    $7F: Result := WideChar($FFFD);
    $80: Result := WideChar($2022);   // BULLET, primeira linha do bloco
    $81: Result := WideChar($2020);   // DAGGER
    // ... $82 até $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;

Veja o que o deslocamento fez a 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, pelo que um byte de documento $9E caía para além da última linha que alguém transcreveu e descia direto para o fallback de identidade, devolvendo um caractere de controlo C1 em vez de uma letra. E o $A0, o Euro, nunca foi escrito na tabela em primeiro lugar — o ramo de identidade devolvia-o como U+00A0 NO-BREAK SPACE, um caractere que sobrevive a todas as validações seguintes, não imprime nada, e nunca bate certo com o nome de campo que procura

Como é que testa uma tabela de codificação transcrita?

Teste a primeira linha, a última linha e os vazios — não uma amostra do meio. Esta é a parte da correção que vale a pena levar a qualquer outra tabela que copie de uma especificação, porque uma spot check a meio do bloco é precisamente o teste que um deslocamento de bloco inteiro sobrevive. Verificar que $8D dá uma left double quote 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 pode satisfazer ambas ao mesmo tempo

procedure CheckPdfDocEncodingAnchors;
begin
  // As duas pontas do bloco especial do Anexo D.2: um deslocamento de bloco inteiro parte-as
  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 descodificar para uma 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 os seus próprios asserts em vez de serem saltadas como desinteressantes. Um off-by-one tem de pôr algo em algum lado, e onde o põe é quase sempre uma posição de código que a especificação deixou vazia — $7F à frente, $9F atrás. Um teste que só verifica posições definidas pode passar enquanto a tabela está errada nas duas direções; um teste que fixa os buracos apanha o deslocamento na primeira corrida

O que isto custa num workflow de formulários em round-trip

Um import de 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. Os nomes de campo são o fio da navalha aqui. O TPdf.ImportFDF compara os nomes recebidos com os widgets do documento aberto, e um nome que descodificou um caractere mal simplesmente não bate certo — o campo fica vazio, o valor de retorno ainda conta o campo como analisado, e o relatório que sai do pipeline fica a menos de um valor sem nada no log a explicar porquê. Quem construir um loop de export-e-reimport do tipo descrito em exportar e importar dados de formulários e anotações XFDF com o PDFium Component devia fazer round-trip de um nome de campo com um símbolo tipográfico, e não só ASCII puro, antes de confiar no loop

Também vale a pena ser honesto sobre os limites da correção. A tabela agora bate certo com o Anexo D.2 para todos os 256 valores de byte, o que faz strings FDF de um byte descodificarem corretamente, mas o PDFDocEncoding em si cobre um repertório latino e nada mais — tudo o que esteja fora tem de chegar como UTF-16BE com BOM, que é exatamente o que §7.9.2.2 pretende. E uma string UTF-16 ainda tem os seus próprios perigos separados do lado do Delphi, já que um code point para além de U+FFFF precisa de um par de surrogates em vez de um WideChar, o problema coberto em porque é que emojis e caracteres CJK partem o WideChar em Delphi. O ponto mais geral vai para além das tabelas de codificação: um parser de PDF pode rejeitar algo que devia aceitar, o que se encontra imediatamente, ou aceitar algo e errá-lo em silêncio, o que se encontra em produção. As armadilhas de name tokens descritas em analisar dicionários de PDF em segurança ficam nessa segunda categoria, e o mesmo vale para uma tabela deslocada por uma linha — o code path 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 em TPdf, juntamente com os parsers de FDF e XFDF por trás deles, fazem parte do PDFium Component padrão para Delphi e C++Builder