Artículo técnico

ToUnicode indexado por code point: NBSP y guión suave

Una búsqueda de "two-x" en un PDF recién generado puede volver con cero resultados mientras la página muestra claramente esos cinco caracteres. Volcad el string extraído como code points y la razón está ahí mismo: el guión volvió como U+00AD SOFT HYPHEN, y el espacio posterior como U+00A0 NO-BREAK SPACE. En pantalla ambos se ven idénticos. La diferencia vive en la CMap ToUnicode, la tabla que un lector consulta para convertir los códigos de carácter de vuelta a Unicode, y su origen es un glifo de la fuente compartido por dos code points

Las herramientas de cadena corrientes empeoran el desajuste. Un export CSV parte la línea de otra manera, una herramienta de diff marca dos líneas que se leen igual en cualquier visor, y un corrector ortográfico trata "two-x" con guión suave como una palabra desconocida. La normalización Unicode solo os rescata a medias: NFKC pliega U+00A0 en un espacio ordinario porque NBSP lleva una descomposición de compatibilidad, pero U+00AD no tiene descomposición alguna, así que el guión suave sobrevive a todas las formas de normalización

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

Los espacios extraídos se convierten en U+00A0 cuando la CMap ToUnicode 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. Una CMap construida desde el lado del glifo tiene que decir algo sobre el CID 0003, y el generador de PDFium en FPDFText_LoadFont acaba diciendo dos cosas: una entrada bfchar que lo mapea a U+0020, y un bfrange en forma de array que mapea el mismo CID a U+00A0

Cómo un glifo de Arial confunde 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
Bajo la precedencia de gana-el-más-bajo el espacio se queda plano; bajo gana-el-último, la regla que aplica el lector de PDFium, una CMap indexada por glifo extrae cada espacio como NBSP y cada guión como guión suave
1 beginbfchar
<0003> <0020>
endbfchar
1 beginbfrange
<0003> <0010> [<00A0> ...]
endbfrange

ISO 32000-1 §9.10.3 describe cómo bfchar y bfrange mapean códigos a Unicode, pero no dice qué entrada gana cuando dos de ellas reclaman el mismo CID. Cada lector escoge su propia regla de precedencia. Un lector que conserva el mapeo más bajo o el primero extrae un espacio plano; el lector de PDFium conserva el último, y como el rango en array va detrás del bloque bfchar, cada espacio escrito mediante una CMap indexada por glifo se extrae como NBSP y cada guión como guión suave. El mismo texto puede, por tanto, extraerse de forma distinta en dos visores del mismo fichero

Los pares siguen un patrón. 0x20/0xA0 y 0x2D/0xAD solo difieren en el bit alto, que es justo lo que produce una fuente cuyo cmap manda los look-alikes de Latin-1 al contorno ya usado por el carácter ASCII. La Omega griega mayúscula (U+03A9) y el símbolo Ohm (U+2126) comparten glifo en Arial también. Si vuestro código de extracción pasaba antes y ahora falla en caracteres que no podéis ver, imprimid los code points en vez de fiaros de la vista de cadena del debugger; lo básico de sacar texto de una página está en cómo extraer texto de documentos PDF con PDFium en Delphi

Cómo indexa PDFium Component fuentes del sistema por code point

PDFium Component nunca le pide al motor que genere esa CMap para fuentes del sistema que llevan una cmap Unicode. Cuando TPdf.AddText necesita una fuente, el componente 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. U+0020 y U+00A0 obtienen así dos CIDs distintos que dibujan el mismo contorno, y la CMap ToUnicode que escribe el componente mapea cada CID a exactamente un code point

Asignación de CIDs indexada por code point en PDFium Component: el componente 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 escribe una CMap ToUnicode que 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

