Una factura china compuesta en Noto Sans SC desde su fichero .otf puede abrirse en mutool con ideogramas que no pintan nada donde debería ir el nombre del cliente, mientras que una fuente TrueType dibuja la misma página sin fallo. El culpable es el charset CFF de la fuente; PDFlibPas lo reescribe a la identidad en la copia incrustada, así que los códigos de índice de glifo llegan al glifo previsto
Que una fuente sufra el problema depende de cómo se construyó. Un programa TrueType incrustado como fuente CIDFontType2 recibe un CIDToGIDMap de tipo Identity, y el código del content stream aterriza en el glifo buscado. La mayoría de las fuentes latinas OpenType con contornos CFF son name-keyed, y un programa CFF name-keyed usa el CID entrante directamente como índice de glifo. ISO 32000-1 llega a señalar en §9.7.4.2 que CID y GID coinciden en muchas fuentes. No en todas. Las grandes familias CJK construidas como fuentes CID-keyed — Noto Sans CJK y sus hermanas Source Han entre ellas — llevan su propia numeración de CID, y un writer que manda los índices de glifo directo a esa numeración se lleva el glifo que casualmente tenga el CID correspondiente
Qué hace un programa CFF CID-keyed con un CID
ISO 32000-1 §9.7.4.2 divide los programas CFF incrustados en dos casos según el Top DICT. Si el Top DICT usa los operadores CIDFont — en la práctica, abre con ROS (operador 12 30: registry, ordering, supplement) — el lector toma el CID del content stream, busca el glifo cuya entrada del charset lleva ese CID y solo entonces va a por el contorno al CharStrings INDEX. Sin operadores CIDFont, el CID es el índice de glifo y el charset no pinta nada. Cualquier lector que siga §9.7.4.2 al pie de la letra dibujará el glifo equivocado para un código de índice de glifo siempre que el charset no sea la identidad, uses el viewer que uses. El charset en sí, definido en Adobe Technical Note #5176, viene en tres formatos con dos casos especiales:
- El formato 0 guarda un SID o CID de 2 bytes por glifo, en orden de glifo
- El formato 1 guarda rangos: un primer CID seguido de un contador de 1 byte con los glifos que le siguen con CIDs consecutivos
- El formato 2 es el mismo diseño de rangos con un contador de 2 bytes, ideal para fuentes con series largas sin cortes
- El glifo 0 es
.notdefy no tiene entrada en ningún formato; un offset de charset de 0, 1 o 2 nombra uno de los charsets predefinidos en lugar de apuntar a datos
Así que lo primero es averiguar qué clase de fuente tienes. Identity-H en el lado PDF solo dice que un código de 2 bytes es igual al CID; cómo ese CID llega a un glifo vive por entero dentro del programa de la fuente. La función de abajo lee la tabla CFF de un .otf, recorre el Top DICT en busca de ROS y comprueba si el charset mapea cada glifo a un CID con el mismo número. Solo necesita la RTL
uses
System.SysUtils, System.IOUtils;
type
TCFFCharsetReport = record
IsCFF, CIDKeyed, IdentityCharset: Boolean;
CharsetFormat, GlyphCount: Integer;
end;
{$RANGECHECKS ON} // un fichero truncado lanza ERangeError en lugar de leer mal
function InspectOtfCharset(const FileName: string): TCFFCharsetReport;
var
B: TBytes;
NumTables, I, Cff, P, Dict, DictEnd, Unused1, Unused2: Integer;
Op, Operand, CharsetOfs, CharStringsOfs, G, Left: Integer;
function BE(Pos, Count: Integer): Integer;
var
K: Integer;
begin
Result := 0;
for K := 0 to Count - 1 do
Result := (Result shl 8) or B[Pos + K];
end;
// Devuelve el final del INDEX situado en Pos y acota su primer objeto
function SkipIndex(Pos: Integer; out ObjStart, ObjEnd: Integer): Integer;
var
Count, OffSize, Base: Integer;
begin
Count := BE(Pos, 2);
if Count = 0 then
begin
ObjStart := Pos + 2;
ObjEnd := ObjStart;
Exit(Pos + 2);
end;
OffSize := B[Pos + 2];
Base := Pos + 2 + (Count + 1) * OffSize; // los offsets son 1-based desde aquí
ObjStart := Base + BE(Pos + 3, OffSize);
ObjEnd := Base + BE(Pos + 3 + OffSize, OffSize);
Result := Base + BE(Pos + 3 + Count * OffSize, OffSize);
end;
begin
Result := Default(TCFFCharsetReport);
Result.CharsetFormat := -1;
B := TFile.ReadAllBytes(FileName);
if (Length(B) < 12) or (BE(0, 4) <> $4F54544F) then // 'OTTO'
Exit;
Cff := -1;
NumTables := BE(4, 2);
for I := 0 to NumTables - 1 do
if BE(12 + I * 16, 4) = $43464620 then // 'CFF '
Cff := BE(12 + I * 16 + 8, 4);
if Cff < 0 then
Exit;
Result.IsCFF := True;
P := SkipIndex(Cff + B[Cff + 2], Unused1, Unused2); // Name INDEX
SkipIndex(P, Dict, DictEnd); // Top DICT INDEX
Operand := 0;
CharsetOfs := 0;
CharStringsOfs := 0;
P := Dict;
while P < DictEnd do
begin
Op := B[P];
case Op of
12:
begin
if B[P + 1] = 30 then
Result.CIDKeyed := True; // ROS
Inc(P, 2);
end;
0..11, 13..21:
begin
if Op = 15 then
CharsetOfs := Operand
else if Op = 17 then
CharStringsOfs := Operand;
Inc(P);
end;
28: begin Operand := SmallInt(BE(P + 1, 2)); Inc(P, 3); end;
29: begin Operand := BE(P + 1, 4); Inc(P, 5); end;
30:
begin // número real
repeat
Inc(P);
until ((B[P] and $F) = $F) or ((B[P] shr 4) = $F);
Inc(P);
end;
32..246: begin Operand := Op - 139; Inc(P); end;
247..250: begin Operand := (Op - 247) * 256 + B[P + 1] + 108; Inc(P, 2); end;
251..254: begin Operand := -(Op - 251) * 256 - B[P + 1] - 108; Inc(P, 2); end;
else
Exit; // byte reservado
end;
end;
if (not Result.CIDKeyed) or (CharsetOfs <= 2) or (CharStringsOfs <= 0) then
Exit;
Result.GlyphCount := BE(Cff + CharStringsOfs, 2);
P := Cff + CharsetOfs;
Result.CharsetFormat := B[P];
if Result.CharsetFormat > 2 then
Exit;
Inc(P);
Result.IdentityCharset := True;
G := 1; // .notdef no tiene entrada
while G < Result.GlyphCount do
begin
if BE(P, 2) <> G then
Result.IdentityCharset := False;
case Result.CharsetFormat of
0: Left := 0;
1: Left := B[P + 2];
else
Left := BE(P + 2, 2);
end;
Inc(P, Result.CharsetFormat + 2);
Inc(G, Left + 1);
end;
end;
Un informe con CIDKeyed = True y IdentityCharset = False describe justo la fuente de este artículo: cualquier writer que meta índices de glifo en el content stream sin contar con ese charset dibujará glifos equivocados con ella. Un resultado name-keyed, o una fuente CID-keyed cuyo charset ya sea la identidad de casualidad, se dibuja bien con el mismo writer. Así es como una fuente CJK de un producto funciona y la siguiente no. El bucle compara solo el primer CID de cada rango, y basta: un rango cubre glifos consecutivos con CIDs consecutivos, así que un rango que arranca en su propio índice de glifo se queda en él hasta el final
Los índices de glifo atraviesan todo el diccionario de fuentes
Un writer PDF rara vez tiene CIDs a mano. La tabla cmap y el motor de shaping devuelven índices de glifo, así que el diseño natural escribe esos índices como códigos Identity-H de 2 bytes y ancla todo lo demás a ellos: el array de anchos /W, el bitmap /CIDSet que pide PDF/A y la CMap ToUnicode que permite a la extracción recuperar el texto original. TrueType tiene una vía de escape: la Tabla 117 ofrece CIDToGIDMap, pero solo para CIDFonts de tipo 2; un programa CFF vive en una fuente CIDFontType0, que no tiene forma de redirigir un CID desde el lado PDF. Eso también descarta el truco que algunos writers usan con TrueType — dar a un glifo reutilizado un segundo CID "alias" más allá del final de la fuente, para que ToUnicode pueda asociar texto distinto a cada uso. En una fuente CFF ese alias no selecciona ningún glifo, y mutool informa de que no puede dibujarlo. Las fuentes que mapean U+4E00 y U+2F00 (KANGXI RADICAL ONE) a un mismo contorno son el caso habitual. Si tu documento necesita que ambos se extraigan de forma distinta, envuelve la aparición en un span de marked-content con /ActualText (ISO 32000-1 §14.9.4), que el lector usa en lugar del mapeo de la fuente
Reescribir el charset in situ
Hay dos caminos para reconciliar los códigos de índice de glifo con un programa CID-keyed. Uno traduce cada índice de glifo al CID de la fuente antes de escribirlo, lo que toca los códigos del content stream, las claves de /W, las posiciones de bit de /CIDSet, los códigos fuente de ToUnicode y cada subset en el guardado. El otro cambia la única estructura que discrepa, el charset, para que la fuente responda a los índices de glifo como lo haría un programa name-keyed. El segundo camino es el seguro: el shaper, la tabla de anchos, el subsetter y el generador de ToUnicode siguen trabajando en espacio de glifo, y el único código que tiene que entender CIDs es una rutina corta que se ejecuta una vez sobre la copia incrustada de la fuente. PDFlibPas toma ese camino
La reescritura conserva el formato del charset y su longitud en bytes. En el formato 0 cada entrada se sobrescribe con su propio índice de glifo. En los formatos 1 y 2 solo cambia el primer CID de cada rango, y los contadores se quedan como están. Nada crece ni encoge. Los offsets del Top DICT hacia CharStrings, los Private DICTs, FDArray y FDSelect siguen válidos sin recodificar nada, y FDSelect — que asigna diccionarios de hinting por índice de glifo — deja cada glifo en su FD original. Los programas name-keyed, los charsets predefinidos, las tablas CFF2 (no tienen charset) y los programas que no parsean se dejan tranquilos. Tres rutas de código ejecutan la reescritura:
AddTrueTypeFontFromFilesobre un.otfincrusta la tablaCFFdesnuda comoFontFile3con subtypeCIDFontType0C, con el charset ya reescrito- El subset del guardado conserva los índices de glifo originales pero también los CIDs de origen, así que su charset se reescribe una segunda vez
- El compañero de shaping detrás de
DrawOpenTypeTextyDrawShapedTextconserva el sfnt entero; tras la reescritura se recalcula el checksum de la tablaCFF, y elcheckSumAdjustmentde la tablaheadpasa a ser0xB1B0AFBAmenos la suma del fichero - Ese ajuste está definido solo para una fuente única, así que una fuente sacada de una colección se queda con el checksum de tabla solamente
uses
SysUtils, PDFlibrary;
var
Lib: TPDFlib;
FontID: Integer;
begin
Lib := TPDFlib.Create;
try
// Un .otf con contornos CFF se incrusta como FontFile3 /CIDFontType0C;
// la copia incrustada recibe un charset de identidad cuando es CID-keyed
FontID := Lib.AddTrueTypeFontFromFile('C:\Fonts\NotoSansSC-Regular.otf');
if FontID = 0 then
raise Exception.Create('the font file could not be read or parsed');
Lib.SetTextSize(18);
Lib.DrawText(56, 760, '季度报表:华东区销售汇总');
Lib.DrawText(56, 730, '发票号码 2026-11-0187,金额 ¥12,480.00');
if Lib.SaveToFile('noto-sc.pdf') <> 1 then
raise Exception.CreateFmt('save failed, error %d', [Lib.LastErrorCode]);
finally
Lib.Free;
end;
end;
Comprueba la salida renderizándola; la extracción no revela el problema. pdftotext y cualquier otro extractor leen ToUnicode, que se ancla al código del content stream y no sabe nada sobre qué contorno elige la fuente para él, así que una página con ideogramas equivocados sigue extrayendo la cadena correcta. mutool draw -r 150 -o page%d.png noto-sc.pdf muestra cómo es la página de verdad, y para esta ruta DrawText el pdffonts noto-sc.pdf de poppler debería listar la fuente como CID Type 0C. Lo esperado: ambas líneas se dibujan en Noto Sans SC con los glifos que nombra el texto original, y Ghostscript produce la misma salida
¿Qué forma de incrustación necesita PDF 1.6?
La de subtype OpenType. La Tabla 126 de ISO 32000-1 lista un stream FontFile3 de subtype CIDFontType0C como característica de PDF 1.3, mientras que el subtype OpenType — el sfnt entero con su tabla CFF — aparece por primera vez en PDF 1.6. Los documentos nuevos de PDFlibPas arrancan en la versión 1.5, donde cabe la ruta DrawText de CFF puro. La primera llamada a DrawOpenTypeText, DrawShapedText o escritura vertical con una fuente CFF crea un compañero OpenType y eleva el documento a 1.6 en el momento, algo que GetPDFVersion reporta de inmediato. Un bloqueo de versión lo cambia todo: si LockSaveVersion ha fijado el documento más abajo, la elevación se rechaza y SaveToFile devuelve 0 con LastErrorCode 602, así que ningún fichero reclama una versión que sus fuentes no cumplen. Cuando el perfil de entrega fija la versión por debajo de 1.6 — PDF/A-1 es el caso común, pues se apoya en PDF 1.4 — escribe texto CJK con DrawText y el .otf, o usa un corte TrueType de la familia para lo que necesite shaping
var
Lib: TPDFlib;
FontID: Integer;
begin
Lib := TPDFlib.Create;
try
FontID := Lib.AddTrueTypeFontFromFile('NotoSansSC-Regular.otf');
Writeln('before: ', Lib.GetPDFVersion); // Esperado: 1.5
if Lib.DrawOpenTypeText(56, 760, FontID, 18, '《年度报告》第三章', 0) = 1 then
Writeln('after: ', Lib.GetPDFVersion); // Esperado: 1.6
Lib.SaveToFile('shaped.pdf'); // Esperado en pdffonts: CID Type 0C (OT)
finally
Lib.Free;
end;
Lib := TPDFlib.Create;
try
Lib.LockSaveVersion('1.5');
FontID := Lib.AddTrueTypeFontFromFile('NotoSansSC-Regular.otf');
Lib.DrawOpenTypeText(56, 760, FontID, 18, '《年度报告》第三章', 0);
if Lib.SaveToFile('locked.pdf') = 0 then
Writeln('save refused, error ', Lib.LastErrorCode); // Esperado: 602
finally
Lib.Free;
end;
end;
Anchos y ToUnicode anclados a los mismos números
Cuando el charset es la identidad, el CID, el índice de glifo y el código del content stream son un solo número. Los anchos lo siguen. ISO 32000-1 §9.7.4.3 indexa /W por el propio CID, en grupos de un CID inicial seguido de un array de anchos o de un primer CID, un último CID y un ancho compartido, sin el offset /FirstChar de las fuentes simples. Cualquier CID que el array omita toma /DW, y la Tabla 117 fija el valor por defecto de /DW en 1000. Cero está mal: un lector que recurre a 0 para los CIDs no listados aplasta una línea de ideogramas sobre sí misma. Para un .otf cargado con AddTrueTypeFontFromFile, PDFlibPas escribe un array /W que arranca en el CID 0 y lista el avance de cada glifo, junto a un /DW 1000 explícito. En el lado de lectura, el texto CJK que se extrae como basura suele fallar en la mitad de codespace del mismo pipeline, y una auditoría de fuentes PDF/UA de anchos, CharSet y CIDSet confronta estos arrays con el programa incrustado
ToUnicode tiene su propia regla de numeración. Una entrada bfrange guarda solo la primera cadena de destino, y §9.10.3 exige que su último byte no quede empujado más allá de 255 por la longitud del rango, porque un lector genera los destinos siguientes incrementando solo ese último byte. Los glifos consecutivos que mapean a U+4EFE, U+4EFF, U+4F00 y U+4F01 parecen un solo rango, pero no lo son: un lector decodificaría el tercero como U+4E00. El writer de PDFlibPas fusiona pares vecinos de glifo a Unicode en un solo bfrange solo cuando tanto los códigos fuente como los destinos quedan dentro de un mismo bloque de 256, y parte la serie en caso contrario. La escritura CJK vertical pasa por las mismas fuentes compañeras, así que el texto japonés y chino vertical hereda el charset de identidad y estos rangos ToUnicode sin llamadas extra
Toda fuente que InspectOtfCharset reporte como CID-keyed con un charset que no sea la identidad es una donde los códigos de índice de glifo y la fuente discrepan. Incrustada a través de PDFlibPas para Delphi, esa fuente conserva sus contornos, offsets y longitud de charset, y lo que cambia son los CIDs de esa única tabla, más los checksums cuando se conserva el sfnt entero