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
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
// 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
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