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
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
// 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
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_LoadFontcomo 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 oRepairSubsetToUnicodeCMapsde 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
GetFontDatada GDI devolve o .ttc inteiro, e oFPDFText_LoadCidType2Fontnã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