La fuente se carga mediante FPDFText_LoadCidType2Font, el mismo punto de entrada detrás de la escritura a nivel de glifo en el embebido de fuentes CID Type 2 con mapas CID-to-GID explícitos. Cuando FPDFText_SetText escribe después un string, la búsqueda inversa aterriza en un único CID por carácter. Nada de eso depende de la regla de precedencia del lector, porque no hay un segundo mapeo entre el que la regla tenga que elegir. El fichero guardado lleva además el stream ToUnicode del propio componente, así que la extracción tras recargar ve la misma tabla uno a uno que usaba el documento vivo

El coste es un CIDToGIDMap más grande: dos bytes por code point que cubra la fuente. Supongamos una fuente que mapea unos 3,000 code points. El mapa sale entonces a unos 6 KB, entregados al motor con cada fuente. Ese intercambio merece la pena siempre. Unos pocos kilobytes compran una extracción que se lee igual en PDFium, en los visores que conservan el primer mapeo y en el indexador que vuestro cliente estrene el año que viene

El round trip de abajo escribe un string que contiene todos los pares problemáticos, lo extrae del documento sin guardar, guarda, recarga y extrae de nuevo. Ambos strings deberían coincidir exactamente con la entrada; el ayudante CodePoints está ahí solo para que un desajuste se pueda leer:

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.

Dejad el NBSP y el guión suave en el string de muestra aunque vuestros documentos casi nunca los contengan. Un test que escribe solo ASCII pasa también contra una CMap indexada por glifo, porque nada en él le pide jamás al lector que elija entre dos mapeos

¿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, y el lado del CID tiene su trampa gemela en el parser de PDFium: deriva el CID alto como (low and $FFFFFF00) or (high and $FF). Un tramo del CID 00FE al 0101 se lee como 00FE a 0001, bajo mayor que alto, y el bloque entero se marca inválido. SetText tiene éxito, la página renderiza correctamente, y la extracción devuelve U+0000 para cada carácter de ese bloque

La trampa del bfrange en el parseo de CMap PDF: un tramo de CIDs del 00FE al 0101 cruza un límite xxFF, el parser de PDFium 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
El escritor de CMap de PDFium Component 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

Una fuente indexada por code point es exactamente donde esto muerde. Los CIDs son densos y secuenciales, así que cualquier fuente con más de 255 code points produce tramos de CIDs que cruzarían 00FF, 01FF y demás si el escritor simplemente fusionara entradas consecutivas. La CMap que escribe PDFium Component cierra un tramo antes de que el code point o el CID alcancen un byte bajo de FF, y parte las entradas en bloques de 100 como máximo, el límite que la gramática de CMap pone a cada sección beginbfrange y beginbfchar

Los code points por encima de U+FFFF reciben 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 la 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, razón por la que los rangos siguen siendo la forma principal y las reglas de corte cargan con el peso

Reparar conflictos ToUnicode en ficheros de otras herramientas

RepairSubsetToUnicodeCMaps en la unidad FPdfCompress arregla el conflicto indexado por glifo en ficheros que el componente no escribió. TPdf.SaveAs la ejecuta en cada guardado completo no incremental, y podéis llamarla directamente sobre cualquier stream. Para cada CID que lleva dos destinos gana la entrada bfchar. Sin bfchar para ese CID, un par que solo difiere en el bit alto se resuelve al code point base-Latín más pequeño. Cualquier otro conflicto conserva su primer mapeo

La reparación edita los valores hex en conflicto in situ, con reemplazos de igual longitud. El layout de bloques, la cobertura de CIDs y cualquier otro byte del stream se quedan como estaban, así que la CMap permanece en la forma que el lector de PDFium ya aceptó. Una CMap reconstruida limpia quedaría más bonita en un volcado hexadecimal. También cambiaría la estructura de un stream que un lector estricto ya aceptó, y eso es una apuesta sin ventaja cuando solo unos pocos valores de destino están mal

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.

