Articolo tecnico

Correzione ToUnicode: NBSP e trattino morbido in Delphi

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

Perché un solo glifo Arial ha rotto l'estrazione del testo PDF in Delphi: U+0020 e U+00A0 raggiungono il glifo 3 e U+002D e U+00AD raggiungono il glifo del trattino, così la CMap ToUnicode generata mappa il CID 0003 due volte attraverso una voce bfchar e un bfrange in forma di array, e la regola di precedenza del reader decide quale code point viene estratto
La precedenza lowest-wins ha tenuto gli spazi normali per anni, finché un cambiamento upstream verso il last-wins non ha fatto estrarre ogni spazio di AddText come NBSP e ogni trattino come soft hyphen
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

La correzione indicizzata per code point in PDFium Component: LoadUnicodeKeyedCidFont legge la cmap sfnt del font, assegna il CID k+1 a ogni code point ordinato con il CID 0 come notdef, cabla un CIDToGIDMap esplicito così U+0020 e U+00A0 conservano CID diversi, e BuildUnicodeKeyedCidCMap dà a ogni CID esattamente un code point
NBSP, soft hyphen e il simbolo Ohm sopravvivono così come sé stessi sotto una qualsiasi regola di precedenza, nel documento dal vivo e dopo ogni salvataggio, così la riparazione della CMap non trova più nulla da correggere
// 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

La trappola silenziosa del bfrange nel parsing delle CMap PDF: una sequenza di CID da 00FE a 0101 attraversa un confine xxFF, HandleBeginBFRange deriva il CID alto come 0001, un basso maggiore dell'alto marca l'intero blocco invalido, SetText e il rendering riescono ancora, e l'estrazione restituisce U+0000 per ogni carattere del blocco
BuildUnicodeKeyedCidCMap evita la trappola terminando ogni sequenza prima di un byte basso di FF, tenendo i blocchi entro il limite di 100 voci e scrivendo i code point dei piani supplementari come voci bfchar individuali

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_LoadFont come 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) saltano RepairSubsetToUnicodeCMaps di 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 GetFontData di GDI restituisce l'intero .ttc, e FPDFText_LoadCidType2Font non 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