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
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
// 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
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_LoadFontcomo 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 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 componente escribe por sí mismo - Las TrueType Collections requieren cuidado extra: el
GetFontDatade GDI devuelve el .ttc entero, yFPDFText_LoadCidType2Fontno 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