Artigo Técnico

Correção ToUnicode: NBSP e hífen condicional na extração

O PDFium Component para Delphi embute as fontes do sistema usadas pelo TPdf.AddText como fontes CID indexadas por code point Unicode, então todo CID carrega exatamente um mapeamento ToUnicode. É isso que impede que espaços extraídos voltem como U+00A0 (espaço inseparável) e hífens como U+00AD (hífen condicional, o soft hyphen), tanto no documento vivo quanto no arquivo salvo

O sintoma é malvado porque é invisível. Um índice de busca perde "two-x" porque a string armazenada contém um soft hyphen, uma exportação CSV quebra diferente, uma ferramenta de diff marca linhas que parecem idênticas em qualquer viewer. Nada na página renderizada está errado; só o Unicode por trás dos glifos está

Por que espaços extraídos voltam como U+00A0?

Espaços extraídos viram U+00A0 porque o CMap ToUnicode que o PDFium gera no FPDFText_LoadFont é indexado por glifo, e um glifo pode ser alcançado por dois code points. Na Arial, o glifo 3 serve tanto U+0020 quanto U+00A0, e o glifo do hífen serve tanto U+002D quanto U+00AD. O CMap gerado portanto mapeia o mesmo CID duas vezes, uma por uma entrada bfchar e outra por um bfrange em forma de array, e a entrada que a regra de precedência do reader favorecer vira o texto extraído

Por que um glifo da Arial quebrou a extração de texto de PDF no Delphi: U+0020 e U+00A0 alcançam o glifo 3 e U+002D e U+00AD alcançam o glifo do hífen, então o CMap ToUnicode gerado mapeia o CID 0003 duas vezes por uma entrada bfchar e um bfrange em array, e a regra de precedência do reader decide qual code point extrai
A precedência de menor-vence manteve os espaços simples por anos, até uma troca upstream para último-vence fazer todo espaço do AddText extrair como NBSP e todo hífen como soft hyphen
1 beginbfchar
<0003> <0020>
endbfchar
1 beginbfrange
<0003> <0010> [<00A0> ...]
endbfrange

Por muito tempo essa contradição foi inofensiva, já que o reader do PDFium deixava o mapeamento menor vencer. Uma mudança upstream trocou o reader para último-vence, e a partir daquela build todo espaço escrito com AddText extraía como NBSP e todo hífen como soft hyphen. Note o padrão nos pares: 0x20/0xA0 e 0x2D/0xAD diferem só no bit alto, que é exatamente o que se espera de uma fonte cujo cmap manda os sósias Latin-1 para o mesmo contorno. Se o seu código de extração estava bem ontem e agora falha em caracteres invisíveis, despeje os code points em vez de confiar na view do debugger; o básico de puxar texto está coberto em extrair texto de documentos PDF com PDFium no Delphi

uses
  SysUtils, PDFium;

const
  // Espaço/U+00A0 e hífen/U+00AD compartilham um glifo da Arial, assim como
  // Omega grego (U+03A9) e o sinal Ohm (U+2126)
  Sample: WString = 'two-x'#$00A0'y'#$00AD'z '#$03A9#$2126;

function CodePoints(const S: WString): string;
var
  I: Integer;
begin
  Result := '';
  for I := 1 to Length(S) do
    Result := Result + 'U+' + IntToHex(Ord(S[I]), 4) + ' ';
end;

var
  Pdf: TPdf;
  Live, Reloaded: WString;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.CreateDocument;
    Pdf.AddPage(1, 595, 842);
    Pdf.AddText(Sample, 'Arial', 12, 72, 770);
    Live := Pdf.Text;                  // documento vivo, não salvo
    Pdf.SaveAs('codepoints.pdf');
  finally
    Pdf.Free;
  end;

  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'codepoints.pdf';
    Pdf.Active := True;
    Pdf.PageNumber := 1;
    Reloaded := Pdf.Text;              // depois de um save completo e recarga
  finally
    Pdf.Free;
  end;

  if (Live <> Sample) or (Reloaded <> Sample) then
    Writeln('Mismatch: ', CodePoints(Live), '/ ', CodePoints(Reloaded));
end;