La rutina reconstruye una tabla de referencias cruzadas clásica, así que los documentos que usan xref streams, referencias híbridas u object streams pasan byte a byte. Los guardados incrementales (saIncremental) se saltan la reparación a propósito, porque una revisión incremental debe seguir siendo append-only. Para el texto que el propio componente escribe, ninguno de los dos casos importa: sus fuentes indexadas por code point nunca producen un conflicto que la reparación pueda encontrar

Dónde aplica el camino indexado por code point

El camino indexado por code point cubre toda fuente que exponga una subtabla cmap Unicode, y la carga indexada por glifo de PDFium se encarga del resto. Detalles que conviene conocer antes de fiaros de él:

  • Las fuentes Symbol que solo llevan una cmap (3,0), y cualquier fuente que el camino CID no logre cargar, pasan por FPDFText_LoadFont, donde un glifo compartido por dos code points queda sujeto a la regla de precedencia del lector
  • Con solo una subtabla formato 4 el mapa cubre el Basic Multilingual Plane; una subtabla formato 12 en la plataforma (3,10) o la plataforma Unicode añade code points de planos suplementarios
  • El glifo 0 y los code points sustitutos se quedan fuera, y el conteo de entradas se cap a 65535 para que cada CID quepa en dos bytes por encima de cero
  • 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 pasar la colección tal cual embebería la face 0, SimSun, cuando pedisteis NSimSun desde simsun.ttc. El componente iguala el nombre de familia contra la tabla name de la fuente (nameID 1 y 16) y entrega la face pedida como un sfnt independiente; una colección que no pueda parsear pasa entera y usa la face 0
  • Si no se puede leer ninguna fuente del sistema con el nombre pedido, AddText le pasa el nombre de fuente a FPDFPageObj_NewTextObj, que la busca entre las fuentes estándar de PDFium

Plegar los caracteres invisibles en el índice, conservarlos en el texto

Una vez que la extracción devuelve los code points reales, surge otra pregunta: ¿qué debería hacer un índice de búsqueda con un NBSP o un guión suave genuino que el autor tecleó a propósito? Plegadlos en la capa de indexación y dejad TPdf.Text en paz. El string extraído es el registro de lo que dice el documento; un índice es una vista con pérdida construida para coincidir, y las reglas de plegado son cosa suya

El ayudante de abajo es una función que añadís a vuestro propio código. Mapea el NBSP a un espacio y descarta el guión suave, que según el Unicode Standard solo marca un lugar donde la línea puede partirse. Deja U+2126 intacto a propósito. La normalización NFC convertiría el símbolo Ohm en Omega, porque U+2126 tiene una descomposición singleton canónica a U+03A9, y si una búsqueda de datasheets debería coincidir con ambos es una decisión de producto, no de extracción:

uses
  SysUtils, Classes, PDFium;

// Ayudante vuestro para un índice de búsqueda; no forma parte de PDFium Component
function FoldForIndex(const S: string): string;
begin
  Result := StringReplace(S, #$00A0, ' ', [rfReplaceAll]);
  Result := StringReplace(Result, #$00AD, '', [rfReplaceAll]);
end;

procedure IndexDocument(const FileName: string; Lines: TStrings);
var
  Pdf: TPdf;
  P: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;
    for P := 1 to Pdf.PageCount do
    begin
      Pdf.PageNumber := P;
      Lines.Add(FoldForIndex(Pdf.Text));   // el texto exacto se queda en Pdf.Text
    end;
  finally
    Pdf.Free;
  end;
end;

Ejecutad IndexDocument sobre el fichero codepoints.pdf del primer ejemplo y la línea indexada debería decir "two-x yz ΩΩ", once caracteres con un símbolo Ohm como último, mientras Pdf.Text sigue devolviendo los doce caracteres originales, NBSP y guión suave incluidos

Las fuentes indexadas por code point, la reparación de CMap y la extracción de texto se apoyan en el mismo modelo de página en Delphi, C++Builder y Lazarus; la API de fuentes y texto está cubierta por completo en la página de producto de PDFium Component para Delphi