Artículo técnico

Fix ToUnicode en Delphi: NBSP y guión suave en PDF

PDFium Component para Delphi embebe las fuentes del sistema que usa TPdf.AddText como fuentes CID indexadas por code point Unicode, así que cada CID lleva exactamente un mapeo ToUnicode. Eso es lo que evita que los espacios extraídos vuelvan como U+00A0 (no-break space) y los guiones como U+00AD (guión suave), tanto del documento vivo como del archivo guardado

El síntoma es desagradable porque es invisible. Un índice de búsqueda no encuentra «two-x» porque el string almacenado contiene un guión suave, un export CSV parte distinto, una herramienta de diff marca líneas que se ven idénticas en cualquier visor. Nada en la página renderizada está mal; solo lo está el Unicode detrás de los glifos

¿Por qué los espacios extraídos vuelven como U+00A0?

Los espacios extraídos se convierten en U+00A0 porque la CMap ToUnicode que PDFium genera en FPDFText_LoadFont está indexada por glifo, y a un glifo se puede llegar desde dos code points. En Arial, el glifo 3 sirve tanto a U+0020 como a U+00A0, y el glifo del guión sirve tanto a U+002D como a U+00AD. La CMap generada mapea por tanto el mismo CID dos veces, una mediante una entrada bfchar y otra mediante un bfrange en forma de array, y la entrada que favorezca la regla de precedencia del lector se convierte en el texto extraído

Por qué un glifo de Arial rompió la extracción de texto PDF en Delphi: U+0020 y U+00A0 llegan al glifo 3 y U+002D y U+00AD llegan al glifo del guión, así que la CMap ToUnicode generada mapea el CID 0003 dos veces mediante una entrada bfchar y un bfrange en array, y la regla de precedencia del lector decide qué code point se extrae
La precedencia de gana-el-más-bajo mantuvo los espacios planos años, hasta que un cambio upstream a gana-el-último hizo que cada espacio de AddText se extrajera como NBSP y cada guión como guión suave
1 beginbfchar
<0003> <0020>
endbfchar
1 beginbfrange
<0003> <0010> [<00A0> ...]
endbfrange

Durante mucho tiempo esta contradicción fue inofensiva, porque el lector de PDFium dejaba ganar al mapeo más bajo. Un cambio upstream conmutó el lector a gana-el-último, y desde aquella build cada espacio escrito con AddText se extraía como NBSP y cada guión como guión suave. Fíjate en el patrón de los pares: 0x20/0xA0 y 0x2D/0xAD solo difieren en el bit alto, que es justo lo que cabe esperar de una fuente cuyo cmap manda los look-alikes de Latin-1 al mismo contorno. Si tu código de extracción iba bien ayer y ahora falla con caracteres invisibles, vuelca los code points en vez de fiarte de la vista del debugger; lo básico de sacar texto está cubierto en cómo extraer texto de documentos PDF con PDFium en Delphi

uses
  SysUtils, PDFium;

const
  // Space/U+00A0 y hyphen/U+00AD comparten un glifo de Arial, igual que
  // Greek Omega (U+03A9) y el símbolo 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, sin guardar
    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;              // tras un guardado completo y una recarga
  finally
    Pdf.Free;
  end;

  if (Live <> Sample) or (Reloaded <> Sample) then
    Writeln('Mismatch: ', CodePoints(Live), '/ ', CodePoints(Reloaded));
end;

Por qué parchear la CMap después de guardar no bastó

Parchear el archivo guardado solo arregla el archivo guardado, y solo si el parche mantiene la estructura de la CMap intacta byte a byte. El primer fix, RepairSubsetToUnicodeCMaps en la unidad FPdfCompress, corre tras cada TPdf.SaveAs no incremental y resuelve cada CID en conflicto: gana la entrada bfchar, un par que solo difiere en el bit alto se resuelve al code point base-Latín más pequeño, y todo lo demás conserva su primer mapeo

Lo interesante es el resultado negativo. Reconstruir limpiamente la CMap en conflicto, en forma de start-code o de array, parecía el movimiento obvio, y PDFium rechazó todas las CMap reconstruidas sin más, cayendo a Identity. La única salida que el lector nativo aceptó fue un reemplazo in situ de igual longitud de los valores hex en conflicto, con el layout de bloques y la cobertura de CIDs intactos. La segunda lección fue más humilde: nuestra nota de entonces culpaba del caso en memoria a que el documento vivo no tenía ningún stream ToUnicode. Llamar a la DLL directamente lo desmintió, ya que el documento vivo lleva el mismo stream ambiguo, con lo que el fix de verdad tenía que ocurrir antes de que PDFium llegara a generar la CMap. La rutina de reparación se queda en la biblioteca como defensa frente a PDFs producidos por otras herramientas basadas en 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 ediciones de igual longitud; los archivos sin conflicto reparable,
      // y los archivos con xref stream u object stream, se copian tal cual
      RepairSubsetToUnicodeCMaps(Source, Dest);
    finally
      Dest.Free;
    end;
  finally
    Source.Free;
  end;
end;

Indexar la fuente por code point en lugar de por glifo

