Artículo técnico

Fix de ToUnicode: NBSP y soft hyphen en extracción PDF

PDFium Component para Delphi incrusta las fuentes del sistema que usa TPdf.AddText como fuentes CID indexadas por code point Unicode, así que cada CID carga 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 (soft hyphen), tanto del documento vivo como del archivo guardado

El síntoma es sucio porque es invisible. Un índice de búsqueda no encuentra «two-x» porque el string guardado contiene un soft hyphen, un export CSV parte distinto, una herramienta de diff marca líneas que se ven idénticas en todos los visores. Nada en la página renderizada está mal; 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 el CMap ToUnicode que PDFium genera en FPDFText_LoadFont está indexado por glifo, y un glifo se puede alcanzar desde dos code points. En Arial, el glifo 3 sirve tanto a U+0020 como a U+00A0, y el glifo del guion sirve tanto a U+002D como a U+00AD. El CMap generado mapea por tanto el mismo CID dos veces, una vía una entrada bfchar y otra vía un bfrange en forma de arreglo, y la entrada que favorezca la regla de precedencia del lector se vuelve el texto extraído

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

Por mucho tiempo esa contradicción fue inofensiva, porque el lector de PDFium dejaba ganar al mapeo más bajo. Un cambio upstream volvió al lector gana-el-último, y desde ese build cada espacio escrito con AddText se extraía como NBSP y cada guion como soft hyphen. Note el patrón en los pares: 0x20/0xA0 y 0x2D/0xAD difieren solo en el bit alto, que es exactamente lo que cabría esperar de una fuente cuyo cmap manda los pareados de Latin-1 al mismo outline. Si su código de extracción estaba bien ayer y ahora falla con caracteres invisibles, dumpee los code points en lugar de confiar en la vista del debugger; lo básico de sacar texto está cubierto en extraer texto de documentos PDF con PDFium en Delphi

uses
  SysUtils, PDFium;

const
  // Espacio/U+00A0 y guion/U+00AD comparten un glifo de Arial, igual que
  // Omega griega (U+03A9) y el signo 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 recarga
  finally
    Pdf.Free;
  end;

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

Por qué parchar el CMap después de guardar no bastaba

Parchar el archivo guardado arregla solo el archivo guardado, y solo si el parche mantiene la estructura del CMap intacta byte a byte. El primer fix, RepairSubsetToUnicodeCMaps en la unidad FPdfCompress, corre después de cada TPdf.SaveAs no incremental y resuelve cada CID en conflicto: gana la entrada bfchar, un par que difiere solo en el bit alto se resuelve al code point base-Latin menor, y todo lo demás conserva su primer mapeo

La parte interesante es el resultado negativo. Reconstruir el CMap en conflicto limpiamente, en forma de start-code o de arreglo, parecía el movimiento obvio, y PDFium rechazó cada CMap reconstruido sin apelación, cayendo de vuelta 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 la época culpaba al caso en memoria de que el documento vivo no tenía stream ToUnicode alguno. Llamar a la DLL directamente lo desmintió, ya que el documento vivo carga el mismo stream ambiguo, lo que significaba que el fix de verdad tenía que ocurrir antes de que PDFium generara el CMap. La rutina de reparación se queda en la librería como defensa para 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 un conflicto reparable,
      // y los archivos con cross-reference-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 el CMap. TPdf.LoadCachedFont ahora le pasa los bytes de la fuente del sistema a TPdf.LoadUnicodeKeyedCidFont, que lee la tabla cmap sfnt de la propia fuente, prefiriendo una subtabla formato 12 y cayendo al formato 4. Los code points vuelven ordenados y sin duplicados, y al k-ésimo code point se le asigna el CID k+1, dejando el CID 0 como .notdef. Un CIDToGIDMap explícito manda cada CID a su glifo, así que U+0020 y U+00A0 reciben dos CIDs distintos que dibujan el mismo outline, y el CMap ToUnicode mapea cada CID a un solo code point. La fuente se carga luego por FPDFText_LoadCidType2Font, el mismo punto de entrada detrás de la escritura a nivel de glifo en incrustación de fuentes CID Type 2 con mapas CID-to-GID explícitos

El fix indexado por code point en PDFium Component: LoadUnicodeKeyedCidFont lee el cmap sfnt de la fuente, asigna CID k+1 a cada code point ordenado con CID 0 como notdef, conecta 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, soft hyphen y el signo Ohm sobreviven entonces como ellos mismos bajo cualquiera de las dos reglas de precedencia, en el documento vivo y tras cualquier guardado, así que la reparación del 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 luego un string, el lookup inverso aterriza en un único CID por carácter, así que NBSP, soft hyphen y el signo Ohm sobreviven cada uno como él mismo bajo cualquiera de las dos reglas de precedencia, en memoria y tras cualquier guardado. Como el archivo guardado carga 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 borrar un bloque entero?

Un solo bfrange cuya corrida de CIDs cruza una frontera xxFF hace que PDFium descarte el bloque entero donde vive. ISO 32000-1 §9.10.3 solo permite que el último byte del destino varíe dentro de un rango, pero el lado CID tiene su propia trampa: el HandleBeginBFRange de PDFium deriva el CID alto como (low and $FFFFFF00) or (high and $FF). Una corrida del CID 00FE a 0101 se lee por tanto como 00FE a 0001, bajo mayor que alto, y el bloque entero queda marcado 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 CMaps PDF: una corrida de CIDs de 00FE a 0101 cruza una frontera xxFF, HandleBeginBFRange deriva el CID alto como 0001, bajo mayor que alto marca el bloque entero inválido, SetText y el renderizado siguen teniendo éxito, y la extracción devuelve U+0000 para cada carácter del bloque
BuildUnicodeKeyedCidCMap evita la trampa terminando cada corrida 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 termina una corrida 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 CMap, y escribe los code points de planos suplementarios como entradas bfchar individuales con destinos en pares surrogate UTF-16, ya que incrementar un par surrogate dentro de un rango no tiene significado definido; el lado surrogate de esa historia está en manejo de emoji, CJK y pares surrogate en Delphi. Un CMap solo-bfchar esquivaría el problema de frontera por completo, a cambio de varios 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 de vuelta al viejo comportamiento indexado por glifo para el resto. Las fronteras que vale conocer antes de confiar en esto:

  • Las fuentes Symbol con solo un 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 puede igual extraerse de forma ambigua ahí
  • Sin una subtabla formato 12 el mapa queda limitado al BMP, y el conteo de entradas se tapa en 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 componente escribe por sí mismo
  • Las TrueType Collections requieren cuidado extra: el 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 antes incrustaba y renderizaba SimSun, la face 0. El componente ahora empareja el nombre de familia contra la tabla name (nameID 1 y 16) y extrae la face pedida como un sfnt independiente antes de parsear el cmap; si el parseo falla, los bytes de la colección pasan tal cual y el comportamiento vuelve a la face 0

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