Por que remendar o CMap depois de salvar não bastou

Remendar o arquivo salvo conserta só o arquivo salvo, e só se o remendo mantiver a estrutura do CMap intacta byte a byte. O primeiro conserto, o RepairSubsetToUnicodeCMaps na unit FPdfCompress, roda depois de todo TPdf.SaveAs não incremental e resolve cada CID em conflito: a entrada bfchar vence, um par que difere só no bit alto resolve para o menor code point Latin-1 base, e todo o resto mantém o primeiro mapeamento

A parte interessante é o resultado negativo. Reconstruir o CMap em conflito de forma limpa, tanto em forma de start-code quanto de array, parecia o movimento óbvio, e o PDFium rejeitou todo CMap reconstruído de bandeja, caindo de volta para Identity. A única saída que o reader nativo aceitou foi uma substituição in-place de mesmo comprimento dos valores hex em conflito, com layout de blocos e cobertura de CIDs intocados. A segunda lição foi mais humilde: nossa nota da época culpava o caso em memória pelo documento vivo não ter stream ToUnicode nenhum. Chamar a DLL direto desmentiu isso, já que o documento vivo carrega o mesmo stream ambíguo, o que significava que o conserto de verdade precisava acontecer antes de o PDFium gerar o CMap. A rotina de reparo fica na biblioteca como defesa para PDFs produzidos por outras ferramentas baseadas em PDFium

uses
  Classes, FPdfCompress;

var
  Source, Dest: TFileStream;
begin
  Source := TFileStream.Create('from-other-tool.pdf',
    fmOpenRead or fmShareDenyWrite);
  try
    Dest := TFileStream.Create('repaired.pdf', fmCreate);
    try
      // Só edições de mesmo comprimento; arquivos sem conflito reparável,
      // e arquivos com cross-reference stream ou object stream, são copiados como estão
      RepairSubsetToUnicodeCMaps(Source, Dest);
    finally
      Dest.Free;
    end;
  finally
    Source.Free;
  end;
end;

Indexando a fonte por code point em vez de por glifo

O conserto de raiz é parar de pedir ao PDFium para gerar o CMap. O TPdf.LoadCachedFont agora entrega os bytes da fonte do sistema ao TPdf.LoadUnicodeKeyedCidFont, que lê a própria tabela sfnt cmap da fonte, preferindo uma subtable de formato 12 e caindo para o formato 4. Os code points voltam ordenados e deduplicados, e o CID k+1 é atribuído ao k-ésimo code point, com o CID 0 deixado como .notdef. Um CIDToGIDMap explícito manda cada CID para o glifo dele, então U+0020 e U+00A0 ganham dois CIDs diferentes que desenham o mesmo contorno, e o CMap ToUnicode mapeia cada CID para um único code point. A fonte é então carregada pelo FPDFText_LoadCidType2Font, o mesmo ponto de entrada por trás da escrita de nível de glifo em embutimento de fonte CID Type 2 com mapas CID-to-GID explícitos

O conserto indexado por code point no PDFium Component: o LoadUnicodeKeyedCidFont lê o sfnt cmap da fonte, atribui CID k+1 a cada code point ordenado com CID 0 como notdef, liga um CIDToGIDMap explícito para que U+0020 e U+00A0 mantenham CIDs diferentes, e o BuildUnicodeKeyedCidCMap dá a todo CID exatamente um code point
NBSP, soft hyphen e o sinal Ohm então sobrevivem como eles mesmos sob qualquer regra de precedência, no documento vivo e depois de qualquer save, então o reparo do CMap não encontra mais nada a consertar
// Condensado de TPdf.LoadUnicodeKeyedCidFont
SetLength(CidToGidMap, (Length(Entries) + 1) * 2);   // CID 0 = .notdef
for I := 0 to High(Entries) do
begin
  CidToGidMap[(I + 1) * 2]     := Byte(Entries[I].GlyphID shr 8);
  CidToGidMap[(I + 1) * 2 + 1] := Byte(Entries[I].GlyphID and $FF);