El fix de raíz es dejar de pedirle a PDFium que genere la CMap. TPdf.LoadCachedFont le entrega ahora los bytes de la fuente del sistema a TPdf.LoadUnicodeKeyedCidFont, que lee la tabla sfnt cmap de la propia fuente, prefiriendo una subtabla formato 12 y cayendo al formato 4. Los code points vuelven ordenados y sin duplicados, y el CID k+1 se asigna al code point k-ésimo, dejando el CID 0 como .notdef. Un CIDToGIDMap explícito manda cada CID a su glifo, así que U+0020 y U+00A0 obtienen dos CIDs distintos que dibujan el mismo contorno, y la CMap ToUnicode mapea cada CID a un único code point. La fuente se carga entonces mediante FPDFText_LoadCidType2Font, el mismo punto de entrada detrás de la escritura a nivel de glifo en embebido de fuentes CID Type 2 con mapas CID-to-GID explícitos

El fix indexado por code point en PDFium Component: LoadUnicodeKeyedCidFont lee la cmap sfnt de la fuente, asigna el CID k+1 a cada code point ordenado con el CID 0 como notdef, cablea un CIDToGIDMap explícito para que U+0020 y U+00A0 conserven CIDs distintos, y BuildUnicodeKeyedCidCMap da a cada CID exactamente un code point
NBSP, el guión suave y el símbolo Ohm sobreviven entonces como ellos mismos bajo cualquier regla de precedencia, en el documento vivo y tras cualquier guardado, así que la reparación de CMap no encuentra nada que arreglar
// 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);       // un CID, un code point
Result := FPDFText_LoadCidType2Font(Document, @Data[0], Length(Data),
  PAnsiChar(ToUnicode), @CidToGidMap[0], Length(CidToGidMap));

Cuando FPDFText_SetText escribe después un string, la búsqueda inversa aterriza en un único CID por carácter, así que NBSP, el guión suave y el símbolo Ohm sobreviven cada uno como él mismo bajo cualquier regla de precedencia, en memoria y tras cualquier guardado. Como el archivo guardado lleva el stream ToUnicode del propio componente y no uno generado por el motor, RepairSubsetToUnicodeCMaps no encuentra nada que arreglar en él

¿Por qué una entrada bfrange puede arrasar un bloque entero?

Un solo bfrange cuyo tramo de CIDs cruza un límite xxFF hace que PDFium descarte el bloque entero donde se aloja. ISO 32000-1 §9.10.3 solo deja variar el último byte del destino dentro de un rango, pero el lado del CID tiene su propia trampa: HandleBeginBFRange de PDFium deriva el CID alto como (low and $FFFFFF00) or (high and $FF). Un tramo del CID 00FE al 0101 se lee por tanto como 00FE a 0001, bajo mayor que alto, y el bloque entero se marca inválido. El fallo es silencioso: SetText tiene éxito, la página renderiza perfecta, y la extracción devuelve U+0000 para cada carácter de ese bloque

La trampa silenciosa del bfrange en el parseo de CMap PDF: un tramo de CIDs del 00FE al 0101 cruza un límite xxFF, HandleBeginBFRange deriva el CID alto como 0001, bajo mayor que alto marca el bloque entero como inválido, SetText y el renderizado siguen teniendo éxito, y la extracción devuelve U+0000 para cada carácter del bloque
BuildUnicodeKeyedCidCMap esquiva la trampa terminando cada tramo antes de un byte bajo de FF, manteniendo los bloques dentro del límite de 100 entradas y escribiendo los code points de planos suplementarios como entradas bfchar individuales

BuildUnicodeKeyedCidCMap cierra un tramo antes de que el code point o el CID alcancen un byte bajo de FF, mantiene cada bloque dentro del límite de 100 entradas de la gramática de CMap, y escribe los code points de planos suplementarios como entradas bfchar individuales con destinos en pares sustitutos UTF-16, ya que incrementar un par sustituto dentro de un rango no tiene significado definido; la parte sustituta de esa historia está en el manejo de emoji, CJK y pares sustitutos en Delphi. Una CMap solo con bfchar esquivaría el problema del límite por completo, a costa de varias veces el tamaño

¿Qué no cubre la fuente indexada por code point?

El camino indexado por code point cubre toda fuente que exponga una subtabla cmap Unicode, y cae al viejo comportamiento indexado por glifo para el resto. Los límites que conviene conocer antes de fiarte de él:

  • Las fuentes Symbol con solo una cmap (3,0), y cualquier fuente que el camino CID no logre cargar, pasan por FPDFText_LoadFont como antes, así que un glifo compartido por dos code points aún puede extraerse ambiguo ahí
  • Sin subtabla formato 12 el mapa se limita al BMP, y el conteo de entradas se cap a 65535 para que cada CID quepa en dos bytes por encima de cero
  • Los guardados incrementales (saIncremental) se saltan RepairSubsetToUnicodeCMaps a propósito, porque una revisión incremental debe seguir siendo append-only; las fuentes indexadas por code point vuelven eso irrelevante para el texto que el propio componente escribe
  • Las TrueType Collections necesitan un cuidado extra: la GetFontData de GDI devuelve el .ttc entero, y FPDFText_LoadCidType2Font no tiene parámetro de índice de face, así que pedir NSimSun desde simsun.ttc solía embeber y renderizar SimSun, la face 0. El componente ahora iguala el nombre de familia contra la tabla name (nameID 1 y 16) y extrae la face pedida como sfnt independiente antes de parsear la cmap; si el parseo falla, los bytes de la colección pasan tal cual y el comportamiento revierte a la face 0

La escritura de texto, el embebido de fuentes y la extracción comparten un único modelo de página en Delphi, C++Builder y Lazarus, y la API completa está descrita en la página de producto de PDFium Component para Delphi