Il PDFium Component per Delphi incorpora i font di sistema usati da TPdf.AddText come font CID indicizzati per code point Unicode, così ogni CID porta esattamente una mappatura ToUnicode. È questo che impedisce agli spazi estratti di tornare come U+00A0 (no-break space) e ai trattini di tornare come U+00AD (soft hyphen), sia dal documento dal vivo sia dal file salvato
Il sintomo è cattivo perché è invisibile. Un indice di ricerca perde "two-x" perché la stringa memorizzata contiene un soft hyphen, un export CSV spezza in modo diverso, uno strumento di diff segnala righe che appaiono identiche in ogni viewer. Nulla nella pagina renderizzata è sbagliato; sbagliato è solo l'Unicode dietro i glifi
Perché gli spazi estratti tornano come U+00A0?
Gli spazi estratti diventano U+00A0 perché la CMap ToUnicode che PDFium genera in FPDFText_LoadFont è indicizzata per glifo, e un glifo può essere raggiunto da due code point. In Arial, il glifo 3 serve sia U+0020 sia U+00A0, e il glifo del trattino serve sia U+002D sia U+00AD. La CMap generata quindi mappa lo stesso CID due volte, una volta attraverso una voce bfchar e una attraverso un bfrange in forma di array, e la voce favorita dalla regola di precedenza del reader diventa il testo estratto
1 beginbfchar
<0003> <0020>
endbfchar
1 beginbfrange
<0003> <0010> [<00A0> ...]
endbfrange
Per molto tempo questa contraddizione è stata innocua, visto che il reader di PDFium lasciava vincere la mappatura più bassa. Un cambiamento upstream ha passato il reader al last-wins, e da quella build ogni spazio scritto con AddText veniva estratto come NBSP e ogni trattino come soft hyphen. Notate il pattern delle coppie: 0x20/0xA0 e 0x2D/0xAD differiscono solo nel bit alto, che è esattamente ciò che vi aspettereste da un font il cui cmap manda i sosia Latin-1 allo stesso contorno. Se il vostro codice di estrazione funzionava ieri e oggi fallisce su caratteri invisibili, fate il dump dei code point invece di fidarvi della vista del debugger; le basi dell'estrazione del testo sono trattate in estrarre testo da documenti PDF con PDFium in Delphi
uses
SysUtils, PDFium;
const
// Spazio/U+00A0 e trattino/U+00AD condividono un glifo Arial, come
// l'Omega greco (U+03A9) e il simbolo 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 dal vivo, non salvato
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; // dopo un salvataggio completo e un ricaricamento
finally
Pdf.Free;
end;
if (Live <> Sample) or (Reloaded <> Sample) then
Writeln('Mismatch: ', CodePoints(Live), '/ ', CodePoints(Reloaded));
end;
Perché patchare la CMap dopo il salvataggio non bastava
Patchare il file salvato corregge solo il file salvato, e solo se la patch lascia la struttura della CMap intatta byte per byte. La prima correzione, RepairSubsetToUnicodeCMaps nell'unit FPdfCompress, gira dopo ogni TPdf.SaveAs non incrementale e risolve ogni CID in conflitto: vince la voce bfchar, una coppia che differisce solo nel bit alto si risolve nel code point base-Latin più piccolo, e tutto il resto conserva la sua prima mappatura
La parte interessante è il risultato negativo. Ricostruire in modo pulito la CMap in conflitto, in forma start-code o array, sembrava la mossa ovvia, e PDFium ha respinto a occhi chiusi ogni CMap ricostruita, ricadendo su Identity. L'unico output che il reader nativo accettava era una sostituzione sul posto a lunghezza uguale dei valori hex in conflitto, con layout dei blocchi e copertura dei CID intatti. La seconda lezione è stata più umile: la nostra nota di allora dava la colpa del caso in memoria al documento dal vivo privo di qualsiasi stream ToUnicode. Chiamare la DLL direttamente ha smentito quella tesi, visto che il documento dal vivo porta lo stesso stream ambiguo, il che significava che la vera correzione doveva avvenire prima che PDFium generasse la CMap. La routine di riparazione resta nella libreria come difesa per i PDF prodotti da altri strumenti basati su 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
// Solo modifiche a lunghezza uguale; i file senza un conflitto riparabile,
// e i file con stream di riferimenti incrociati o object stream, vengono copiati così come sono
RepairSubsetToUnicodeCMaps(Source, Dest);
finally
Dest.Free;
end;
finally
Source.Free;
end;
end;
Indicizzare il font per code point invece che per glifo
La correzione alla radice è smettere del tutto di chiedere a PDFium di generare la CMap. TPdf.LoadCachedFont ora passa i byte del font di sistema a TPdf.LoadUnicodeKeyedCidFont, che legge la tabella sfnt cmap del font stesso, preferendo una sottotabella formato 12 e ricadendo sul formato 4. I code point tornano ordinati e deduplicati, e il CID k+1 viene assegnato al k-esimo code point, con il CID 0 lasciato come .notdef. Un CIDToGIDMap esplicito manda ogni CID al suo glifo, così U+0020 e U+00A0 ricevono due CID diversi che disegnano lo stesso contorno, e la CMap ToUnicode mappa ogni CID a un solo code point. Il font viene poi caricato tramite FPDFText_LoadCidType2Font, lo stesso punto d'ingresso dietro la scrittura a livello di glifo in embedding di font CID Type 2 con mappe CID-to-GID esplicite
// Condensato da 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); // un CID, un code point
Result := FPDFText_LoadCidType2Font(Document, @Data[0], Length(Data),
PAnsiChar(ToUnicode), @CidToGidMap[0], Length(CidToGidMap));
Quando FPDFText_SetText scrive in seguito una stringa, la ricerca inversa atterra su un singolo CID per carattere, così NBSP, soft hyphen e simbolo Ohm sopravvivono ciascuno come sé stesso sotto una qualsiasi regola di precedenza, in memoria e dopo ogni salvataggio. Siccome il file salvato porta lo stream ToUnicode del componente stesso invece di uno generato dall'engine, RepairSubsetToUnicodeCMaps non trova lì nulla da correggere
Perché una sola voce bfrange può cancellare un intero blocco?
Un solo bfrange la cui sequenza di CID attraversa un confine xxFF fa scartare a PDFium l'intero blocco in cui sta. ISO 32000-1 §9.10.3 lascia variare solo l'ultimo byte della destinazione dentro un range, ma il lato CID ha la sua trappola: HandleBeginBFRange di PDFium deriva il CID alto come (low and $FFFFFF00) or (high and $FF). Una sequenza dal CID 00FE a 0101 viene quindi letta come da 00FE a 0001, basso maggiore dell'alto, e l'intero blocco viene marcato invalido. Il fallimento è silenzioso: SetText riesce, la pagina si renderizza perfettamente, e l'estrazione restituisce U+0000 per ogni carattere di quel blocco
BuildUnicodeKeyedCidCMap termina una sequenza prima che il code point o il CID raggiunga un byte basso di FF, tiene ogni blocco entro il limite di 100 voci della grammatica CMap, e scrive i code point dei piani supplementari come voci bfchar individuali con destinazioni come coppie surrogate UTF-16, visto che incrementare una coppia surrogate dentro un range non ha significato definito; il versante surrogate di quella storia è in gestione di emoji, CJK e coppie surrogate in Delphi. Una CMap fatta solo di bfchar aggirerebbe del tutto il problema del confine, a costo di diverse volte la dimensione
Che cosa non copre il font indicizzato per code point?
Il percorso indicizzato per code point copre ogni font che espone una sottotabella cmap Unicode, e ricade sul vecchio comportamento indicizzato per glifo per il resto. I confini che vale la pena conoscere prima di farci affidamento:
- I font symbol con solo una cmap (3,0), e qualsiasi font che il percorso CID non riesce a caricare, passano per
FPDFText_LoadFontcome prima, quindi un glifo condiviso da due code point può ancora estrarsi in modo ambiguo lì - Senza una sottotabella formato 12 la mappa è limitata al BMP, e il conteggio delle voci è tappato a 65535 così ogni CID sta in due byte sopra lo zero
- I salvataggi incrementali (
saIncremental) saltanoRepairSubsetToUnicodeCMapsdi proposito, perché una revisione incrementale deve restare append-only; i font indicizzati per code point rendono la cosa irrilevante per il testo che il componente scrive da sé - Le TrueType Collection richiedono cura extra: la
GetFontDatadi GDI restituisce l'intero .ttc, eFPDFText_LoadCidType2Fontnon ha un parametro di indice di face, quindi chiedere NSimSun da simsun.ttc incorporava e renderizzava SimSun, la face 0. Il componente ora confronta il nome della famiglia con la name table (nameID 1 e 16) ed estrae la face richiesta come sfnt autonomo prima che la cmap venga parsata; se il parsing fallisce, i byte della collection passano attraverso e il comportamento torna alla face 0
Scrittura del testo, embedding dei font ed estrazione condividono un unico modello di pagina su Delphi, C++Builder e Lazarus, e l'API completa è descritta sulla pagina del prodotto PDFium Component per Delphi