Article technique

Correctif ToUnicode pour NBSP et trait souple en Delphi

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

Pourquoi un glyphe d'Arial a cassé l'extraction de texte PDF en Delphi : U+0020 et U+00A0 atteignent le glyphe 3 et U+002D et U+00AD atteignent le glyphe du tiret, si bien que la CMap ToUnicode générée mappe le CID 0003 deux fois, via une entrée bfchar et un bfrange tableau, et la règle de priorité du lecteur décide quel point de code est extrait
La règle du plus bas gagnant a gardé les espaces ordinaires pendant des années, jusqu'à ce qu'un passage en amont au dernier gagnant fasse extraire chaque espace de AddText en NBSP et chaque tiret en trait d'union souple
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

Le correctif indexé par point de code dans PDFium Component : LoadUnicodeKeyedCidFont lit la cmap sfnt de la police, attribue le CID k+1 à chaque point de code trié avec le CID 0 en notdef, câble un CIDToGIDMap explicite pour que U+0020 et U+00A0 gardent des CIDs différents, et BuildUnicodeKeyedCidCMap donne à chaque CID exactement un point de code
NBSP, trait d'union souple et signe Ohm survivent alors comme eux-mêmes sous l'une ou l'autre règle de priorité, dans le document vivant et après toute sauvegarde, si bien que la réparation de CMap ne trouve plus rien à corriger
// 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

Le piège silencieux du bfrange dans le parsing de CMap PDF : une série de CIDs de 00FE à 0101 franchit une frontière xxFF, HandleBeginBFRange dérive le CID haut comme 0001, un bas supérieur au haut marque le bloc entier invalide, SetText et le rendu réussissent toujours, et l'extraction renvoie U+0000 pour chaque caractère du bloc
BuildUnicodeKeyedCidCMap évite le piège en terminant chaque série avant un octet bas de FF, en gardant les blocs dans la limite de 100 entrées et en écrivant les points de code du plan supplémentaire comme entrées bfchar individuelles

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_LoadFont comme 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) sautent RepairSubsetToUnicodeCMaps par 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 : GetFontData de GDI renvoie tout le .ttc, et FPDFText_LoadCidType2Font n'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