PDFium Component pour Delphi embarque les polices système utilisées par TPdf.AddText comme polices CID indexées par point de code Unicode, si bien que chaque CID porte exactement un mapping ToUnicode. C'est ce qui empêche les espaces extraites de revenir en U+00A0 (espace insécable) et les tirets en U+00AD (trait d'union souple), à la fois depuis le document vivant et depuis le fichier sauvegardé
Le symptôme est vicieux parce qu'il est invisible. Un index de recherche rate two-x parce que la chaîne stockée contient un trait d'union souple, un export CSV découpe différemment, un outil de diff signale des lignes identiques dans toutes les visionneuses. Rien dans la page rendue n'est faux ; seul l'Unicode derrière les glyphes l'est
Pourquoi les espaces extraites reviennent-elles en U+00A0 ?
Les espaces extraites deviennent U+00A0 parce que la CMap ToUnicode que PDFium génère dans FPDFText_LoadFont est indexée par glyphe, et un glyphe peut être atteint depuis deux points de code. Dans Arial, le glyphe 3 sert à la fois U+0020 et U+00A0, et le glyphe du tiret sert à la fois U+002D et U+00AD. La CMap générée mappe donc le même CID deux fois, une fois via une entrée bfchar et une fois via un bfrange sous forme de tableau, et l'entrée que favorise la règle de priorité du lecteur devient le texte extrait
1 beginbfchar
<0003> <0020>
endbfchar
1 beginbfrange
<0003> <0010> [<00A0> ...]
endbfrange
Pendant longtemps cette contradiction fut inoffensive, puisque le lecteur de PDFium laissait gagner le mapping le plus bas. Un changement en amont a basculé le lecteur vers le dernier gagnant, et depuis ce build chaque espace écrite avec AddText s'extrayait en NBSP et chaque tiret en trait d'union souple. Notez le motif des paires : 0x20/0xA0 et 0x2D/0xAD ne diffèrent que par le bit de poids fort, exactement ce à quoi l'on s'attend d'une police dont la cmap envoie les sosies Latin-1 vers le même contour. Si votre code d'extraction allait bien hier et échoue maintenant sur des caractères invisibles, déchargez les points de code plutôt que de vous fier à la vue du débogueur ; les bases du retrait de texte sont couvertes dans l'extraction de texte de documents PDF avec PDFium en Delphi
uses
SysUtils, PDFium;
const
// Espace/U+00A0 et tiret/U+00AD partagent un glyphe d'Arial, tout comme
// Omega grec (U+03A9) et le signe 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; // document vivant, non sauvegardé
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; // après une sauvegarde complète et un rechargement
finally
Pdf.Free;
end;
if (Live <> Sample) or (Reloaded <> Sample) then
Writeln('Mismatch: ', CodePoints(Live), '/ ', CodePoints(Reloaded));
end;
Pourquoi patcher la CMap après sauvegarde ne suffisait pas
Patcher le fichier sauvegardé ne corrige que le fichier sauvegardé, et seulement si le patch garde la structure de la CMap intacte octet pour octet. Le premier correctif, RepairSubsetToUnicodeCMaps dans l'unité FPdfCompress, s'exécute après chaque TPdf.SaveAs non incrémental et résout chaque CID en conflit : l'entrée bfchar gagne, une paire qui ne diffère que par le bit de poids fort se résout vers le plus petit point de code Latin de base, et tout le reste garde son premier mapping
La partie intéressante est le résultat négatif. Reconstruire proprement la CMap en conflit, sous forme start-code ou tableau, ressemblait à l'évidence, et PDFium a rejeté chaque CMap reconstruite, en retombant sur Identity. La seule sortie que le lecteur natif acceptait était un remplacement sur place de même longueur des valeurs hexadécimales en conflit, avec la disposition des blocs et la couverture de CIDs intactes. La deuxième leçon fut plus humble : notre note de l'époque imputait le cas en mémoire à un document vivant sans aucun flux ToUnicode. Appeler la DLL directement a démenti cela, puisque le document vivant porte le même flux ambigu, ce qui signifiait que le vrai correctif devait arriver avant même que PDFium ne génère la CMap. La routine de réparation reste dans la bibliothèque comme défense contre les PDF produits par d'autres outils basés sur 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
// Éditions de même longueur seulement ; les fichiers sans conflit réparable,
// et les fichiers cross-reference-stream ou object-stream, sont copiés tels quels
RepairSubsetToUnicodeCMaps(Source, Dest);
finally
Dest.Free;
end;
finally
Source.Free;
end;
end;
Indexer la police par point de code au lieu de par glyphe
Le correctif racine consiste à cesser de demander à PDFium de générer la CMap. TPdf.LoadCachedFont remet désormais les octets de la police système à TPdf.LoadUnicodeKeyedCidFont, qui lit la propre table sfnt cmap de la police, en préférant une sous-table de format 12 avec repli sur format 4. Les points de code reviennent triés et dédupliqués, et le CID k+1 est attribué au k-ième point de code, le CID 0 restant .notdef. Un CIDToGIDMap explicite envoie chaque CID vers son glyphe, si bien que U+0020 et U+00A0 obtiennent deux CIDs différents qui dessinent le même contour, et la CMap ToUnicode mappe chaque CID vers un unique point de code. La police est ensuite chargée via FPDFText_LoadCidType2Font, le même point d'entrée derrière l'écriture au niveau glyphe dans l'incorporation de polices CID Type 2 avec CID-to-GID explicites
// Condensé depuis 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 point de code
Result := FPDFText_LoadCidType2Font(Document, @Data[0], Length(Data),
PAnsiChar(ToUnicode), @CidToGidMap[0], Length(CidToGidMap));
Quand FPDFText_SetText écrit ensuite une chaîne, la recherche inverse atterrit sur un CID unique par caractère, si bien que NBSP, trait d'union souple et signe Ohm survivent chacun comme eux-mêmes sous l'une ou l'autre règle de priorité, en mémoire et après toute sauvegarde. Comme le fichier sauvegardé porte le propre flux ToUnicode du composant plutôt qu'un flux généré par le moteur, RepairSubsetToUnicodeCMaps n'y trouve rien à corriger
Pourquoi une seule entrée bfrange peut-elle effacer un bloc entier ?
Un seul bfrange dont la série de CIDs franchit une frontière xxFF fait jeter par PDFium le bloc entier dans lequel il se trouve. L'ISO 32000-1 §9.10.3 ne laisse varier que le dernier octet de la destination à l'intérieur d'une plage, mais le côté CID a son propre piège : HandleBeginBFRange de PDFium dérive le CID haut comme (low and $FFFFFF00) or (high and $FF). Une série du CID 00FE à 0101 se lit donc 00FE à 0001, bas supérieur à haut, et le bloc entier est marqué invalide. L'échec est silencieux : SetText réussit, la page se rend parfaitement, et l'extraction renvoie U+0000 pour chaque caractère de ce bloc
BuildUnicodeKeyedCidCMap termine une série avant que le point de code ou le CID n'atteigne un octet bas de FF, garde chaque bloc dans la limite de 100 entrées de la grammaire CMap, et écrit les points de code du plan supplémentaire comme entrées bfchar individuelles avec des destinations paires de surrogates UTF-16, puisque incrémenter une paire de surrogates à l'intérieur d'une plage n'a pas de sens défini ; le versant surrogate de cette histoire est dans la gestion des emoji, CJK et paires de surrogates en Delphi. Une CMap en bfchar uniquement contournerait le problème de frontière entièrement, pour plusieurs fois la taille
Que ne couvre pas la police indexée par point de code ?
Le chemin indexé par point de code couvre chaque police qui expose une sous-table cmap Unicode, et retombe sur l'ancien comportement indexé par glyphe pour le reste. Les frontières à connaître avant de vous y fier :
- Les polices Symbol avec seulement une cmap (3,0), et toute police que le chemin CID échoue à charger, passent par
FPDFText_LoadFontcomme avant, si bien qu'un glyphe partagé par deux points de code peut encore s'extraire de façon ambiguë là - Sans sous-table de format 12, la carte se limite au BMP, et le nombre d'entrées est plafonné à 65535 pour que chaque CID tienne dans deux octets au-dessus de zéro
- Les sauvegardes incrémentales (
saIncremental) sautentRepairSubsetToUnicodeCMapspar conception, parce qu'une révision incrémentale doit rester en ajout seul ; les polices indexées par point de code rendent cela sans objet pour le texte que le composant écrit lui-même - Les TrueType Collections demandent un soin supplémentaire :
GetFontDatade GDI renvoie tout le .ttc, etFPDFText_LoadCidType2Fontn'a pas de paramètre d'index de face, si bien que demander NSimSun depuis simsun.ttc incorporait et rendait autrefois SimSun, face 0. Le composant apparie désormais le nom de famille contre la table de noms (nameID 1 et 16) et extrait la face demandée comme sfnt autonome avant que la cmap ne soit analysée ; si l'analyse échoue, les octets de la collection passent tels quels et le comportement revient à la face 0
Écriture de texte, incorporation de polices et extraction partagent un modèle de page unique à travers Delphi, C++Builder et Lazarus, et l'API complète est décrite sur la page produit de PDFium Component pour Delphi