O PDFium Component for Delphi incorpora os tipos de letra do sistema usados pelo TPdf.AddText como CID fonts indexados por code point Unicode, pelo que cada CID transporta exatamente um mapeamento ToUnicode. É isso que impede os espaços extraídos de voltar como U+00A0 (no-break space) e os hífenes como U+00AD (soft hyphen), tanto no documento vivo como no ficheiro gravado
O sintoma é desagradável porque é invisível. Um índice de pesquisa perde «two-x» porque a string guardada contém um soft hyphen, uma exportação CSV divide-se de forma diferente, uma ferramenta de diff assinala linhas que parecem idênticas em qualquer visualizador. Nada na página renderizada está errado; só o Unicode por trás dos glifos está
Porque é que os espaços extraídos voltam como U+00A0?
Os espaços extraídos transformam-se em U+00A0 porque o CMap ToUnicode que o PDFium gera em FPDFText_LoadFont é indexado por glifo, e um glifo pode ser alcançado a partir de dois code points. Na Arial, o glifo 3 serve tanto o U+0020 como o U+00A0, e o glifo do hífen serve tanto o U+002D como o U+00AD. O CMap gerado mapeia portanto o mesmo CID duas vezes, uma através de uma entrada bfchar e outra através de um bfrange em forma de matriz, e a entrada que a regra de precedência do leitor favorecer torna-se o texto extraído
1 beginbfchar
<0003> <0020>
endbfchar
1 beginbfrange
<0003> <0010> [<00A0> ...]
endbfrange
Durante muito tempo esta contradição foi inofensiva, já que o leitor do PDFium deixava ganhar o mapeamento mais baixo. Uma mudança a montante passou o leitor para vence-o-último, e a partir dessa build cada espaço escrito com o AddText extraía como NBSP e cada hífen como soft hyphen. Note o padrão nos pares: 0x20/0xA0 e 0x2D/0xAD diferem apenas no bit alto, que é exatamente o que se espera de um tipo de letra cujo cmap manda os look-alikes de Latin-1 para o mesmo contorno. Se o seu código de extração estava bom ontem e agora falha em caracteres invisíveis, despeje os code points em vez de confiar na vista do debugger; os fundamentos de tirar texto de fora estão cobertos em extrair texto de documentos PDF com PDFium em Delphi
uses
SysUtils, PDFium;
const
// O espaço/U+00A0 e o hífen/U+00AD partilham um glifo da Arial, tal como
// o 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 gravado
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 uma gravação completa e recarga
finally
Pdf.Free;
end;
if (Live <> Sample) or (Reloaded <> Sample) then
Writeln('Mismatch: ', CodePoints(Live), '/ ', CodePoints(Reloaded));
end;
Porque é que remendar o CMap depois de gravar não chegava
Remendar o ficheiro gravado só corrige o ficheiro gravado, e apenas se o remendo mantiver a estrutura do CMap byte a byte intacta. A primeira correção, o RepairSubsetToUnicodeCMaps na unidade FPdfCompress, corre depois de cada TPdf.SaveAs não incremental e resolve cada CID em conflito: a entrada bfchar ganha, um par que difira apenas no bit alto resolve-se para o code point menor de base-Latin, e tudo o resto conserva o seu primeiro mapeamento
A parte interessante é o resultado negativo. Reconstruir o CMap em conflito de forma limpa, em forma de start-code ou de matriz, parecia o movimento óbvio, e o PDFium recusou todos os CMaps reconstruídos de bandeja, recuando para Identity. A única saída que o leitor nativo aceitou foi uma substituição no local de comprimento igual dos valores hex em conflito, com o layout dos blocos e a cobertura de CIDs intocados. A segunda lição foi mais humilde: a nossa nota na altura culpava o caso em memória por o documento vivo não ter stream ToUnicode nenhum. Chamar à DLL diretamente desmentiu isso, já que o documento vivo transporta o mesmo stream ambíguo, o que significava que a correção verdadeira tinha de acontecer antes de o PDFium gerar o CMap. A rotina de reparação 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 comprimento igual; ficheiros sem conflito reparável,
// e ficheiros de cross-reference-stream ou object-stream, são copiados como estão
RepairSubsetToUnicodeCMaps(Source, Dest);
finally
Dest.Free;
end;
finally
Source.Free;
end;
end;
Indexar o tipo de letra por code point em vez de por glifo
A correção de raiz é deixar de pedir ao PDFium que gere o CMap de todo. O TPdf.LoadCachedFont entrega agora os bytes do tipo de letra do sistema ao TPdf.LoadUnicodeKeyedCidFont, que lê a própria tabela cmap sfnt do tipo de letra, preferindo uma sub-tabela de formato 12 e recuando para o formato 4. Os code points voltam ordenados e sem duplicados, e o CID k+1 é atribuído ao k-ésimo code point, ficando o CID 0 como .notdef. Um CIDToGIDMap explícito manda cada CID para o seu glifo, pelo que o U+0020 e o U+00A0 recebem dois CIDs diferentes que desenham o mesmo contorno, e o CMap ToUnicode mapeia cada CID num único code point. O tipo de letra é depois carregado através do FPDFText_LoadCidType2Font, o mesmo ponto de entrada por trás da escrita ao nível de glifo em incorporação de fontes 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 escreve mais tarde uma string, a procura inversa cai num único CID por carácter, pelo que NBSP, soft hyphen e o sinal Ohm sobrevivem cada um como ele próprio sob qualquer regra de precedência, em memória e depois de qualquer gravação. Como o ficheiro gravado transporta o stream ToUnicode do próprio componente em vez de um gerado pelo motor, o RepairSubsetToUnicodeCMaps não encontra nada a corrigir nele
Porque é que uma entrada bfrange pode apagar um bloco inteiro?
Um único bfrange cuja corrida de CIDs atravesse uma fronteira xxFF faz o PDFium descartar o bloco inteiro onde se senta. A ISO 32000-1 §9.10.3 só deixa o último byte do destino variar dentro de um intervalo, mas o lado dos CIDs tem a sua própria armadilha: o HandleBeginBFRange do PDFium deriva o CID alto como (low and $FFFFFF00) or (high and $FF). Uma corrida do CID 00FE a 0101 é portanto lida como 00FE a 0001, baixo maior do que alto, e o bloco inteiro é marcado como inválido. A falha é silenciosa: o SetText tem êxito, a página renderiza perfeitamente, e a extração devolve U+0000 para todos os caracteres desse bloco
O BuildUnicodeKeyedCidCMap termina uma corrida antes de o code point ou o CID alcançar um byte baixo de FF, mantém todos os blocos dentro do limite de 100 entradas da gramática do CMap, e escreve code points do plano suplementar como entradas bfchar individuais com destinos de pares surrogate UTF-16, já que incrementar um par surrogate dentro de um intervalo não tem significado definido; o lado dos surrogates dessa história está em tratamento de emoji, CJK e pares surrogate em Delphi. Um CMap só com bfchar contornaria o problema da fronteira por inteiro, ao custo de várias vezes o tamanho
O que não cobre o tipo de letra indexado por code point?
O caminho indexado por code point cobre todos os tipos de letra que exponham uma sub-tabela cmap Unicode, e recua para o comportamento antigo indexado por glifo no resto. Os limites que valem a pena conhecer antes de confiar nele:
- Tipos de letra Symbol com apenas um cmap (3,0), e qualquer tipo de letra que o caminho CID falhe em carregar, passam pelo
FPDFText_LoadFontcomo antes, pelo que um glifo partilhado por dois code points ainda pode extrair de forma ambígua aí - Sem uma sub-tabela de formato 12, o mapa fica limitado ao BMP, e a contagem de entradas tem o teto de 65535 para que cada CID caiba em dois bytes acima de zero
- Gravações incrementais (
saIncremental) saltam oRepairSubsetToUnicodeCMapspor desenho, porque uma revisão incremental tem de se manter append-only; os tipos de letra indexados por code point tornam isso irrelevante para o texto que o componente escreve ele próprio - TrueType Collections pedem cuidado extra: o
GetFontDatada GDI devolve o .ttc inteiro, e oFPDFText_LoadCidType2Fontnão tem parâmetro de índice de face, pelo que pedir o NSimSun do simsun.ttc incorporava e renderizava o SimSun, face 0. O componente faz agora corresponder o nome da família na tabela de nomes (nameID 1 e 16) e extrai a face pedida como um sfnt autónomo antes de o cmap ser analisado; se a análise falhar, os bytes da coleção passam diretos e o comportamento volta à face 0
Escrita de texto, incorporação de tipos de letra e extração partilham um único modelo de página em Delphi, C++Builder e Lazarus, e a API completa está descrita na página do produto PDFium Component for Delphi