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
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
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
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
GetFontDatade GDI devuelve el .ttc entero, yFPDFText_LoadCidType2Fontno 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,
AddTextle pasa el nombre de fuente aFPDFPageObj_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