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
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
// 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
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_LoadFontcomo 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 saltanRepairSubsetToUnicodeCMapsa 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
GetFontDatade GDI devuelve el .ttc entero, yFPDFText_LoadCidType2Fontno 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