end;
ToUnicode := BuildUnicodeKeyedCidCMap(Entries);       // um CID, um code point
Result := FPDFText_LoadCidType2Font(Document, @Data[0], Length(Data),
  PAnsiChar(ToUnicode), @CidToGidMap[0], Length(CidToGidMap));

Quando o FPDFText_SetText depois grava uma string, a busca reversa cai num único CID por caractere, então NBSP, soft hyphen e o sinal Ohm cada um sobrevive como ele mesmo sob qualquer regra de precedência, em memória e depois de qualquer save. Como o arquivo salvo carrega o stream ToUnicode do próprio componente em vez de um gerado pelo motor, o RepairSubsetToUnicodeCMaps não encontra nada a consertar nele

Por que uma entrada bfrange pode apagar um bloco inteiro?

Um único bfrange cujo trecho de CIDs cruza uma fronteira xxFF faz o PDFium descartar o bloco inteiro em que ele está. A ISO 32000-1 §9.10.3 só deixa o último byte do destino variar dentro de um intervalo, mas o lado do CID tem armadilha própria: o HandleBeginBFRange do PDFium deriva o CID alto como (low and $FFFFFF00) or (high and $FF). Um trecho do CID 00FE a 0101 é portanto lido como 00FE a 0001, menor maior que o alto, e o bloco inteiro é marcado como inválido. A falha é silenciosa: o SetText tem sucesso, a página renderiza perfeitamente, e a extração devolve U+0000 para todo caractere daquele bloco

A armadilha silenciosa do bfrange na análise de CMaps de PDF: um trecho de CID de 00FE a 0101 cruza uma fronteira xxFF, o HandleBeginBFRange deriva o CID alto como 0001, menor maior que o alto marca o bloco inteiro como inválido, SetText e renderização continuam tendo sucesso, e a extração devolve U+0000 para todo caractere do bloco
O BuildUnicodeKeyedCidCMap evita a armadilha terminando cada trecho antes de um byte baixo de FF, mantendo blocos dentro do limite de 100 entradas e gravando code points do plano suplementar como entradas bfchar individuais

O BuildUnicodeKeyedCidCMap termina um trecho antes de o code point ou o CID alcançar um byte baixo de FF, mantém todo bloco dentro do limite de 100 entradas da gramática do CMap, e grava code points do plano suplementar como entradas bfchar individuais com destinos de par surrogate UTF-16, já que incrementar um par surrogate dentro de um intervalo não tem significado definido; o lado surrogate dessa história está em tratamento de emoji, CJK e pares surrogate no Delphi. Um CMap só de bfchar contornaria o problema de fronteira por completo, ao custo de várias vezes o tamanho

O que a fonte indexada por code point não cobre?

O caminho indexado por code point cobre toda fonte que expõe uma subtable cmap Unicode, e cai no comportamento antigo indexado por glifo para o resto. Os limites que valem conhecer antes de confiar nele:

  • Fontes symbol com só um cmap (3,0), e qualquer fonte que o caminho CID não consiga carregar, passam pelo FPDFText_LoadFont como antes, então um glifo compartilhado por dois code points ainda pode extrair de forma ambígua ali
  • Sem uma subtable de formato 12 o mapa fica limitado ao BMP, e a contagem de entradas tem teto de 65535 para que todo CID caiba em dois bytes acima de zero
  • Saves incrementais (saIncremental) pulam o RepairSubsetToUnicodeCMaps de propósito, porque uma revisão incremental precisa permanecer só de anexação; as fontes indexadas por code point tornam isso irrelevante para texto que o componente grava ele mesmo
  • TrueType Collections pedem cuidado extra: o GetFontData da GDI devolve o .ttc inteiro, e o FPDFText_LoadCidType2Font não tem parâmetro de índice de face, então pedir NSimSun do simsun.ttc costumava embutir e renderizar SimSun, a face 0. O componente agora casa o nome da família contra a tabela name (nameID 1 e 16) e extrai a face pedida como um sfnt independente antes de o cmap ser analisado; se a análise falha, os bytes da coleção passam direto e o comportamento volta para a face 0

Escrita de texto, embutimento de fontes e extração compartilham um único modelo de página entre Delphi, C++Builder e Lazarus, e a API completa está descrita na página do produto PDFium Component para Delphi